Fix: Security, reliability, and code quality improvements from PR review
Critical Security Fixes: - Fix command injection vulnerability in Windows shims (beadboard.cmd, bb.cmd) - Added path validation to block traversal (.. and root-relative paths) - Added quotes around env var to prevent command injection Reliability Fixes: - Fix agent cache null safety bug - Fixed callBdAgentShow() to check for cache misses (null check, expiration) - Fixed getCachedAgent to properly return entry.data or null - Fix null body crashes in mail ack route - Added null check before casting body to object - Returns 400 error instead of 500 for invalid requests BD Compliance Fixes: - Fix read-issues to use BD audit record path - Ensures all writes go through bd audit record - Maintains watcher/SSE parity and Dolt commit tracking Code Quality Fixes: - Fix path canonicalization violations - Use canonicalizeWindowsPath() and windowsPathKey() from pathing module - Prevents Windows edge cases and ensures machine-reproducible paths - Fix typo: mobile-fronted → mobile-frontend - Pin GitHub Actions tags - softprops/action-gh-release@v1 → specific commit hash - Register pr14 test in package.json (already registered) Testing: - Refactor broad exception handlers in Python scripts - Replace except Exception: with specific exceptions - Allows KeyboardInterrupt and SystemExit to propagate correctly - All tests passing
This commit is contained in:
parent
d54e4f3311
commit
ce4700849b
15 changed files with 2995 additions and 756 deletions
|
|
@ -22,7 +22,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@26994186c0ac3ef5cae75ac16aa32e8153525f77
|
||||||
with:
|
with:
|
||||||
name: ${{ github.ref_name || inputs.version }}
|
name: ${{ github.ref_name || inputs.version }}
|
||||||
tag_name: ${{ github.ref_name || inputs.version }}
|
tag_name: ${{ github.ref_name || inputs.version }}
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ def infer_project_name(project_dir: Path) -> str:
|
||||||
data = json.loads(package_json.read_text())
|
data = json.loads(package_json.read_text())
|
||||||
if name := data.get("name"):
|
if name := data.get("name"):
|
||||||
return name.replace("-", " ").replace("_", " ").title()
|
return name.replace("-", " ").replace("_", " ").title()
|
||||||
except (json.JSONDecodeError, KeyError):
|
except (json.JSONDecodeError, KeyError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try pyproject.toml (Python)
|
# Try pyproject.toml (Python)
|
||||||
if tomllib:
|
if tomllib:
|
||||||
|
|
@ -73,7 +73,7 @@ def infer_project_name(project_dir: Path) -> str:
|
||||||
return name.replace("-", " ").replace("_", " ").title()
|
return name.replace("-", " ").replace("_", " ").title()
|
||||||
if name := data.get("tool", {}).get("poetry", {}).get("name"):
|
if name := data.get("tool", {}).get("poetry", {}).get("name"):
|
||||||
return name.replace("-", " ").replace("_", " ").title()
|
return name.replace("-", " ").replace("_", " ").title()
|
||||||
except Exception:
|
except (tomllib.TOMLDecodeError, OSError, KeyError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try Cargo.toml (Rust)
|
# Try Cargo.toml (Rust)
|
||||||
|
|
@ -83,7 +83,7 @@ def infer_project_name(project_dir: Path) -> str:
|
||||||
data = tomllib.loads(cargo.read_text())
|
data = tomllib.loads(cargo.read_text())
|
||||||
if name := data.get("package", {}).get("name"):
|
if name := data.get("package", {}).get("name"):
|
||||||
return name.replace("-", " ").replace("_", " ").title()
|
return name.replace("-", " ").replace("_", " ").title()
|
||||||
except Exception:
|
except (tomllib.TOMLDecodeError, OSError, KeyError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try go.mod (Go)
|
# Try go.mod (Go)
|
||||||
|
|
@ -96,7 +96,7 @@ def infer_project_name(project_dir: Path) -> str:
|
||||||
module_path = line.split()[1]
|
module_path = line.split()[1]
|
||||||
name = module_path.split("/")[-1]
|
name = module_path.split("/")[-1]
|
||||||
return name.replace("-", " ").replace("_", " ").title()
|
return name.replace("-", " ").replace("_", " ").title()
|
||||||
except Exception:
|
except (OSError, ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback to directory name
|
# Fallback to directory name
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from dataclasses import dataclass, field
|
||||||
# Try to import tiktoken for accurate token counting
|
# Try to import tiktoken for accurate token counting
|
||||||
try:
|
try:
|
||||||
import tiktoken
|
import tiktoken
|
||||||
|
|
||||||
TIKTOKEN_AVAILABLE = True
|
TIKTOKEN_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
TIKTOKEN_AVAILABLE = False
|
TIKTOKEN_AVAILABLE = False
|
||||||
|
|
@ -26,6 +27,7 @@ except ImportError:
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChunkResult:
|
class ChunkResult:
|
||||||
"""Result of chunking a piece of content."""
|
"""Result of chunking a piece of content."""
|
||||||
|
|
||||||
content: str
|
content: str
|
||||||
tokens: int
|
tokens: int
|
||||||
type: str
|
type: str
|
||||||
|
|
@ -59,7 +61,7 @@ class ChunkingEngine:
|
||||||
if TIKTOKEN_AVAILABLE:
|
if TIKTOKEN_AVAILABLE:
|
||||||
try:
|
try:
|
||||||
self._encoder = tiktoken.get_encoding("cl100k_base")
|
self._encoder = tiktoken.get_encoding("cl100k_base")
|
||||||
except Exception:
|
except (ImportError, AttributeError, ValueError, KeyError):
|
||||||
pass # Fall back to character-based estimation
|
pass # Fall back to character-based estimation
|
||||||
|
|
||||||
def count_tokens(self, text: str) -> int:
|
def count_tokens(self, text: str) -> int:
|
||||||
|
|
@ -81,7 +83,7 @@ class ChunkingEngine:
|
||||||
if self._encoder is not None:
|
if self._encoder is not None:
|
||||||
try:
|
try:
|
||||||
return len(self._encoder.encode(text))
|
return len(self._encoder.encode(text))
|
||||||
except Exception:
|
except (AttributeError, TypeError, ValueError):
|
||||||
pass # Fall back to approximation
|
pass # Fall back to approximation
|
||||||
|
|
||||||
# Character-based approximation: ~4 chars per token for English
|
# Character-based approximation: ~4 chars per token for English
|
||||||
|
|
@ -112,9 +114,14 @@ class ChunkingEngine:
|
||||||
|
|
||||||
# Decision indicators (highest priority - explicit actions)
|
# Decision indicators (highest priority - explicit actions)
|
||||||
decision_patterns = [
|
decision_patterns = [
|
||||||
r'\bdecided\b', r'\bchose\b', r'\bselected\b',
|
r"\bdecided\b",
|
||||||
r'\bgoing with\b', r'\bwent with\b', r'\bopted for\b',
|
r"\bchose\b",
|
||||||
r'\bsettled on\b', r'\bconcluded\b'
|
r"\bselected\b",
|
||||||
|
r"\bgoing with\b",
|
||||||
|
r"\bwent with\b",
|
||||||
|
r"\bopted for\b",
|
||||||
|
r"\bsettled on\b",
|
||||||
|
r"\bconcluded\b",
|
||||||
]
|
]
|
||||||
for pattern in decision_patterns:
|
for pattern in decision_patterns:
|
||||||
if re.search(pattern, content_lower):
|
if re.search(pattern, content_lower):
|
||||||
|
|
@ -123,10 +130,18 @@ class ChunkingEngine:
|
||||||
# Pattern indicators (habits, recurring behaviors) - check BEFORE preference
|
# Pattern indicators (habits, recurring behaviors) - check BEFORE preference
|
||||||
# because phrases like "generally prefer" describe patterns, not preferences
|
# because phrases like "generally prefer" describe patterns, not preferences
|
||||||
pattern_patterns = [
|
pattern_patterns = [
|
||||||
r'\busually\b', r'\boften\b', r'\btends to\b', r'\bpattern\b',
|
r"\busually\b",
|
||||||
r'\balways\b', r'\btypically\b', r'\bgenerally\b',
|
r"\boften\b",
|
||||||
r'\bfrequently\b', r'\bregularly\b', r'\bevery time\b',
|
r"\btends to\b",
|
||||||
r'\bmost of the time\b', r'\bwhenever\b'
|
r"\bpattern\b",
|
||||||
|
r"\balways\b",
|
||||||
|
r"\btypically\b",
|
||||||
|
r"\bgenerally\b",
|
||||||
|
r"\bfrequently\b",
|
||||||
|
r"\bregularly\b",
|
||||||
|
r"\bevery time\b",
|
||||||
|
r"\bmost of the time\b",
|
||||||
|
r"\bwhenever\b",
|
||||||
]
|
]
|
||||||
for pattern in pattern_patterns:
|
for pattern in pattern_patterns:
|
||||||
if re.search(pattern, content_lower):
|
if re.search(pattern, content_lower):
|
||||||
|
|
@ -134,9 +149,16 @@ class ChunkingEngine:
|
||||||
|
|
||||||
# Preference indicators
|
# Preference indicators
|
||||||
preference_patterns = [
|
preference_patterns = [
|
||||||
r'\bprefer\b', r'\blike\b', r'\bwant\b', r'\brather\b',
|
r"\bprefer\b",
|
||||||
r'\bdislike\b', r'\bhate\b', r'\bwish\b', r'\bwould like\b',
|
r"\blike\b",
|
||||||
r'\bfavorite\b', r'\bfavour\b'
|
r"\bwant\b",
|
||||||
|
r"\brather\b",
|
||||||
|
r"\bdislike\b",
|
||||||
|
r"\bhate\b",
|
||||||
|
r"\bwish\b",
|
||||||
|
r"\bwould like\b",
|
||||||
|
r"\bfavorite\b",
|
||||||
|
r"\bfavour\b",
|
||||||
]
|
]
|
||||||
for pattern in preference_patterns:
|
for pattern in preference_patterns:
|
||||||
if re.search(pattern, content_lower):
|
if re.search(pattern, content_lower):
|
||||||
|
|
@ -144,11 +166,23 @@ class ChunkingEngine:
|
||||||
|
|
||||||
# Fact indicators (statements of truth)
|
# Fact indicators (statements of truth)
|
||||||
fact_patterns = [
|
fact_patterns = [
|
||||||
r'\bis a\b', r'\bare a\b', r'\bworks as\b', r'\blocated in\b',
|
r"\bis a\b",
|
||||||
r'\bis an\b', r'\bare an\b', r'\bwas a\b', r'\bwere a\b',
|
r"\bare a\b",
|
||||||
r'\bworks at\b', r'\bworks for\b', r'\blives in\b',
|
r"\bworks as\b",
|
||||||
r'\bborn in\b', r'\bstudied at\b', r'\bgraduated from\b',
|
r"\blocated in\b",
|
||||||
r'\bhas\s+\d+', r'\bthere are\s+\d+', r'\bthere is\s+'
|
r"\bis an\b",
|
||||||
|
r"\bare an\b",
|
||||||
|
r"\bwas a\b",
|
||||||
|
r"\bwere a\b",
|
||||||
|
r"\bworks at\b",
|
||||||
|
r"\bworks for\b",
|
||||||
|
r"\blives in\b",
|
||||||
|
r"\bborn in\b",
|
||||||
|
r"\bstudied at\b",
|
||||||
|
r"\bgraduated from\b",
|
||||||
|
r"\bhas\s+\d+",
|
||||||
|
r"\bthere are\s+\d+",
|
||||||
|
r"\bthere is\s+",
|
||||||
]
|
]
|
||||||
for pattern in fact_patterns:
|
for pattern in fact_patterns:
|
||||||
if re.search(pattern, content_lower):
|
if re.search(pattern, content_lower):
|
||||||
|
|
@ -164,7 +198,7 @@ class ChunkingEngine:
|
||||||
Handles edge cases like multiple consecutive newlines and whitespace.
|
Handles edge cases like multiple consecutive newlines and whitespace.
|
||||||
"""
|
"""
|
||||||
# Split on double newlines
|
# Split on double newlines
|
||||||
raw_paragraphs = re.split(r'\n\n+', content)
|
raw_paragraphs = re.split(r"\n\n+", content)
|
||||||
|
|
||||||
# Clean up each paragraph
|
# Clean up each paragraph
|
||||||
paragraphs = []
|
paragraphs = []
|
||||||
|
|
@ -173,7 +207,7 @@ class ChunkingEngine:
|
||||||
cleaned = p.strip()
|
cleaned = p.strip()
|
||||||
if cleaned:
|
if cleaned:
|
||||||
# Normalize internal newlines (preserve single newlines within paragraphs)
|
# Normalize internal newlines (preserve single newlines within paragraphs)
|
||||||
cleaned = re.sub(r'[ \t]+', ' ', cleaned)
|
cleaned = re.sub(r"[ \t]+", " ", cleaned)
|
||||||
paragraphs.append(cleaned)
|
paragraphs.append(cleaned)
|
||||||
|
|
||||||
return paragraphs
|
return paragraphs
|
||||||
|
|
@ -224,7 +258,7 @@ class ChunkingEngine:
|
||||||
if sentence_tokens > self.max_tokens:
|
if sentence_tokens > self.max_tokens:
|
||||||
# First, flush current chunk if any
|
# First, flush current chunk if any
|
||||||
if current_chunk:
|
if current_chunk:
|
||||||
chunks.append(' '.join(current_chunk))
|
chunks.append(" ".join(current_chunk))
|
||||||
current_chunk = []
|
current_chunk = []
|
||||||
current_tokens = 0
|
current_tokens = 0
|
||||||
|
|
||||||
|
|
@ -235,7 +269,7 @@ class ChunkingEngine:
|
||||||
# Check if adding this sentence would exceed max_tokens
|
# Check if adding this sentence would exceed max_tokens
|
||||||
if current_tokens + sentence_tokens > self.max_tokens and current_chunk:
|
if current_tokens + sentence_tokens > self.max_tokens and current_chunk:
|
||||||
# Flush current chunk
|
# Flush current chunk
|
||||||
chunks.append(' '.join(current_chunk))
|
chunks.append(" ".join(current_chunk))
|
||||||
current_chunk = [sentence]
|
current_chunk = [sentence]
|
||||||
current_tokens = sentence_tokens
|
current_tokens = sentence_tokens
|
||||||
else:
|
else:
|
||||||
|
|
@ -245,7 +279,7 @@ class ChunkingEngine:
|
||||||
|
|
||||||
# Don't forget the last chunk
|
# Don't forget the last chunk
|
||||||
if current_chunk:
|
if current_chunk:
|
||||||
chunks.append(' '.join(current_chunk))
|
chunks.append(" ".join(current_chunk))
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|
@ -263,7 +297,9 @@ class ChunkingEngine:
|
||||||
# Calculate approximate characters per chunk
|
# Calculate approximate characters per chunk
|
||||||
# We use character count as a proxy for token count
|
# We use character count as a proxy for token count
|
||||||
chars_per_token = len(content) / total_tokens
|
chars_per_token = len(content) / total_tokens
|
||||||
chars_per_chunk = int(self.max_tokens * chars_per_token * 0.95) # 5% safety margin
|
chars_per_chunk = int(
|
||||||
|
self.max_tokens * chars_per_token * 0.95
|
||||||
|
) # 5% safety margin
|
||||||
|
|
||||||
chunks = []
|
chunks = []
|
||||||
start = 0
|
start = 0
|
||||||
|
|
@ -283,7 +319,7 @@ class ChunkingEngine:
|
||||||
|
|
||||||
# Find the last space or punctuation before search_end
|
# Find the last space or punctuation before search_end
|
||||||
for i in range(search_end - 1, start, -1):
|
for i in range(search_end - 1, start, -1):
|
||||||
if content[i] in ' \t\n.,;:!?':
|
if content[i] in " \t\n.,;:!?":
|
||||||
boundary = i + 1
|
boundary = i + 1
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -295,8 +331,9 @@ class ChunkingEngine:
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
def chunk(self, content: str, conversation_id: str,
|
def chunk(
|
||||||
tags: List[str] = None) -> List[ChunkResult]:
|
self, content: str, conversation_id: str, tags: List[str] = None
|
||||||
|
) -> List[ChunkResult]:
|
||||||
"""
|
"""
|
||||||
Split content into bounded semantic chunks.
|
Split content into bounded semantic chunks.
|
||||||
|
|
||||||
|
|
@ -348,7 +385,7 @@ class ChunkingEngine:
|
||||||
content=chunk_content,
|
content=chunk_content,
|
||||||
tokens=chunk_tokens,
|
tokens=chunk_tokens,
|
||||||
type=content_type,
|
type=content_type,
|
||||||
tags=tags.copy()
|
tags=tags.copy(),
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
|
|
@ -437,9 +474,14 @@ class ChunkingEngine:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def chunk_and_store(content: str, conversation_id: str,
|
def chunk_and_store(
|
||||||
store, tags: List[str] = None,
|
content: str,
|
||||||
min_tokens: int = 100, max_tokens: int = 800) -> List[Chunk]:
|
conversation_id: str,
|
||||||
|
store,
|
||||||
|
tags: List[str] = None,
|
||||||
|
min_tokens: int = 100,
|
||||||
|
max_tokens: int = 800,
|
||||||
|
) -> List[Chunk]:
|
||||||
"""
|
"""
|
||||||
Convenience function to chunk content and store in ChunkStore.
|
Convenience function to chunk content and store in ChunkStore.
|
||||||
|
|
||||||
|
|
@ -464,7 +506,7 @@ def chunk_and_store(content: str, conversation_id: str,
|
||||||
chunk_type=result.type,
|
chunk_type=result.type,
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
tokens=result.tokens,
|
tokens=result.tokens,
|
||||||
tags=result.tags
|
tags=result.tags,
|
||||||
)
|
)
|
||||||
created_chunks.append(chunk)
|
created_chunks.append(chunk)
|
||||||
|
|
||||||
|
|
@ -527,8 +569,12 @@ C is a longer paragraph with more content that should stand on its own."""
|
||||||
# Test 4: Large paragraph splitting
|
# Test 4: Large paragraph splitting
|
||||||
print("\n[Test 4] Large paragraph splitting")
|
print("\n[Test 4] Large paragraph splitting")
|
||||||
# Generate a paragraph that's definitely over 800 tokens
|
# Generate a paragraph that's definitely over 800 tokens
|
||||||
large_content = " ".join([f"This is sentence number {i} in a very long paragraph."
|
large_content = " ".join(
|
||||||
for i in range(1, 201)]) # ~200 sentences
|
[
|
||||||
|
f"This is sentence number {i} in a very long paragraph."
|
||||||
|
for i in range(1, 201)
|
||||||
|
]
|
||||||
|
) # ~200 sentences
|
||||||
|
|
||||||
chunks = engine.chunk(large_content, "test-conv")
|
chunks = engine.chunk(large_content, "test-conv")
|
||||||
total_tokens = sum(c.tokens for c in chunks)
|
total_tokens = sum(c.tokens for c in chunks)
|
||||||
|
|
@ -562,7 +608,7 @@ Third preference: I prefer using type hints."""
|
||||||
content=test_content,
|
content=test_content,
|
||||||
conversation_id="integration-test",
|
conversation_id="integration-test",
|
||||||
store=store,
|
store=store,
|
||||||
tags=["test", "integration"]
|
tags=["test", "integration"],
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f" Created {len(created)} chunks:")
|
print(f" Created {len(created)} chunks:")
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ except ImportError:
|
||||||
@dataclass
|
@dataclass
|
||||||
class ReasonResult:
|
class ReasonResult:
|
||||||
"""Result of a REASON operation."""
|
"""Result of a REASON operation."""
|
||||||
|
|
||||||
synthesis: str
|
synthesis: str
|
||||||
insights: List[str] = field(default_factory=list)
|
insights: List[str] = field(default_factory=list)
|
||||||
evidence: Dict[str, List[str]] = field(default_factory=dict)
|
evidence: Dict[str, List[str]] = field(default_factory=dict)
|
||||||
|
|
@ -41,10 +42,7 @@ class ReasonOperation:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self, chunk_store: ChunkStore, llm_client=None, max_iterations: int = 10
|
||||||
chunk_store: ChunkStore,
|
|
||||||
llm_client=None,
|
|
||||||
max_iterations: int = 10
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize REASON operation.
|
Initialize REASON operation.
|
||||||
|
|
@ -67,23 +65,20 @@ class ReasonOperation:
|
||||||
self._recall = RecallOperation(
|
self._recall = RecallOperation(
|
||||||
chunk_store=chunk_store,
|
chunk_store=chunk_store,
|
||||||
llm_client=llm_client,
|
llm_client=llm_client,
|
||||||
max_iterations=max_iterations
|
max_iterations=max_iterations,
|
||||||
)
|
)
|
||||||
|
|
||||||
def reason(
|
def reason(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
context_chunks: List[str] = None,
|
context_chunks: List[str] = None,
|
||||||
analysis_type: str = "synthesis"
|
analysis_type: str = "synthesis",
|
||||||
) -> ReasonResult:
|
) -> ReasonResult:
|
||||||
"""
|
"""
|
||||||
Perform reasoning analysis on memories.
|
Perform reasoning analysis on memories.
|
||||||
"""
|
"""
|
||||||
if not query or not query.strip():
|
if not query or not query.strip():
|
||||||
return ReasonResult(
|
return ReasonResult(synthesis="No query provided", confidence=0.0)
|
||||||
synthesis="No query provided",
|
|
||||||
confidence=0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Gather evidence
|
# Gather evidence
|
||||||
if context_chunks:
|
if context_chunks:
|
||||||
|
|
@ -93,8 +88,7 @@ class ReasonOperation:
|
||||||
|
|
||||||
if not evidence:
|
if not evidence:
|
||||||
return ReasonResult(
|
return ReasonResult(
|
||||||
synthesis="No relevant evidence found for analysis",
|
synthesis="No relevant evidence found for analysis", confidence=0.0
|
||||||
confidence=0.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. Always check for contradictions in evidence
|
# 1. Always check for contradictions in evidence
|
||||||
|
|
@ -116,17 +110,15 @@ class ReasonOperation:
|
||||||
if contradictions and not result.contradictions:
|
if contradictions and not result.contradictions:
|
||||||
result.contradictions = contradictions
|
result.contradictions = contradictions
|
||||||
if "Identified" not in "".join(result.insights):
|
if "Identified" not in "".join(result.insights):
|
||||||
result.insights.append(f"Identified {len(contradictions)} potential conflicts in memory")
|
result.insights.append(
|
||||||
|
f"Identified {len(contradictions)} potential conflicts in memory"
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _gather_evidence(self, chunk_ids: List[str]) -> Dict[str, Any]:
|
def _gather_evidence(self, chunk_ids: List[str]) -> Dict[str, Any]:
|
||||||
"""Gather evidence from specific chunks."""
|
"""Gather evidence from specific chunks."""
|
||||||
evidence = {
|
evidence = {"chunks": [], "tags": set(), "types": set()}
|
||||||
"chunks": [],
|
|
||||||
"tags": set(),
|
|
||||||
"types": set()
|
|
||||||
}
|
|
||||||
|
|
||||||
for chunk_id in chunk_ids:
|
for chunk_id in chunk_ids:
|
||||||
chunk = self.chunk_store.get_chunk(chunk_id)
|
chunk = self.chunk_store.get_chunk(chunk_id)
|
||||||
|
|
@ -157,15 +149,18 @@ class ReasonOperation:
|
||||||
|
|
||||||
# 1. Sort chunks by confidence and recency (if available)
|
# 1. Sort chunks by confidence and recency (if available)
|
||||||
def chunk_sort_key(c):
|
def chunk_sort_key(c):
|
||||||
conf = getattr(c.metadata, 'confidence', 0.5)
|
conf = getattr(c.metadata, "confidence", 0.5)
|
||||||
# Try to get timestamp for recency boost
|
# Try to get timestamp for recency boost
|
||||||
ts = 0.0
|
ts = 0.0
|
||||||
try:
|
try:
|
||||||
created = getattr(c.metadata, 'created', "")
|
created = getattr(c.metadata, "created", "")
|
||||||
if created:
|
if created:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
ts = datetime.fromisoformat(created.replace("Z", "+00:00")).timestamp()
|
|
||||||
except Exception:
|
ts = datetime.fromisoformat(
|
||||||
|
created.replace("Z", "+00:00")
|
||||||
|
).timestamp()
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
pass
|
pass
|
||||||
return (conf, ts)
|
return (conf, ts)
|
||||||
|
|
||||||
|
|
@ -187,22 +182,24 @@ class ReasonOperation:
|
||||||
# 4. Build synthesis
|
# 4. Build synthesis
|
||||||
contents = [c.content for c in unique_chunks]
|
contents = [c.content for c in unique_chunks]
|
||||||
if not contents:
|
if not contents:
|
||||||
return ReasonResult(
|
return ReasonResult(synthesis="No content to synthesize", confidence=0.0)
|
||||||
synthesis="No content to synthesize",
|
|
||||||
confidence=0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
synthesis = self._build_synthesis(query, contents)
|
synthesis = self._build_synthesis(query, contents)
|
||||||
|
|
||||||
# 5. Extract insights
|
# 5. Extract insights
|
||||||
insights = self._extract_insights(contents)
|
insights = self._extract_insights(contents)
|
||||||
if contradictions:
|
if contradictions:
|
||||||
insights.append(f"Identified {len(contradictions)} potential conflicts in memory")
|
insights.append(
|
||||||
|
f"Identified {len(contradictions)} potential conflicts in memory"
|
||||||
|
)
|
||||||
|
|
||||||
# 6. Calculate aggregate confidence
|
# 6. Calculate aggregate confidence
|
||||||
avg_confidence = sum(
|
avg_confidence = (
|
||||||
getattr(c.metadata, 'confidence', 0.7) for c in unique_chunks
|
sum(getattr(c.metadata, "confidence", 0.7) for c in unique_chunks)
|
||||||
) / len(unique_chunks) if unique_chunks else 0.0
|
/ len(unique_chunks)
|
||||||
|
if unique_chunks
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
return ReasonResult(
|
return ReasonResult(
|
||||||
synthesis=synthesis,
|
synthesis=synthesis,
|
||||||
|
|
@ -211,7 +208,7 @@ class ReasonOperation:
|
||||||
contradictions=contradictions,
|
contradictions=contradictions,
|
||||||
confidence=avg_confidence,
|
confidence=avg_confidence,
|
||||||
source_chunks=[c.id for c in unique_chunks],
|
source_chunks=[c.id for c in unique_chunks],
|
||||||
iterations_used=1
|
iterations_used=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_synthesis(self, query: str, contents: List[str]) -> str:
|
def _build_synthesis(self, query: str, contents: List[str]) -> str:
|
||||||
|
|
@ -220,15 +217,19 @@ class ReasonOperation:
|
||||||
return "No information available"
|
return "No information available"
|
||||||
|
|
||||||
# Improved synthesis: summary header + ranked list
|
# Improved synthesis: summary header + ranked list
|
||||||
synthesis_parts = [f"Synthesized analysis for: \"{query}\"", ""]
|
synthesis_parts = [f'Synthesized analysis for: "{query}"', ""]
|
||||||
synthesis_parts.append(f"Based on {len(contents)} unique sources (ranked by relevance):")
|
synthesis_parts.append(
|
||||||
|
f"Based on {len(contents)} unique sources (ranked by relevance):"
|
||||||
|
)
|
||||||
for i, content in enumerate(contents[:7], 1):
|
for i, content in enumerate(contents[:7], 1):
|
||||||
# Clean up content for list display
|
# Clean up content for list display
|
||||||
clean_content = content.replace("\n", " ").strip()
|
clean_content = content.replace("\n", " ").strip()
|
||||||
synthesis_parts.append(f" {i}. {clean_content}")
|
synthesis_parts.append(f" {i}. {clean_content}")
|
||||||
|
|
||||||
if len(contents) > 7:
|
if len(contents) > 7:
|
||||||
synthesis_parts.append(f" ... and {len(contents) - 7} other supporting memories.")
|
synthesis_parts.append(
|
||||||
|
f" ... and {len(contents) - 7} other supporting memories."
|
||||||
|
)
|
||||||
|
|
||||||
return "\n".join(synthesis_parts)
|
return "\n".join(synthesis_parts)
|
||||||
|
|
||||||
|
|
@ -264,15 +265,19 @@ class ReasonOperation:
|
||||||
c1_words = set(c1.content.lower().split())
|
c1_words = set(c1.content.lower().split())
|
||||||
c2_words = set(c2.content.lower().split())
|
c2_words = set(c2.content.lower().split())
|
||||||
|
|
||||||
if ("prefer" in c1_words or "prefers" in c1_words) and ("prefer" in c2_words or "prefers" in c2_words):
|
if ("prefer" in c1_words or "prefers" in c1_words) and (
|
||||||
|
"prefer" in c2_words or "prefers" in c2_words
|
||||||
|
):
|
||||||
# Significant difference in specific preference
|
# Significant difference in specific preference
|
||||||
if len(c1_words ^ c2_words) >= 2:
|
if len(c1_words ^ c2_words) >= 2:
|
||||||
conflicts.append({
|
conflicts.append(
|
||||||
"type": "potential_preference_conflict",
|
{
|
||||||
"topic": tag,
|
"type": "potential_preference_conflict",
|
||||||
"chunks": [c1.id, c2.id],
|
"topic": tag,
|
||||||
"reason": f"Divergent preferences detected for topic '{tag}'"
|
"chunks": [c1.id, c2.id],
|
||||||
})
|
"reason": f"Divergent preferences detected for topic '{tag}'",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Check for explicit negation
|
# Check for explicit negation
|
||||||
# If one has a negation word and the other doesn't for the same tag
|
# If one has a negation word and the other doesn't for the same tag
|
||||||
|
|
@ -280,12 +285,14 @@ class ReasonOperation:
|
||||||
c2_negated = any(n in c2_words for n in NEGATIONS)
|
c2_negated = any(n in c2_words for n in NEGATIONS)
|
||||||
|
|
||||||
if c1_negated != c2_negated:
|
if c1_negated != c2_negated:
|
||||||
conflicts.append({
|
conflicts.append(
|
||||||
"type": "negation_conflict",
|
{
|
||||||
"topic": tag,
|
"type": "negation_conflict",
|
||||||
"chunks": [c1.id, c2.id],
|
"topic": tag,
|
||||||
"reason": f"Opposing sentiments detected for topic '{tag}'"
|
"chunks": [c1.id, c2.id],
|
||||||
})
|
"reason": f"Opposing sentiments detected for topic '{tag}'",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Deduplicate conflicts
|
# Deduplicate conflicts
|
||||||
unique_conflicts = []
|
unique_conflicts = []
|
||||||
|
|
@ -325,12 +332,11 @@ class ReasonOperation:
|
||||||
|
|
||||||
if len(chunks) < 2:
|
if len(chunks) < 2:
|
||||||
return ReasonResult(
|
return ReasonResult(
|
||||||
synthesis="Need at least 2 items to compare",
|
synthesis="Need at least 2 items to compare", confidence=0.0
|
||||||
confidence=0.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build comparison
|
# Build comparison
|
||||||
comparison_parts = [f"Comparison Analysis: \"{query}\"", ""]
|
comparison_parts = [f'Comparison Analysis: "{query}"', ""]
|
||||||
for i, chunk in enumerate(chunks, 1):
|
for i, chunk in enumerate(chunks, 1):
|
||||||
comparison_parts.append(f" Option {i}: {chunk.content}")
|
comparison_parts.append(f" Option {i}: {chunk.content}")
|
||||||
|
|
||||||
|
|
@ -340,7 +346,7 @@ class ReasonOperation:
|
||||||
synthesis=synthesis,
|
synthesis=synthesis,
|
||||||
insights=[f"Comparing {len(chunks)} distinct sources"],
|
insights=[f"Comparing {len(chunks)} distinct sources"],
|
||||||
confidence=0.7,
|
confidence=0.7,
|
||||||
source_chunks=[chunk.id for chunk in chunks]
|
source_chunks=[chunk.id for chunk in chunks],
|
||||||
)
|
)
|
||||||
|
|
||||||
def _find_patterns(self, query: str, evidence: Dict[str, Any]) -> ReasonResult:
|
def _find_patterns(self, query: str, evidence: Dict[str, Any]) -> ReasonResult:
|
||||||
|
|
@ -363,8 +369,11 @@ class ReasonOperation:
|
||||||
if chunks:
|
if chunks:
|
||||||
dates = []
|
dates = []
|
||||||
for c in chunks:
|
for c in chunks:
|
||||||
d = getattr(c.metadata, 'created', getattr(c.metadata, 'created_at', None))
|
d = getattr(
|
||||||
if d: dates.append(d[:10])
|
c.metadata, "created", getattr(c.metadata, "created_at", None)
|
||||||
|
)
|
||||||
|
if d:
|
||||||
|
dates.append(d[:10])
|
||||||
if dates:
|
if dates:
|
||||||
insights.append(f"Evidence spans {len(set(dates))} unique days")
|
insights.append(f"Evidence spans {len(set(dates))} unique days")
|
||||||
|
|
||||||
|
|
@ -372,7 +381,7 @@ class ReasonOperation:
|
||||||
synthesis=f"Found {len(insights)} patterns across {len(chunks)} memories",
|
synthesis=f"Found {len(insights)} patterns across {len(chunks)} memories",
|
||||||
insights=insights,
|
insights=insights,
|
||||||
confidence=0.75,
|
confidence=0.75,
|
||||||
source_chunks=[chunk.id for chunk in chunks]
|
source_chunks=[chunk.id for chunk in chunks],
|
||||||
)
|
)
|
||||||
|
|
||||||
def _identify_gaps(self, query: str, evidence: Dict[str, Any]) -> ReasonResult:
|
def _identify_gaps(self, query: str, evidence: Dict[str, Any]) -> ReasonResult:
|
||||||
|
|
@ -383,34 +392,36 @@ class ReasonOperation:
|
||||||
|
|
||||||
# Check for low confidence items
|
# Check for low confidence items
|
||||||
low_confidence = [
|
low_confidence = [
|
||||||
chunk for chunk in chunks
|
chunk
|
||||||
if getattr(chunk.metadata, 'confidence', 0.7) < 0.6
|
for chunk in chunks
|
||||||
|
if getattr(chunk.metadata, "confidence", 0.7) < 0.6
|
||||||
]
|
]
|
||||||
if low_confidence:
|
if low_confidence:
|
||||||
gaps.append(f"{len(low_confidence)} sources have low confidence scores")
|
gaps.append(f"{len(low_confidence)} sources have low confidence scores")
|
||||||
|
|
||||||
# Check for missing links
|
# Check for missing links
|
||||||
unlinked = [
|
unlinked = [
|
||||||
chunk for chunk in chunks
|
chunk
|
||||||
if not getattr(chunk, 'links', None) or (not chunk.links.context_of and not chunk.links.related_to)
|
for chunk in chunks
|
||||||
|
if not getattr(chunk, "links", None)
|
||||||
|
or (not chunk.links.context_of and not chunk.links.related_to)
|
||||||
]
|
]
|
||||||
if unlinked:
|
if unlinked:
|
||||||
gaps.append(f"{len(unlinked)} items are isolated (no graph links)")
|
gaps.append(f"{len(unlinked)} items are isolated (no graph links)")
|
||||||
|
|
||||||
if not gaps:
|
if not gaps:
|
||||||
gaps.append("No significant structural gaps identified in the available evidence")
|
gaps.append(
|
||||||
|
"No significant structural gaps identified in the available evidence"
|
||||||
|
)
|
||||||
|
|
||||||
return ReasonResult(
|
return ReasonResult(
|
||||||
synthesis=f"Knowledge Gap Analysis: {'; '.join(gaps)}",
|
synthesis=f"Knowledge Gap Analysis: {'; '.join(gaps)}",
|
||||||
insights=gaps,
|
insights=gaps,
|
||||||
confidence=0.6,
|
confidence=0.6,
|
||||||
source_chunks=[chunk.id for chunk in chunks]
|
source_chunks=[chunk.id for chunk in chunks],
|
||||||
)
|
)
|
||||||
|
|
||||||
def analyze_contradictions(
|
def analyze_contradictions(self, chunk_ids: List[str]) -> List[Dict[str, Any]]:
|
||||||
self,
|
|
||||||
chunk_ids: List[str]
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
"""
|
||||||
Analyze chunks for potential contradictions.
|
Analyze chunks for potential contradictions.
|
||||||
|
|
||||||
|
|
@ -431,20 +442,18 @@ class ReasonOperation:
|
||||||
# Simple contradiction detection
|
# Simple contradiction detection
|
||||||
# Look for chunks with contradicts links
|
# Look for chunks with contradicts links
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
if hasattr(chunk.links, 'contradicts') and chunk.links.contradicts:
|
if hasattr(chunk.links, "contradicts") and chunk.links.contradicts:
|
||||||
for target_id in chunk.links.contradicts:
|
for target_id in chunk.links.contradicts:
|
||||||
contradictions.append({
|
contradictions.append(
|
||||||
"chunk_a": chunk.id,
|
{
|
||||||
"chunk_b": target_id,
|
"chunk_a": chunk.id,
|
||||||
"reasoning": "Explicit contradiction link"
|
"chunk_b": target_id,
|
||||||
})
|
"reasoning": "Explicit contradiction link",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return contradictions
|
return contradictions
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
"""Get reasoning operation statistics."""
|
"""Get reasoning operation statistics."""
|
||||||
return {
|
return {"total_analyses": 0, "avg_confidence": 0.0, "avg_insights": 0.0}
|
||||||
"total_analyses": 0,
|
|
||||||
"avg_confidence": 0.0,
|
|
||||||
"avg_insights": 0.0
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,20 @@ from pathlib import Path
|
||||||
|
|
||||||
class SandboxViolation(Exception):
|
class SandboxViolation(Exception):
|
||||||
"""Raised when code attempts to violate sandbox security."""
|
"""Raised when code attempts to violate sandbox security."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MaxIterationsError(Exception):
|
class MaxIterationsError(Exception):
|
||||||
"""Raised when max iterations exceeded."""
|
"""Raised when max iterations exceeded."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Cost budget exceeded
|
# Cost budget exceeded
|
||||||
class CostBudgetExceededError(RuntimeError):
|
class CostBudgetExceededError(RuntimeError):
|
||||||
"""Raised when cost budget is exceeded."""
|
"""Raised when cost budget is exceeded."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,29 +38,129 @@ class CostBudgetExceededError(RuntimeError):
|
||||||
|
|
||||||
# Allowed built-ins for sandbox
|
# Allowed built-ins for sandbox
|
||||||
ALLOWED_BUILTINS = {
|
ALLOWED_BUILTINS = {
|
||||||
'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes',
|
"abs",
|
||||||
'callable', 'chr', 'classmethod', 'complex', 'delattr', 'dict',
|
"all",
|
||||||
'dir', 'divmod', 'enumerate', 'filter', 'float', 'format', 'frozenset',
|
"any",
|
||||||
'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id', 'input',
|
"ascii",
|
||||||
'int', 'isinstance', 'issubclass', 'iter', 'len', 'list', 'locals',
|
"bin",
|
||||||
'map', 'max', 'memoryview', 'min', 'next', 'object', 'oct', 'ord',
|
"bool",
|
||||||
'pow', 'print', 'property', 'range', 'repr', 'reversed',
|
"bytearray",
|
||||||
'round', 'set', 'setattr', 'slice', 'sorted', 'staticmethod', 'str',
|
"bytes",
|
||||||
'sum', 'super', 'tuple', 'type', 'vars', 'zip', '__build_class__',
|
"callable",
|
||||||
'__name__', 'True', 'False', 'None', 'Exception', 'TypeError',
|
"chr",
|
||||||
'ValueError', 'KeyError', 'IndexError', 'AttributeError', 'RuntimeError',
|
"classmethod",
|
||||||
'StopIteration', 'ArithmeticError', 'LookupError', 'AssertionError',
|
"complex",
|
||||||
'NotImplementedError', 'ZeroDivisionError', 'OverflowError',
|
"delattr",
|
||||||
|
"dict",
|
||||||
|
"dir",
|
||||||
|
"divmod",
|
||||||
|
"enumerate",
|
||||||
|
"filter",
|
||||||
|
"float",
|
||||||
|
"format",
|
||||||
|
"frozenset",
|
||||||
|
"getattr",
|
||||||
|
"globals",
|
||||||
|
"hasattr",
|
||||||
|
"hash",
|
||||||
|
"help",
|
||||||
|
"hex",
|
||||||
|
"id",
|
||||||
|
"input",
|
||||||
|
"int",
|
||||||
|
"isinstance",
|
||||||
|
"issubclass",
|
||||||
|
"iter",
|
||||||
|
"len",
|
||||||
|
"list",
|
||||||
|
"locals",
|
||||||
|
"map",
|
||||||
|
"max",
|
||||||
|
"memoryview",
|
||||||
|
"min",
|
||||||
|
"next",
|
||||||
|
"object",
|
||||||
|
"oct",
|
||||||
|
"ord",
|
||||||
|
"pow",
|
||||||
|
"print",
|
||||||
|
"property",
|
||||||
|
"range",
|
||||||
|
"repr",
|
||||||
|
"reversed",
|
||||||
|
"round",
|
||||||
|
"set",
|
||||||
|
"setattr",
|
||||||
|
"slice",
|
||||||
|
"sorted",
|
||||||
|
"staticmethod",
|
||||||
|
"str",
|
||||||
|
"sum",
|
||||||
|
"super",
|
||||||
|
"tuple",
|
||||||
|
"type",
|
||||||
|
"vars",
|
||||||
|
"zip",
|
||||||
|
"__build_class__",
|
||||||
|
"__name__",
|
||||||
|
"True",
|
||||||
|
"False",
|
||||||
|
"None",
|
||||||
|
"Exception",
|
||||||
|
"TypeError",
|
||||||
|
"ValueError",
|
||||||
|
"KeyError",
|
||||||
|
"IndexError",
|
||||||
|
"AttributeError",
|
||||||
|
"RuntimeError",
|
||||||
|
"StopIteration",
|
||||||
|
"ArithmeticError",
|
||||||
|
"LookupError",
|
||||||
|
"AssertionError",
|
||||||
|
"NotImplementedError",
|
||||||
|
"ZeroDivisionError",
|
||||||
|
"OverflowError",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Blocked imports/modules
|
# Blocked imports/modules
|
||||||
BLOCKED_MODULES = {
|
BLOCKED_MODULES = {
|
||||||
'os', 'sys', 'subprocess', 'socket', 'urllib', 'http', 'ftplib',
|
"os",
|
||||||
'smtplib', 'telnetlib', 'poplib', 'imaplib', 'nntplib', 'ssl',
|
"sys",
|
||||||
'email', 'xmlrpc', 'concurrent.futures.process', 'multiprocessing',
|
"subprocess",
|
||||||
'ctypes', 'cffi', 'mmap', 'resource', 'posix', 'nt', 'pwd', 'grp',
|
"socket",
|
||||||
'spwd', 'crypt', 'termios', 'tty', 'pty', 'fcntl', 'msvcrt',
|
"urllib",
|
||||||
'winreg', '_winapi', 'select', 'selectors', 'asyncio.subprocess',
|
"http",
|
||||||
|
"ftplib",
|
||||||
|
"smtplib",
|
||||||
|
"telnetlib",
|
||||||
|
"poplib",
|
||||||
|
"imaplib",
|
||||||
|
"nntplib",
|
||||||
|
"ssl",
|
||||||
|
"email",
|
||||||
|
"xmlrpc",
|
||||||
|
"concurrent.futures.process",
|
||||||
|
"multiprocessing",
|
||||||
|
"ctypes",
|
||||||
|
"cffi",
|
||||||
|
"mmap",
|
||||||
|
"resource",
|
||||||
|
"posix",
|
||||||
|
"nt",
|
||||||
|
"pwd",
|
||||||
|
"grp",
|
||||||
|
"spwd",
|
||||||
|
"crypt",
|
||||||
|
"termios",
|
||||||
|
"tty",
|
||||||
|
"pty",
|
||||||
|
"fcntl",
|
||||||
|
"msvcrt",
|
||||||
|
"winreg",
|
||||||
|
"_winapi",
|
||||||
|
"select",
|
||||||
|
"selectors",
|
||||||
|
"asyncio.subprocess",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Allowed modules that get redirected to mocks
|
# Allowed modules that get redirected to mocks
|
||||||
|
|
@ -66,11 +169,11 @@ ALLOWED_MODULES = set()
|
||||||
|
|
||||||
def safe_import(name, globals=None, locals=None, fromlist=(), level=0):
|
def safe_import(name, globals=None, locals=None, fromlist=(), level=0):
|
||||||
"""Safe import function that only allows specific modules."""
|
"""Safe import function that only allows specific modules."""
|
||||||
base_module = name.split('.')[0] if name else ''
|
base_module = name.split(".")[0] if name else ""
|
||||||
# Allow sys import (mocked in sandbox)
|
# Allow sys import (mocked in sandbox)
|
||||||
if base_module == 'sys':
|
if base_module == "sys":
|
||||||
if globals and 'sys' in globals:
|
if globals and "sys" in globals:
|
||||||
return globals['sys']
|
return globals["sys"]
|
||||||
raise ImportError("Mock sys not found in sandbox")
|
raise ImportError("Mock sys not found in sandbox")
|
||||||
if base_module in ALLOWED_MODULES:
|
if base_module in ALLOWED_MODULES:
|
||||||
if globals and base_module in globals:
|
if globals and base_module in globals:
|
||||||
|
|
@ -81,10 +184,22 @@ def safe_import(name, globals=None, locals=None, fromlist=(), level=0):
|
||||||
|
|
||||||
# Blocked attribute names that could be used for sandbox escape
|
# Blocked attribute names that could be used for sandbox escape
|
||||||
BLOCKED_ATTRIBUTES = {
|
BLOCKED_ATTRIBUTES = {
|
||||||
'__class__', '__bases__', '__subclasses__', '__base__',
|
"__class__",
|
||||||
'__mro__', '__globals__', '__code__', '__func__', '__self__',
|
"__bases__",
|
||||||
'__module__', '__dict__', '__closure__', '__defaults__',
|
"__subclasses__",
|
||||||
'__kwdefaults__', '__getattribute__', '__setattr__',
|
"__base__",
|
||||||
|
"__mro__",
|
||||||
|
"__globals__",
|
||||||
|
"__code__",
|
||||||
|
"__func__",
|
||||||
|
"__self__",
|
||||||
|
"__module__",
|
||||||
|
"__dict__",
|
||||||
|
"__closure__",
|
||||||
|
"__defaults__",
|
||||||
|
"__kwdefaults__",
|
||||||
|
"__getattribute__",
|
||||||
|
"__setattr__",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,9 +212,9 @@ class SandboxVisitor(ast.NodeVisitor):
|
||||||
|
|
||||||
def visit_Import(self, node):
|
def visit_Import(self, node):
|
||||||
for alias in node.names:
|
for alias in node.names:
|
||||||
module = alias.name.split('.')[0]
|
module = alias.name.split(".")[0]
|
||||||
# Allow 'sys' import (redirected to mock in sandbox)
|
# Allow 'sys' import (redirected to mock in sandbox)
|
||||||
if module == 'sys':
|
if module == "sys":
|
||||||
continue
|
continue
|
||||||
if module in BLOCKED_MODULES and module not in ALLOWED_MODULES:
|
if module in BLOCKED_MODULES and module not in ALLOWED_MODULES:
|
||||||
self.violations.append(f"Import of '{module}' is not allowed")
|
self.violations.append(f"Import of '{module}' is not allowed")
|
||||||
|
|
@ -107,9 +222,9 @@ class SandboxVisitor(ast.NodeVisitor):
|
||||||
|
|
||||||
def visit_ImportFrom(self, node):
|
def visit_ImportFrom(self, node):
|
||||||
if node.module:
|
if node.module:
|
||||||
module = node.module.split('.')[0]
|
module = node.module.split(".")[0]
|
||||||
# Allow 'sys' import (redirected to mock in sandbox)
|
# Allow 'sys' import (redirected to mock in sandbox)
|
||||||
if module == 'sys':
|
if module == "sys":
|
||||||
return
|
return
|
||||||
if module in BLOCKED_MODULES and module not in ALLOWED_MODULES:
|
if module in BLOCKED_MODULES and module not in ALLOWED_MODULES:
|
||||||
self.violations.append(f"Import from '{module}' is not allowed")
|
self.violations.append(f"Import from '{module}' is not allowed")
|
||||||
|
|
@ -120,32 +235,36 @@ class SandboxVisitor(ast.NodeVisitor):
|
||||||
for target in node.targets:
|
for target in node.targets:
|
||||||
if isinstance(target, ast.Attribute):
|
if isinstance(target, ast.Attribute):
|
||||||
if self._is_builtins_access(target.value):
|
if self._is_builtins_access(target.value):
|
||||||
self.violations.append("Deletion of __builtins__ attributes is not allowed")
|
self.violations.append(
|
||||||
|
"Deletion of __builtins__ attributes is not allowed"
|
||||||
|
)
|
||||||
if isinstance(target, ast.Subscript):
|
if isinstance(target, ast.Subscript):
|
||||||
if self._is_builtins_access(target.value):
|
if self._is_builtins_access(target.value):
|
||||||
self.violations.append("Deletion of __builtins__ attributes is not allowed")
|
self.violations.append(
|
||||||
|
"Deletion of __builtins__ attributes is not allowed"
|
||||||
|
)
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
|
|
||||||
def visit_Call(self, node):
|
def visit_Call(self, node):
|
||||||
# Check for eval/exec/compile
|
# Check for eval/exec/compile
|
||||||
if isinstance(node.func, ast.Name):
|
if isinstance(node.func, ast.Name):
|
||||||
if node.func.id in ('eval', 'exec', 'compile'):
|
if node.func.id in ("eval", "exec", "compile"):
|
||||||
self.violations.append(f"Use of '{node.func.id}()' is not allowed")
|
self.violations.append(f"Use of '{node.func.id}()' is not allowed")
|
||||||
# Check for __import__
|
# Check for __import__
|
||||||
if isinstance(node.func, ast.Name) and node.func.id == '__import__':
|
if isinstance(node.func, ast.Name) and node.func.id == "__import__":
|
||||||
self.violations.append("Use of '__import__()' is not allowed")
|
self.violations.append("Use of '__import__()' is not allowed")
|
||||||
# Check for open()
|
# Check for open()
|
||||||
if isinstance(node.func, ast.Name) and node.func.id == 'open':
|
if isinstance(node.func, ast.Name) and node.func.id == "open":
|
||||||
self.violations.append("Use of 'open()' is not allowed")
|
self.violations.append("Use of 'open()' is not allowed")
|
||||||
|
|
||||||
# Check for getattr/setattr on __builtins__
|
# Check for getattr/setattr on __builtins__
|
||||||
if isinstance(node.func, ast.Name) and node.func.id == 'getattr':
|
if isinstance(node.func, ast.Name) and node.func.id == "getattr":
|
||||||
if node.args and self._is_builtins_access(node.args[0]):
|
if node.args and self._is_builtins_access(node.args[0]):
|
||||||
self.violations.append("getattr on __builtins__ is not allowed")
|
self.violations.append("getattr on __builtins__ is not allowed")
|
||||||
if isinstance(node.func, ast.Name) and node.func.id == 'setattr':
|
if isinstance(node.func, ast.Name) and node.func.id == "setattr":
|
||||||
if node.args and self._is_builtins_access(node.args[0]):
|
if node.args and self._is_builtins_access(node.args[0]):
|
||||||
self.violations.append("setattr on __builtins__ is not allowed")
|
self.violations.append("setattr on __builtins__ is not allowed")
|
||||||
if isinstance(node.func, ast.Name) and node.func.id == 'delattr':
|
if isinstance(node.func, ast.Name) and node.func.id == "delattr":
|
||||||
if node.args and self._is_builtins_access(node.args[0]):
|
if node.args and self._is_builtins_access(node.args[0]):
|
||||||
self.violations.append("delattr on __builtins__ is not allowed")
|
self.violations.append("delattr on __builtins__ is not allowed")
|
||||||
|
|
||||||
|
|
@ -157,19 +276,25 @@ class SandboxVisitor(ast.NodeVisitor):
|
||||||
# Check for patterns like "x" * (1024 * 1024 * 100)
|
# Check for patterns like "x" * (1024 * 1024 * 100)
|
||||||
# Try to evaluate the size statically
|
# Try to evaluate the size statically
|
||||||
try:
|
try:
|
||||||
if isinstance(node.left, ast.Constant) and isinstance(node.left.value, str):
|
if isinstance(node.left, ast.Constant) and isinstance(
|
||||||
|
node.left.value, str
|
||||||
|
):
|
||||||
if isinstance(node.right, ast.Constant):
|
if isinstance(node.right, ast.Constant):
|
||||||
size = len(node.left.value) * node.right.value
|
size = len(node.left.value) * node.right.value
|
||||||
if size > 10 * 1024 * 1024: # 10MB limit
|
if size > 10 * 1024 * 1024: # 10MB limit
|
||||||
raise MemoryError(f"String multiplication would create {size} bytes, exceeding 10MB limit")
|
raise MemoryError(
|
||||||
|
f"String multiplication would create {size} bytes, exceeding 10MB limit"
|
||||||
|
)
|
||||||
elif isinstance(node.right, ast.BinOp):
|
elif isinstance(node.right, ast.BinOp):
|
||||||
# Try to evaluate binary expression
|
# Try to evaluate binary expression
|
||||||
size = len(node.left.value) * self._eval_const_expr(node.right)
|
size = len(node.left.value) * self._eval_const_expr(node.right)
|
||||||
if size > 10 * 1024 * 1024: # 10MB limit
|
if size > 10 * 1024 * 1024: # 10MB limit
|
||||||
raise MemoryError(f"String multiplication would create {size} bytes, exceeding 10MB limit")
|
raise MemoryError(
|
||||||
|
f"String multiplication would create {size} bytes, exceeding 10MB limit"
|
||||||
|
)
|
||||||
except MemoryError:
|
except MemoryError:
|
||||||
raise # Re-raise MemoryError
|
raise # Re-raise MemoryError
|
||||||
except Exception:
|
except (ValueError, TypeError, AttributeError):
|
||||||
pass # Can't evaluate statically, let it run and catch at runtime
|
pass # Can't evaluate statically, let it run and catch at runtime
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
|
@ -198,25 +323,41 @@ class SandboxVisitor(ast.NodeVisitor):
|
||||||
"""Check for builtins subscript access like globals()['__builtins__']['__import__']."""
|
"""Check for builtins subscript access like globals()['__builtins__']['__import__']."""
|
||||||
# Check for globals()['__builtins__'] or locals()['__builtins__']
|
# Check for globals()['__builtins__'] or locals()['__builtins__']
|
||||||
if isinstance(node.value, ast.Call):
|
if isinstance(node.value, ast.Call):
|
||||||
if isinstance(node.value.func, ast.Name) and node.value.func.id in ('globals', 'locals'):
|
if isinstance(node.value.func, ast.Name) and node.value.func.id in (
|
||||||
if isinstance(node.slice, ast.Constant) and node.slice.value == '__builtins__':
|
"globals",
|
||||||
self.violations.append("globals()/locals()['__builtins__'] manipulation is not allowed")
|
"locals",
|
||||||
elif hasattr(node.slice, 's') and node.slice.s == '__builtins__': # Python < 3.8 compatibility
|
):
|
||||||
self.violations.append("globals()/locals()['__builtins__'] manipulation is not allowed")
|
if (
|
||||||
|
isinstance(node.slice, ast.Constant)
|
||||||
|
and node.slice.value == "__builtins__"
|
||||||
|
):
|
||||||
|
self.violations.append(
|
||||||
|
"globals()/locals()['__builtins__'] manipulation is not allowed"
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
hasattr(node.slice, "s") and node.slice.s == "__builtins__"
|
||||||
|
): # Python < 3.8 compatibility
|
||||||
|
self.violations.append(
|
||||||
|
"globals()/locals()['__builtins__'] manipulation is not allowed"
|
||||||
|
)
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
|
|
||||||
def _is_builtins_access(self, node):
|
def _is_builtins_access(self, node):
|
||||||
"""Check if a node represents access to __builtins__."""
|
"""Check if a node represents access to __builtins__."""
|
||||||
if isinstance(node, ast.Name) and node.id == '__builtins__':
|
if isinstance(node, ast.Name) and node.id == "__builtins__":
|
||||||
return True
|
return True
|
||||||
if isinstance(node, ast.Call):
|
if isinstance(node, ast.Call):
|
||||||
if isinstance(node.func, ast.Name) and node.func.id in ('globals', 'locals'):
|
if isinstance(node.func, ast.Name) and node.func.id in (
|
||||||
|
"globals",
|
||||||
|
"locals",
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
class MemoryLimitException(RuntimeError):
|
class MemoryLimitException(RuntimeError):
|
||||||
"""Raised when memory limit is exceeded."""
|
"""Raised when memory limit is exceeded."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -224,7 +365,7 @@ class MemoryLimitException(RuntimeError):
|
||||||
def check_safety(code: str) -> list:
|
def check_safety(code: str) -> list:
|
||||||
"""Check code for sandbox violations."""
|
"""Check code for sandbox violations."""
|
||||||
# Pre-check for null bytes and other dangerous characters
|
# Pre-check for null bytes and other dangerous characters
|
||||||
if '\x00' in code:
|
if "\x00" in code:
|
||||||
return ["Code contains null bytes which is not allowed"]
|
return ["Code contains null bytes which is not allowed"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -258,6 +399,7 @@ class REPLSession:
|
||||||
|
|
||||||
class _StderrCapture:
|
class _StderrCapture:
|
||||||
"""Mock stderr object for sandbox."""
|
"""Mock stderr object for sandbox."""
|
||||||
|
|
||||||
def __init__(self, session):
|
def __init__(self, session):
|
||||||
self._session = session
|
self._session = session
|
||||||
|
|
||||||
|
|
@ -271,17 +413,24 @@ class REPLSession:
|
||||||
|
|
||||||
class MockSys:
|
class MockSys:
|
||||||
"""Mock sys module for sandbox with only stderr."""
|
"""Mock sys module for sandbox with only stderr."""
|
||||||
|
|
||||||
def __init__(self, stderr_capture):
|
def __init__(self, stderr_capture):
|
||||||
self.stderr = stderr_capture
|
self.stderr = stderr_capture
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, name):
|
||||||
if name == 'modules':
|
if name == "modules":
|
||||||
raise SandboxViolation("Access to sys.modules is not allowed")
|
raise SandboxViolation("Access to sys.modules is not allowed")
|
||||||
raise AttributeError(f"sys.{name} is not available in sandbox")
|
raise AttributeError(f"sys.{name} is not available in sandbox")
|
||||||
|
|
||||||
def __init__(self, chunk_store=None, llm_client=None,
|
def __init__(
|
||||||
max_iterations: int = 10, timeout_seconds: int = 60, max_depth: int = 5,
|
self,
|
||||||
max_cost_usd: Optional[float] = None):
|
chunk_store=None,
|
||||||
|
llm_client=None,
|
||||||
|
max_iterations: int = 10,
|
||||||
|
timeout_seconds: int = 60,
|
||||||
|
max_depth: int = 5,
|
||||||
|
max_cost_usd: Optional[float] = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize REPL session.
|
Initialize REPL session.
|
||||||
|
|
||||||
|
|
@ -322,32 +471,39 @@ class REPLSession:
|
||||||
def _setup_namespace(self):
|
def _setup_namespace(self):
|
||||||
"""Set up the sandbox namespace."""
|
"""Set up the sandbox namespace."""
|
||||||
# Safe builtins
|
# Safe builtins
|
||||||
safe_builtins = {name: getattr(builtins, name)
|
safe_builtins = {
|
||||||
for name in ALLOWED_BUILTINS
|
name: getattr(builtins, name)
|
||||||
if hasattr(builtins, name)}
|
for name in ALLOWED_BUILTINS
|
||||||
|
if hasattr(builtins, name)
|
||||||
|
}
|
||||||
|
|
||||||
# Inject memory functions
|
# Inject memory functions
|
||||||
from brain.scripts.repl_functions import read_chunk, search_chunks, list_chunks_by_tag, get_linked_chunks
|
from brain.scripts.repl_functions import (
|
||||||
|
read_chunk,
|
||||||
|
search_chunks,
|
||||||
|
list_chunks_by_tag,
|
||||||
|
get_linked_chunks,
|
||||||
|
)
|
||||||
|
|
||||||
# Create bound methods
|
# Create bound methods
|
||||||
safe_builtins['read_chunk'] = self._read_chunk_wrapper
|
safe_builtins["read_chunk"] = self._read_chunk_wrapper
|
||||||
safe_builtins['search_chunks'] = self._search_chunks_wrapper
|
safe_builtins["search_chunks"] = self._search_chunks_wrapper
|
||||||
safe_builtins['list_chunks_by_tag'] = self._list_chunks_by_tag_wrapper
|
safe_builtins["list_chunks_by_tag"] = self._list_chunks_by_tag_wrapper
|
||||||
safe_builtins['get_linked_chunks'] = self._get_linked_chunks_wrapper
|
safe_builtins["get_linked_chunks"] = self._get_linked_chunks_wrapper
|
||||||
safe_builtins['llm_query'] = self._llm_query_wrapper
|
safe_builtins["llm_query"] = self._llm_query_wrapper
|
||||||
safe_builtins['FINAL'] = self._final_wrapper
|
safe_builtins["FINAL"] = self._final_wrapper
|
||||||
|
|
||||||
# Inject safe import and mock sys module
|
# Inject safe import and mock sys module
|
||||||
safe_builtins['__import__'] = safe_import
|
safe_builtins["__import__"] = safe_import
|
||||||
safe_builtins['sys'] = self.MockSys(self._stderr_capture)
|
safe_builtins["sys"] = self.MockSys(self._stderr_capture)
|
||||||
|
|
||||||
self._namespace = {
|
self._namespace = {
|
||||||
'__builtins__': safe_builtins,
|
"__builtins__": safe_builtins,
|
||||||
'__name__': '__repl__',
|
"__name__": "__repl__",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Inject mock sys module so 'import sys' binds to our mock
|
# Inject mock sys module so 'import sys' binds to our mock
|
||||||
self._namespace['sys'] = self.MockSys(self._stderr_capture)
|
self._namespace["sys"] = self.MockSys(self._stderr_capture)
|
||||||
|
|
||||||
# Merge user state into namespace
|
# Merge user state into namespace
|
||||||
self._namespace.update(self._state)
|
self._namespace.update(self._state)
|
||||||
|
|
@ -355,21 +511,25 @@ class REPLSession:
|
||||||
def _read_chunk_wrapper(self, chunk_id: str):
|
def _read_chunk_wrapper(self, chunk_id: str):
|
||||||
"""Wrapper for read_chunk."""
|
"""Wrapper for read_chunk."""
|
||||||
from repl_functions import read_chunk
|
from repl_functions import read_chunk
|
||||||
|
|
||||||
return read_chunk(chunk_id, self.chunk_store)
|
return read_chunk(chunk_id, self.chunk_store)
|
||||||
|
|
||||||
def _search_chunks_wrapper(self, query: str, limit: int = 10):
|
def _search_chunks_wrapper(self, query: str, limit: int = 10):
|
||||||
"""Wrapper for search_chunks."""
|
"""Wrapper for search_chunks."""
|
||||||
from repl_functions import search_chunks
|
from repl_functions import search_chunks
|
||||||
|
|
||||||
return search_chunks(query, self.chunk_store, limit)
|
return search_chunks(query, self.chunk_store, limit)
|
||||||
|
|
||||||
def _list_chunks_by_tag_wrapper(self, tags):
|
def _list_chunks_by_tag_wrapper(self, tags):
|
||||||
"""Wrapper for list_chunks_by_tag."""
|
"""Wrapper for list_chunks_by_tag."""
|
||||||
from repl_functions import list_chunks_by_tag
|
from repl_functions import list_chunks_by_tag
|
||||||
|
|
||||||
return list_chunks_by_tag(tags, self.chunk_store)
|
return list_chunks_by_tag(tags, self.chunk_store)
|
||||||
|
|
||||||
def _get_linked_chunks_wrapper(self, chunk_id: str, link_type: str = None):
|
def _get_linked_chunks_wrapper(self, chunk_id: str, link_type: str = None):
|
||||||
"""Wrapper for get_linked_chunks."""
|
"""Wrapper for get_linked_chunks."""
|
||||||
from repl_functions import get_linked_chunks
|
from repl_functions import get_linked_chunks
|
||||||
|
|
||||||
return get_linked_chunks(chunk_id, self.chunk_store, link_type)
|
return get_linked_chunks(chunk_id, self.chunk_store, link_type)
|
||||||
|
|
||||||
def _llm_query_wrapper(self, prompt: str, context=None):
|
def _llm_query_wrapper(self, prompt: str, context=None):
|
||||||
|
|
@ -383,7 +543,9 @@ class REPLSession:
|
||||||
|
|
||||||
# Check max depth
|
# Check max depth
|
||||||
if self._current_depth >= self.max_depth:
|
if self._current_depth >= self.max_depth:
|
||||||
raise RecursionError(f"Maximum recursion depth ({self.max_depth}) exceeded")
|
raise RecursionError(
|
||||||
|
f"Maximum recursion depth ({self.max_depth}) exceeded"
|
||||||
|
)
|
||||||
|
|
||||||
# Increment depth counter
|
# Increment depth counter
|
||||||
self._current_depth += 1
|
self._current_depth += 1
|
||||||
|
|
@ -396,11 +558,14 @@ class REPLSession:
|
||||||
# Handle context as a list of chunk IDs
|
# Handle context as a list of chunk IDs
|
||||||
if isinstance(context, list):
|
if isinstance(context, list):
|
||||||
from repl_functions import read_chunk
|
from repl_functions import read_chunk
|
||||||
|
|
||||||
context_parts = []
|
context_parts = []
|
||||||
for chunk_id in context:
|
for chunk_id in context:
|
||||||
chunk = read_chunk(chunk_id, self.chunk_store)
|
chunk = read_chunk(chunk_id, self.chunk_store)
|
||||||
if chunk:
|
if chunk:
|
||||||
context_parts.append(f"Chunk {chunk_id}:\n{chunk.get('content', '')}")
|
context_parts.append(
|
||||||
|
f"Chunk {chunk_id}:\n{chunk.get('content', '')}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
context_parts.append(f"Chunk {chunk_id}:\n[Not found]")
|
context_parts.append(f"Chunk {chunk_id}:\n[Not found]")
|
||||||
context_str = "\n\n".join(context_parts)
|
context_str = "\n\n".join(context_parts)
|
||||||
|
|
@ -415,7 +580,7 @@ class REPLSession:
|
||||||
self._record_cost(response)
|
self._record_cost(response)
|
||||||
self._ensure_budget(allow_equal=True)
|
self._ensure_budget(allow_equal=True)
|
||||||
|
|
||||||
return response.text if hasattr(response, 'text') else str(response)
|
return response.text if hasattr(response, "text") else str(response)
|
||||||
except (RecursionError, MaxIterationsError):
|
except (RecursionError, MaxIterationsError):
|
||||||
# Don't catch these - let them propagate
|
# Don't catch these - let them propagate
|
||||||
raise
|
raise
|
||||||
|
|
@ -470,15 +635,19 @@ class REPLSession:
|
||||||
breakdown = {
|
breakdown = {
|
||||||
"total": self._total_cost,
|
"total": self._total_cost,
|
||||||
"calls": self._iteration_count,
|
"calls": self._iteration_count,
|
||||||
"per_call_average": self._total_cost / self._iteration_count if self._iteration_count > 0 else 0.0
|
"per_call_average": self._total_cost / self._iteration_count
|
||||||
|
if self._iteration_count > 0
|
||||||
|
else 0.0,
|
||||||
}
|
}
|
||||||
if self._max_cost_usd is not None:
|
if self._max_cost_usd is not None:
|
||||||
remaining = self._max_cost_usd - self._total_cost
|
remaining = self._max_cost_usd - self._total_cost
|
||||||
breakdown.update({
|
breakdown.update(
|
||||||
"budget": self._max_cost_usd,
|
{
|
||||||
"remaining": max(0.0, remaining),
|
"budget": self._max_cost_usd,
|
||||||
"over_budget": self._total_cost > self._max_cost_usd
|
"remaining": max(0.0, remaining),
|
||||||
})
|
"over_budget": self._total_cost > self._max_cost_usd,
|
||||||
|
}
|
||||||
|
)
|
||||||
return breakdown
|
return breakdown
|
||||||
|
|
||||||
def get_output(self) -> str:
|
def get_output(self) -> str:
|
||||||
|
|
@ -530,7 +699,7 @@ class REPLSession:
|
||||||
stderr_capture = io.StringIO()
|
stderr_capture = io.StringIO()
|
||||||
|
|
||||||
# Container for execution results
|
# Container for execution results
|
||||||
result_container = {'result': None, 'error': None, 'completed': False}
|
result_container = {"result": None, "error": None, "completed": False}
|
||||||
|
|
||||||
def run_execution():
|
def run_execution():
|
||||||
try:
|
try:
|
||||||
|
|
@ -539,27 +708,30 @@ class REPLSession:
|
||||||
|
|
||||||
# Try to eval as expression first
|
# Try to eval as expression first
|
||||||
try:
|
try:
|
||||||
compiled = compile(code, '<repl>', 'eval')
|
compiled = compile(code, "<repl>", "eval")
|
||||||
result_container['result'] = eval(compiled, self._namespace)
|
result_container["result"] = eval(compiled, self._namespace)
|
||||||
result_container['completed'] = True
|
result_container["completed"] = True
|
||||||
return
|
return
|
||||||
except SyntaxError:
|
except SyntaxError:
|
||||||
# Not an expression, try exec
|
# Not an expression, try exec
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Compile and execute as statements
|
# Compile and execute as statements
|
||||||
compiled = compile(code, '<repl>', 'exec')
|
compiled = compile(code, "<repl>", "exec")
|
||||||
exec(compiled, self._namespace)
|
exec(compiled, self._namespace)
|
||||||
|
|
||||||
# Update state with user-defined variables
|
# Update state with user-defined variables
|
||||||
for key, value in self._namespace.items():
|
for key, value in self._namespace.items():
|
||||||
if not key.startswith('_') and key not in ('__builtins__', '__name__'):
|
if not key.startswith("_") and key not in (
|
||||||
|
"__builtins__",
|
||||||
|
"__name__",
|
||||||
|
):
|
||||||
self._state[key] = value
|
self._state[key] = value
|
||||||
|
|
||||||
result_container['completed'] = True
|
result_container["completed"] = True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result_container['error'] = e
|
result_container["error"] = e
|
||||||
|
|
||||||
# Run execution in a thread with timeout
|
# Run execution in a thread with timeout
|
||||||
exec_thread = threading.Thread(target=run_execution)
|
exec_thread = threading.Thread(target=run_execution)
|
||||||
|
|
@ -577,14 +749,14 @@ class REPLSession:
|
||||||
raise TimeoutError(f"Execution exceeded {exec_timeout} seconds")
|
raise TimeoutError(f"Execution exceeded {exec_timeout} seconds")
|
||||||
|
|
||||||
# Check for errors from the thread
|
# Check for errors from the thread
|
||||||
if result_container['error'] is not None:
|
if result_container["error"] is not None:
|
||||||
raise result_container['error']
|
raise result_container["error"]
|
||||||
|
|
||||||
# Capture output
|
# Capture output
|
||||||
self._output.append(stdout_capture.getvalue())
|
self._output.append(stdout_capture.getvalue())
|
||||||
self._stderr.append(stderr_capture.getvalue())
|
self._stderr.append(stderr_capture.getvalue())
|
||||||
|
|
||||||
return result_container['result']
|
return result_container["result"]
|
||||||
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
raise
|
raise
|
||||||
|
|
@ -665,7 +837,7 @@ Write Python code to solve this query. Use FINAL('your answer') when done."""
|
||||||
try:
|
try:
|
||||||
self._ensure_budget()
|
self._ensure_budget()
|
||||||
response = self.llm_client.complete(retrieval_prompt)
|
response = self.llm_client.complete(retrieval_prompt)
|
||||||
code = response.text if hasattr(response, 'text') else str(response)
|
code = response.text if hasattr(response, "text") else str(response)
|
||||||
self._record_cost(response)
|
self._record_cost(response)
|
||||||
self._ensure_budget(allow_equal=True)
|
self._ensure_budget(allow_equal=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -682,7 +854,9 @@ Write Python code to solve this query. Use FINAL('your answer') when done."""
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Execution error - add to prompt and continue
|
# Execution error - add to prompt and continue
|
||||||
retrieval_prompt += f"\n\nError in previous attempt: {str(e)}\nPlease try again."
|
retrieval_prompt += (
|
||||||
|
f"\n\nError in previous attempt: {str(e)}\nPlease try again."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Max iterations reached without FINAL
|
# Max iterations reached without FINAL
|
||||||
|
|
@ -703,9 +877,11 @@ Write Python code to solve this query. Use FINAL('your answer') when done."""
|
||||||
def _record_cost(self, response: Any) -> None:
|
def _record_cost(self, response: Any) -> None:
|
||||||
"""Record cost from response or LLM client."""
|
"""Record cost from response or LLM client."""
|
||||||
cost_value = None
|
cost_value = None
|
||||||
if hasattr(response, 'cost_usd'):
|
if hasattr(response, "cost_usd"):
|
||||||
cost_value = response.cost_usd
|
cost_value = response.cost_usd
|
||||||
elif hasattr(self.llm_client, 'get_cost') and callable(self.llm_client.get_cost):
|
elif hasattr(self.llm_client, "get_cost") and callable(
|
||||||
|
self.llm_client.get_cost
|
||||||
|
):
|
||||||
cost_value = self.llm_client.get_cost()
|
cost_value = self.llm_client.get_cost()
|
||||||
if not isinstance(cost_value, (int, float)):
|
if not isinstance(cost_value, (int, float)):
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,11 @@ def read_chunk(chunk_id: str, chunk_store) -> Optional[Dict[str, Any]]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check for path traversal patterns
|
# Check for path traversal patterns
|
||||||
if '..' in chunk_id or '/' in chunk_id or '\\' in chunk_id:
|
if ".." in chunk_id or "/" in chunk_id or "\\" in chunk_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Only allow alphanumeric, hyphens, and underscores
|
# Only allow alphanumeric, hyphens, and underscores
|
||||||
if not re.match(r'^[a-zA-Z0-9_-]+$', chunk_id):
|
if not re.match(r"^[a-zA-Z0-9_-]+$", chunk_id):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -39,15 +39,15 @@ def read_chunk(chunk_id: str, chunk_store) -> Optional[Dict[str, Any]]:
|
||||||
|
|
||||||
# Convert Chunk dataclass to dict
|
# Convert Chunk dataclass to dict
|
||||||
return {
|
return {
|
||||||
'id': chunk.id,
|
"id": chunk.id,
|
||||||
'content': chunk.content,
|
"content": chunk.content,
|
||||||
'tokens': chunk.tokens,
|
"tokens": chunk.tokens,
|
||||||
'type': chunk.type,
|
"type": chunk.type,
|
||||||
'metadata': chunk.metadata,
|
"metadata": chunk.metadata,
|
||||||
'links': chunk.links,
|
"links": chunk.links,
|
||||||
'tags': chunk.tags,
|
"tags": chunk.tags,
|
||||||
}
|
}
|
||||||
except Exception:
|
except (AttributeError, TypeError, KeyError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ def search_chunks(query: str, chunk_store, limit: int = 10) -> List[str]:
|
||||||
break
|
break
|
||||||
|
|
||||||
return results
|
return results
|
||||||
except Exception:
|
except (AttributeError, TypeError, KeyError, ValueError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -109,11 +109,13 @@ def list_chunks_by_tag(tags, chunk_store) -> List[str]:
|
||||||
elif isinstance(tags, list):
|
elif isinstance(tags, list):
|
||||||
return chunk_store.list_chunks(tags=tags)
|
return chunk_store.list_chunks(tags=tags)
|
||||||
return []
|
return []
|
||||||
except Exception:
|
except (AttributeError, TypeError, KeyError, ValueError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_linked_chunks(chunk_id: str, chunk_store, link_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
def get_linked_chunks(
|
||||||
|
chunk_id: str, chunk_store, link_type: Optional[str] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get chunks linked to the given chunk.
|
Get chunks linked to the given chunk.
|
||||||
|
|
||||||
|
|
@ -133,18 +135,18 @@ def get_linked_chunks(chunk_id: str, chunk_store, link_type: Optional[str] = Non
|
||||||
linked = []
|
linked = []
|
||||||
for link in chunk.links:
|
for link in chunk.links:
|
||||||
# Filter by link type if specified
|
# Filter by link type if specified
|
||||||
if link_type and link.get('type') != link_type:
|
if link_type and link.get("type") != link_type:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
target_id = link.get('target_id')
|
target_id = link.get("target_id")
|
||||||
if target_id:
|
if target_id:
|
||||||
target_chunk = read_chunk(target_id, chunk_store)
|
target_chunk = read_chunk(target_id, chunk_store)
|
||||||
if target_chunk:
|
if target_chunk:
|
||||||
# Include link metadata
|
# Include link metadata
|
||||||
target_chunk['_link_type'] = link.get('type', 'unknown')
|
target_chunk["_link_type"] = link.get("type", "unknown")
|
||||||
target_chunk['_link_strength'] = link.get('strength', 0.5)
|
target_chunk["_link_strength"] = link.get("strength", 0.5)
|
||||||
linked.append(target_chunk)
|
linked.append(target_chunk)
|
||||||
|
|
||||||
return linked
|
return linked
|
||||||
except Exception:
|
except (AttributeError, TypeError, KeyError, ValueError):
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class TestMemorySafetyEnforcement(unittest.TestCase):
|
||||||
policy = MemoryPolicy(
|
policy = MemoryPolicy(
|
||||||
project_root=project_root,
|
project_root=project_root,
|
||||||
write_layers=["project_global"],
|
write_layers=["project_global"],
|
||||||
redaction_rules=["api_key"]
|
redaction_rules=["api_key"],
|
||||||
)
|
)
|
||||||
store = LayeredMemoryStore(policy=policy, agent_id="agent-1")
|
store = LayeredMemoryStore(policy=policy, agent_id="agent-1")
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ class TestMemorySafetyEnforcement(unittest.TestCase):
|
||||||
"entry_type": "fact",
|
"entry_type": "fact",
|
||||||
"content": "My api_key: sk-12345",
|
"content": "My api_key: sk-12345",
|
||||||
"project_id": "rlm-mem",
|
"project_id": "rlm-mem",
|
||||||
"tags": ["api_key:secret"]
|
"tags": ["api_key:secret"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -48,7 +48,7 @@ class TestMemorySafetyEnforcement(unittest.TestCase):
|
||||||
policy = MemoryPolicy(
|
policy = MemoryPolicy(
|
||||||
project_root=project_root,
|
project_root=project_root,
|
||||||
write_layers=["user_global"],
|
write_layers=["user_global"],
|
||||||
allow_user_global_write=False
|
allow_user_global_write=False,
|
||||||
)
|
)
|
||||||
store = LayeredMemoryStore(policy=policy, agent_id="agent-1")
|
store = LayeredMemoryStore(policy=policy, agent_id="agent-1")
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ class TestMemorySafetyEnforcement(unittest.TestCase):
|
||||||
"scope": "user_global",
|
"scope": "user_global",
|
||||||
"entry_type": "fact",
|
"entry_type": "fact",
|
||||||
"content": "Secret",
|
"content": "Secret",
|
||||||
"project_id": "rlm-mem"
|
"project_id": "rlm-mem",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertIn("blocked by policy", str(cm.exception))
|
self.assertIn("blocked by policy", str(cm.exception))
|
||||||
|
|
@ -75,7 +75,7 @@ class TestMemorySafetyEnforcement(unittest.TestCase):
|
||||||
policy = MemoryPolicy(
|
policy = MemoryPolicy(
|
||||||
project_root=project_root,
|
project_root=project_root,
|
||||||
write_layers=["user_global"],
|
write_layers=["user_global"],
|
||||||
allow_user_global_write=True
|
allow_user_global_write=True,
|
||||||
)
|
)
|
||||||
store = LayeredMemoryStore(policy=policy, agent_id="agent-1")
|
store = LayeredMemoryStore(policy=policy, agent_id="agent-1")
|
||||||
|
|
||||||
|
|
@ -90,15 +90,16 @@ class TestMemorySafetyEnforcement(unittest.TestCase):
|
||||||
"scope": "user_global",
|
"scope": "user_global",
|
||||||
"entry_type": "fact",
|
"entry_type": "fact",
|
||||||
"content": "Shared",
|
"content": "Shared",
|
||||||
"project_id": "rlm-mem"
|
"project_id": "rlm-mem",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
self.fail(f"append_entry raised PermissionError unexpectedly: {e}")
|
self.fail(f"append_entry raised PermissionError unexpectedly: {e}")
|
||||||
except Exception:
|
except (OSError, IOError, FileNotFoundError):
|
||||||
# Other errors (like Path.home() access) are acceptable here
|
# Other errors (like Path.home() access) are acceptable here
|
||||||
# as long as it's not the policy block
|
# as long as it's not a policy block
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,13 @@ export async function POST(request: Request): Promise<Response> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: { code: 'INVALID_BODY', message: 'Request body must be a valid object.' } },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = body as { agent?: string; message?: string };
|
const parsed = body as { agent?: string; message?: string };
|
||||||
const result = await ackAgentMessage({
|
const result = await ackAgentMessage({
|
||||||
agent: parsed.agent ?? '',
|
agent: parsed.agent ?? '',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { showAgent, deriveLiveness } from './agent-registry';
|
import { showAgent, deriveLiveness } from './agent-registry';
|
||||||
|
import { canonicalizeWindowsPath } from './pathing';
|
||||||
import type { AgentMessage } from './agent-mail';
|
import type { AgentMessage } from './agent-mail';
|
||||||
|
|
||||||
const MIN_TTL_MINUTES = 5;
|
const MIN_TTL_MINUTES = 5;
|
||||||
|
|
@ -102,28 +103,11 @@ function messageIndexDirectoryPath(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a path according to the Operative Protocol v1:
|
* Normalizes a path using the canonicalization helpers from pathing module.
|
||||||
* 1. Resolve to absolute path.
|
* Converts to forward slashes for stable case-insensitive comparison.
|
||||||
* 2. Normalize separators to /.
|
|
||||||
* 3. On Windows, lowercase normalized path.
|
|
||||||
* 4. Remove trailing slash except root.
|
|
||||||
*/
|
*/
|
||||||
export function normalizePath(p: string): string {
|
export function normalizePath(p: string): string {
|
||||||
let resolved = path.resolve(p);
|
return canonicalizeWindowsPath(p).replace(/\\/g, '/');
|
||||||
// Normalize separators
|
|
||||||
resolved = resolved.replace(/\\/g, '/');
|
|
||||||
|
|
||||||
// Lowercase on Windows
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
resolved = resolved.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove trailing slash except root (e.g., C:/ or /)
|
|
||||||
if (resolved.length > 3 && resolved.endsWith('/')) {
|
|
||||||
resolved = resolved.slice(0, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OverlapClass = 'exact' | 'partial' | 'disjoint';
|
export type OverlapClass = 'exact' | 'partial' | 'disjoint';
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,16 @@ interface CacheEntry<T> {
|
||||||
const agentCache = new Map<string, CacheEntry<AgentRecord | null>>();
|
const agentCache = new Map<string, CacheEntry<AgentRecord | null>>();
|
||||||
const CACHE_TTL_MS = 30_000;
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
function getCachedAgent(beadId: string): AgentRecord | null {
|
function getCachedAgent(beadId: string): AgentRecord | null | undefined {
|
||||||
const entry = agentCache.get(beadId);
|
const entry = agentCache.get(beadId);
|
||||||
if (entry && entry.expiresAt > Date.now()) {
|
if (!entry) {
|
||||||
return entry.data;
|
return undefined; // Cache miss
|
||||||
}
|
}
|
||||||
agentCache.delete(beadId);
|
if (entry.expiresAt > Date.now()) {
|
||||||
return null;
|
return entry.data; // Valid cache hit (could be null or AgentRecord)
|
||||||
|
}
|
||||||
|
agentCache.delete(beadId); // Expired entry
|
||||||
|
return null; // Treat expired as miss
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCachedAgent(beadId: string, data: AgentRecord | null): void {
|
function setCachedAgent(beadId: string, data: AgentRecord | null): void {
|
||||||
|
|
@ -82,7 +85,7 @@ function trimOrEmpty(value: unknown): string {
|
||||||
async function callBdAgentShow(beadId: string, projectRoot: string): Promise<AgentRecord | null> {
|
async function callBdAgentShow(beadId: string, projectRoot: string): Promise<AgentRecord | null> {
|
||||||
const cached = getCachedAgent(beadId);
|
const cached = getCachedAgent(beadId);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return cached;
|
return cached; // Valid cache hit (could be null or AgentRecord)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showResult = await runBdCommand({
|
const showResult = await runBdCommand({
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { canonicalizeWindowsPath } from './pathing';
|
||||||
|
|
||||||
function isWindowsAbsolute(input: string): boolean {
|
function isWindowsAbsolute(input: string): boolean {
|
||||||
return /^[A-Za-z]:[\\/]/.test(input);
|
return /^[A-Za-z]:[\\/]/.test(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
function windowsToPosixMount(input: string): string {
|
function windowsToPosixMount(input: string): string {
|
||||||
const drive = input[0].toLowerCase();
|
const normalized = canonicalizeWindowsPath(input);
|
||||||
const tail = input.slice(2).replace(/\\/g, '/').replace(/^\/+/, '');
|
const drive = normalized[0]?.toLowerCase() || '';
|
||||||
return `/mnt/${drive}/${tail}`;
|
const tail = normalized.slice(2)?.replace(/\\/g, '/')?.replace(/^\/+/, '') || '';
|
||||||
|
if (drive && tail) {
|
||||||
|
return `/mnt/${drive}/${tail}`;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeProjectRootForRuntime(input: string): string {
|
export function normalizeProjectRootForRuntime(input: string): string {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,30 @@ export function resolveIssuesJsonlPath(projectRoot: string = process.cwd()): str
|
||||||
return resolveIssuesJsonlPathCandidates(projectRoot)[0];
|
return resolveIssuesJsonlPathCandidates(projectRoot)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write issues to disk using BD audit record when available.
|
||||||
|
* This ensures all writes go through the BD audit system for watcher/SSE parity.
|
||||||
|
*/
|
||||||
|
export async function writeIssuesToDisk(
|
||||||
|
issues: BeadIssueWithProject[],
|
||||||
|
options: ReadIssuesOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const projectRoot = options.projectRoot ?? process.cwd();
|
||||||
|
const issuesJson = JSON.stringify(issues, null, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { execFileSync } = await import('child_process');
|
||||||
|
execFileSync('bd', ['audit', 'record', '--stdin'], {
|
||||||
|
input: issuesJson,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
const issuesPath = resolveIssuesJsonlPath(projectRoot);
|
||||||
|
const { writeFile } = await import('node:fs/promises');
|
||||||
|
await writeFile(issuesPath, issuesJson, 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise<BeadIssueWithProject[]> {
|
export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise<BeadIssueWithProject[]> {
|
||||||
const projectRoot = options.projectRoot ?? process.cwd();
|
const projectRoot = options.projectRoot ?? process.cwd();
|
||||||
const project = buildProjectContext(projectRoot, {
|
const project = buildProjectContext(projectRoot, {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import fs from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { readIssuesFromDisk, resolveIssuesJsonlPath, resolveIssuesJsonlPathCandidates } from '../../src/lib/read-issues';
|
import { readIssuesFromDisk, resolveIssuesJsonlPath, resolveIssuesJsonlPathCandidates, writeIssuesToDisk } from '../../src/lib/read-issues';
|
||||||
import { canonicalizeWindowsPath, sameWindowsPath, toDisplayPath, windowsPathKey } from '../../src/lib/pathing';
|
import { canonicalizeWindowsPath, sameWindowsPath, toDisplayPath, windowsPathKey } from '../../src/lib/pathing';
|
||||||
|
|
||||||
test('resolveIssuesJsonlPath appends .beads/issues.jsonl using windows-safe pathing', () => {
|
test('resolveIssuesJsonlPath appends .beads/issues.jsonl using windows-safe pathing', () => {
|
||||||
|
|
@ -18,52 +18,134 @@ test('resolveIssuesJsonlPathCandidates includes .jsonl and .jsonl.new fallback p
|
||||||
assert.equal(sameWindowsPath(fallback, 'C:/Repo/Project/.beads/issues.jsonl.new'), true);
|
assert.equal(sameWindowsPath(fallback, 'C:/Repo/Project/.beads/issues.jsonl.new'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readIssuesFromDisk parses JSONL issues from disk', async () => {
|
test('readIssuesFromDisk parses JSONL issues from disk', async (t) => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-'));
|
try {
|
||||||
const beadsDir = path.join(root, '.beads');
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-'));
|
||||||
const issuesPath = path.join(beadsDir, 'issues.jsonl');
|
const beadsDir = path.join(root, '.beads');
|
||||||
|
const issuesPath = path.join(beadsDir, 'issues.jsonl');
|
||||||
|
|
||||||
await fs.mkdir(beadsDir, { recursive: true });
|
await fs.mkdir(beadsDir, { recursive: true });
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
issuesPath,
|
issuesPath,
|
||||||
[
|
[
|
||||||
JSON.stringify({ id: 'bb-1', title: 'Open issue', status: 'open', priority: 0, issue_type: 'task' }),
|
JSON.stringify({ id: 'bb-1', title: 'Open issue', status: 'open', priority: 0, issue_type: 'task' }),
|
||||||
JSON.stringify({ id: 'bb-2', title: 'Hidden tombstone', status: 'tombstone' }),
|
JSON.stringify({ id: 'bb-2', title: 'Hidden tombstone', status: 'tombstone' }),
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf8',
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const issues = await readIssuesFromDisk({ projectRoot: root });
|
||||||
|
|
||||||
|
assert.equal(issues.length, 1);
|
||||||
|
assert.equal(issues[0].id, 'bb-1');
|
||||||
|
assert.equal(issues[0].priority, 0);
|
||||||
|
assert.equal(issues[0].project.root, canonicalizeWindowsPath(root));
|
||||||
|
assert.equal(issues[0].project.key, windowsPathKey(root));
|
||||||
|
assert.equal(issues[0].project.displayPath, toDisplayPath(root));
|
||||||
|
assert.equal(issues[0].project.name, path.basename(canonicalizeWindowsPath(root)));
|
||||||
|
assert.equal(issues[0].project.source, 'local');
|
||||||
|
assert.equal(issues[0].project.addedAt, null);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).message.includes('Dolt unreachable')) {
|
||||||
|
t.skip('Dolt not available for file-based tests');
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readIssuesFromDisk returns empty list when issues file does not exist', async (t) => {
|
||||||
|
try {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-missing-'));
|
||||||
|
const issues = await readIssuesFromDisk({ projectRoot: root });
|
||||||
|
assert.deepEqual(issues, []);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).message.includes('Dolt unreachable')) {
|
||||||
|
t.skip('Dolt not available for file-based tests');
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readIssuesFromDisk falls back to issues.jsonl.new when issues.jsonl is missing', async (t) => {
|
||||||
|
try {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-fallback-'));
|
||||||
|
const beadsDir = path.join(root, '.beads');
|
||||||
|
const fallbackPath = path.join(beadsDir, 'issues.jsonl.new');
|
||||||
|
await fs.mkdir(beadsDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
fallbackPath,
|
||||||
|
JSON.stringify({ id: 'bb-fallback', title: 'From fallback', status: 'open', priority: 2, issue_type: 'task' }),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const issues = await readIssuesFromDisk({ projectRoot: root });
|
||||||
|
assert.equal(issues.length, 1);
|
||||||
|
assert.equal(issues[0].id, 'bb-fallback');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).message.includes('Dolt unreachable')) {
|
||||||
|
t.skip('Dolt not available for file-based tests');
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readIssuesFromDisk throws error when Dolt is unreachable (BD compliance)', async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-dolt-check-'));
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => readIssuesFromDisk({ projectRoot: root }),
|
||||||
|
{
|
||||||
|
message: 'Dolt unreachable - ensure Dolt is running: bd dolt start',
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const issues = await readIssuesFromDisk({ projectRoot: root });
|
|
||||||
|
|
||||||
assert.equal(issues.length, 1);
|
|
||||||
assert.equal(issues[0].id, 'bb-1');
|
|
||||||
assert.equal(issues[0].priority, 0);
|
|
||||||
assert.equal(issues[0].project.root, canonicalizeWindowsPath(root));
|
|
||||||
assert.equal(issues[0].project.key, windowsPathKey(root));
|
|
||||||
assert.equal(issues[0].project.displayPath, toDisplayPath(root));
|
|
||||||
assert.equal(issues[0].project.name, path.basename(canonicalizeWindowsPath(root)));
|
|
||||||
assert.equal(issues[0].project.source, 'local');
|
|
||||||
assert.equal(issues[0].project.addedAt, null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readIssuesFromDisk returns empty list when issues file does not exist', async () => {
|
test('writeIssuesToDisk uses BD audit record when available', async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-missing-'));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-write-bd-'));
|
||||||
const issues = await readIssuesFromDisk({ projectRoot: root });
|
|
||||||
assert.deepEqual(issues, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('readIssuesFromDisk falls back to issues.jsonl.new when issues.jsonl is missing', async () => {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-fallback-'));
|
|
||||||
const beadsDir = path.join(root, '.beads');
|
const beadsDir = path.join(root, '.beads');
|
||||||
const fallbackPath = path.join(beadsDir, 'issues.jsonl.new');
|
|
||||||
await fs.mkdir(beadsDir, { recursive: true });
|
await fs.mkdir(beadsDir, { recursive: true });
|
||||||
await fs.writeFile(
|
|
||||||
fallbackPath,
|
|
||||||
JSON.stringify({ id: 'bb-fallback', title: 'From fallback', status: 'open', priority: 2, issue_type: 'task' }),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const issues = await readIssuesFromDisk({ projectRoot: root });
|
const issues = [
|
||||||
assert.equal(issues.length, 1);
|
{
|
||||||
assert.equal(issues[0].id, 'bb-fallback');
|
id: 'bb-1',
|
||||||
|
title: 'Test issue',
|
||||||
|
description: null,
|
||||||
|
status: 'open' as const,
|
||||||
|
priority: 1,
|
||||||
|
issue_type: 'task' as const,
|
||||||
|
assignee: null,
|
||||||
|
templateId: null,
|
||||||
|
owner: null,
|
||||||
|
labels: [],
|
||||||
|
dependencies: [],
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
closed_at: null,
|
||||||
|
close_reason: null,
|
||||||
|
closed_by_session: null,
|
||||||
|
created_by: null,
|
||||||
|
due_at: null,
|
||||||
|
estimated_minutes: null,
|
||||||
|
external_ref: null,
|
||||||
|
comments_count: 0,
|
||||||
|
metadata: {},
|
||||||
|
project: {
|
||||||
|
root,
|
||||||
|
key: 'test-key',
|
||||||
|
displayPath: root,
|
||||||
|
name: 'test',
|
||||||
|
source: 'local' as const,
|
||||||
|
addedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await writeIssuesToDisk(issues, { projectRoot: root });
|
||||||
|
|
||||||
|
const issuesPath = resolveIssuesJsonlPath(root);
|
||||||
|
const content = await fs.readFile(issuesPath, 'utf8');
|
||||||
|
assert.ok(content.includes('bb-1'));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue