code-context-control 2.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
@@ -0,0 +1,307 @@
1
+ """LLM-powered analysis engine for Oracle."""
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from oracle.services.cross_memory import CrossMemory
8
+ from oracle.services.memory_reader import MemoryReader
9
+ from oracle.services.ollama_bridge import OllamaBridge
10
+
11
+ _SYSTEM_PROMPT = """You are Oracle, an AI memory analyst for software projects.
12
+ You analyze project memory facts and identify patterns, risks, and opportunities.
13
+ Always respond with valid JSON. No markdown fences, no extra text."""
14
+
15
+
16
+ class InsightEngine:
17
+ """LLM-powered cross-project analysis."""
18
+
19
+ def __init__(self, bridge: OllamaBridge, reader: MemoryReader, cross_memory: CrossMemory):
20
+ self.bridge = bridge
21
+ self.reader = reader
22
+ self.cross_memory = cross_memory
23
+
24
+ def analyze_project(self, project_path: str, max_facts: int = 100) -> dict:
25
+ """LLM reviews a project's top facts. Returns analysis dict."""
26
+ facts = self.reader.read_facts(project_path)
27
+ if not facts:
28
+ return {"project": project_path, "analysis": "No facts found.", "suggestions": []}
29
+
30
+ # Sort by relevance (most accessed first), take top N
31
+ facts.sort(key=lambda f: int(f.get("relevance_count", 0)), reverse=True)
32
+ top_facts = facts[:max_facts]
33
+
34
+ fact_summary = "\n".join(
35
+ f"- [{f.get('category', 'general')}] {f.get('fact', '')[:200]}"
36
+ for f in top_facts
37
+ )
38
+
39
+ prompt = f"""Analyze these memory facts from project "{Path(project_path).name}":
40
+
41
+ {fact_summary}
42
+
43
+ Return JSON with:
44
+ {{
45
+ "health_narrative": "1-2 sentence summary of memory health",
46
+ "key_themes": ["theme1", "theme2"],
47
+ "suggestions": [
48
+ {{"type": "merge|archive|investigate", "description": "what to do", "fact_ids": []}}
49
+ ]
50
+ }}"""
51
+
52
+ response = self.bridge.generate(prompt, system=_SYSTEM_PROMPT, max_tokens=1024)
53
+ if not response:
54
+ return {"project": project_path, "analysis": "LLM unavailable", "suggestions": []}
55
+
56
+ parsed = self._parse_json(response)
57
+ return {
58
+ "project": project_path,
59
+ "analysis": parsed.get("health_narrative", response[:500]),
60
+ "key_themes": parsed.get("key_themes", []),
61
+ "suggestions": parsed.get("suggestions", []),
62
+ }
63
+
64
+ def find_cross_project_links(self, project_paths: list[str], max_facts_per: int = 50) -> list[dict]:
65
+ """Compare facts across projects, generate cross-project insights."""
66
+ if len(project_paths) < 2:
67
+ return []
68
+
69
+ project_summaries = []
70
+ for path in project_paths:
71
+ facts = self.reader.read_facts(path)
72
+ facts.sort(key=lambda f: int(f.get("relevance_count", 0)), reverse=True)
73
+ top = facts[:max_facts_per]
74
+ summary = "\n".join(
75
+ f" - [{f.get('category', 'general')}] {f.get('fact', '')[:150]}"
76
+ for f in top
77
+ )
78
+ project_summaries.append(f"### {Path(path).name}\n{summary}")
79
+
80
+ prompt = f"""Compare memory facts from these {len(project_paths)} projects:
81
+
82
+ {"".join(project_summaries)}
83
+
84
+ Identify cross-project patterns, shared conventions, dependencies, risks, and reuse opportunities.
85
+
86
+ Return JSON array:
87
+ [
88
+ {{
89
+ "type": "pattern|dependency|convention|risk|opportunity|drift",
90
+ "text": "description of the insight",
91
+ "projects": ["project_name_1", "project_name_2"],
92
+ "confidence": 0.0-1.0,
93
+ "tags": ["tag1"]
94
+ }}
95
+ ]"""
96
+
97
+ response = self.bridge.generate(prompt, system=_SYSTEM_PROMPT, max_tokens=2048, num_ctx=16384)
98
+ if not response:
99
+ return []
100
+
101
+ insights_raw = self._parse_json_array(response)
102
+ # Map project names back to paths
103
+ name_to_path = {Path(p).name: p for p in project_paths}
104
+
105
+ new_insights = []
106
+ for raw in insights_raw:
107
+ source_projects = [
108
+ name_to_path.get(n, n) for n in raw.get("projects", [])
109
+ ]
110
+ if len(source_projects) < 2:
111
+ continue
112
+
113
+ insight = self.cross_memory.add_insight(
114
+ text=raw.get("text", ""),
115
+ insight_type=raw.get("type", "pattern"),
116
+ source_projects=source_projects,
117
+ confidence=float(raw.get("confidence", 0.7)),
118
+ tags=raw.get("tags", []),
119
+ )
120
+ new_insights.append(insight)
121
+
122
+ return new_insights
123
+
124
+ def generate_cross_project_insights(
125
+ self,
126
+ project_paths: list[str],
127
+ federated_graph: dict | None = None,
128
+ max_pairs: int = 40,
129
+ ) -> list[dict]:
130
+ """Generate cross-project insights from the federated graph's cross_similar edges.
131
+
132
+ Feeds the LLM with concrete pairs of similar facts across projects so it can
133
+ produce typed insights (shared_convention, duplicated_fact, divergent_decision,
134
+ shared_bug_pattern). Falls back to find_cross_project_links if no graph given.
135
+ """
136
+ if not federated_graph:
137
+ return self.find_cross_project_links(project_paths)
138
+
139
+ nodes_by_id = {n["id"]: n for n in federated_graph.get("nodes", [])}
140
+ cross_edges = [
141
+ e for e in federated_graph.get("edges", [])
142
+ if e.get("scope") == "cross_similar"
143
+ ]
144
+ if not cross_edges:
145
+ return []
146
+
147
+ cross_edges.sort(key=lambda e: e.get("weight", 0), reverse=True)
148
+ pairs = []
149
+ for e in cross_edges[:max_pairs]:
150
+ a = nodes_by_id.get(e["src"])
151
+ b = nodes_by_id.get(e["dst"])
152
+ if not a or not b:
153
+ continue
154
+ pairs.append({
155
+ "a": {"project": a["project"], "category": a.get("category"),
156
+ "text": a.get("text", ""), "local_id": a.get("local_id"),
157
+ "project_path": a.get("project_path")},
158
+ "b": {"project": b["project"], "category": b.get("category"),
159
+ "text": b.get("text", ""), "local_id": b.get("local_id"),
160
+ "project_path": b.get("project_path")},
161
+ "similarity": e.get("weight"),
162
+ })
163
+ if not pairs:
164
+ return []
165
+
166
+ pair_lines = []
167
+ for i, p in enumerate(pairs):
168
+ pair_lines.append(
169
+ f"{i+1}. [{p['similarity']:.2f}] "
170
+ f"{p['a']['project']} ({p['a']['category']}): {p['a']['text'][:200]}\n"
171
+ f" vs {p['b']['project']} ({p['b']['category']}): {p['b']['text'][:200]}"
172
+ )
173
+ prompt = (
174
+ f"These {len(pairs)} pairs of facts from different C3 projects scored "
175
+ f"as similar. Classify each into one of: shared_convention, duplicated_fact, "
176
+ f"divergent_decision, shared_bug_pattern, opportunity, drift. Merge pairs "
177
+ f"into cross-project insights when they reinforce the same point.\n\n"
178
+ + "\n".join(pair_lines)
179
+ + "\n\nReturn JSON array of insights:\n"
180
+ '[{"type": "<type>", "text": "<insight>", '
181
+ '"projects": ["<proj_slug>", ...], '
182
+ '"pair_indices": [<int>, ...], '
183
+ '"confidence": 0.0-1.0, "tags": ["..."]}]'
184
+ )
185
+
186
+ response = self.bridge.generate(prompt, system=_SYSTEM_PROMPT, max_tokens=2048, num_ctx=16384)
187
+ if not response:
188
+ return []
189
+ raw_list = self._parse_json_array(response)
190
+
191
+ slug_to_path = {n["project"]: n["project_path"] for n in federated_graph.get("nodes", [])}
192
+ new_insights: list[dict] = []
193
+ for raw in raw_list:
194
+ proj_slugs = raw.get("projects", [])
195
+ source_projects = [slug_to_path.get(s, s) for s in proj_slugs]
196
+ source_projects = [p for p in source_projects if p in project_paths]
197
+ if len(source_projects) < 2:
198
+ continue
199
+ source_fact_ids: dict[str, list[str]] = {}
200
+ for idx in raw.get("pair_indices", []):
201
+ try:
202
+ p = pairs[int(idx) - 1]
203
+ except (ValueError, IndexError):
204
+ continue
205
+ for side in ("a", "b"):
206
+ path = p[side]["project_path"]
207
+ fid = p[side]["local_id"]
208
+ if path and fid:
209
+ source_fact_ids.setdefault(path, []).append(fid)
210
+ insight = self.cross_memory.add_insight(
211
+ text=raw.get("text", ""),
212
+ insight_type=raw.get("type", "pattern"),
213
+ source_projects=source_projects,
214
+ source_fact_ids=source_fact_ids or None,
215
+ confidence=float(raw.get("confidence", 0.7)),
216
+ tags=raw.get("tags", []),
217
+ )
218
+ new_insights.append(insight)
219
+ return new_insights
220
+
221
+ def suggest_consolidation(self, project_path: str) -> list[dict]:
222
+ """Analyze a project's facts and suggest merge/archive actions."""
223
+ facts = self.reader.read_facts(project_path)
224
+ if len(facts) < 5:
225
+ return []
226
+
227
+ # Focus on potentially stale/duplicate facts
228
+ fact_texts = "\n".join(
229
+ f"[{f.get('id', '?')}] ({f.get('category', 'general')}) {f.get('fact', '')[:200]}"
230
+ for f in facts
231
+ )
232
+
233
+ prompt = f"""Review these {len(facts)} memory facts and suggest consolidation:
234
+
235
+ {fact_texts}
236
+
237
+ Find duplicates to merge and stale facts to archive.
238
+ Return JSON array:
239
+ [
240
+ {{
241
+ "action": "merge",
242
+ "fact_ids": ["id1", "id2"],
243
+ "survivor_id": "id1",
244
+ "merged_text": "combined fact text",
245
+ "reason": "why merge"
246
+ }},
247
+ {{
248
+ "action": "archive",
249
+ "fact_ids": ["id3"],
250
+ "reason": "why archive"
251
+ }}
252
+ ]"""
253
+
254
+ response = self.bridge.generate(prompt, system=_SYSTEM_PROMPT, max_tokens=2048, num_ctx=16384)
255
+ if not response:
256
+ return []
257
+
258
+ return self._parse_json_array(response)
259
+
260
+ def _parse_json(self, text: str) -> dict:
261
+ """Extract JSON object from LLM response."""
262
+ # Try direct parse
263
+ try:
264
+ return json.loads(text)
265
+ except Exception:
266
+ pass
267
+ # Try extracting from markdown fences
268
+ match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
269
+ if match:
270
+ try:
271
+ return json.loads(match.group(1))
272
+ except Exception:
273
+ pass
274
+ # Try finding first { ... }
275
+ match = re.search(r"\{.*\}", text, re.DOTALL)
276
+ if match:
277
+ try:
278
+ return json.loads(match.group(0))
279
+ except Exception:
280
+ pass
281
+ return {}
282
+
283
+ def _parse_json_array(self, text: str) -> list[dict]:
284
+ """Extract JSON array from LLM response."""
285
+ try:
286
+ result = json.loads(text)
287
+ if isinstance(result, list):
288
+ return result
289
+ except Exception:
290
+ pass
291
+ match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
292
+ if match:
293
+ try:
294
+ result = json.loads(match.group(1))
295
+ if isinstance(result, list):
296
+ return result
297
+ except Exception:
298
+ pass
299
+ match = re.search(r"\[.*\]", text, re.DOTALL)
300
+ if match:
301
+ try:
302
+ result = json.loads(match.group(0))
303
+ if isinstance(result, list):
304
+ return result
305
+ except Exception:
306
+ pass
307
+ return []
@@ -0,0 +1,106 @@
1
+ """Read-only access to per-project C3 memory stores."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
8
+ if str(_PROJECT_ROOT) not in sys.path:
9
+ sys.path.insert(0, str(_PROJECT_ROOT))
10
+
11
+ from services.memory_scorer import MemoryScorer # noqa: E402
12
+
13
+
14
+ class MemoryReader:
15
+ """Reads project .c3/facts/ files without writing."""
16
+
17
+ def __init__(self):
18
+ self._scorer = MemoryScorer()
19
+
20
+ def read_facts(self, project_path: str) -> list[dict]:
21
+ """Load all facts from a project's facts.json."""
22
+ facts_file = Path(project_path) / ".c3" / "facts" / "facts.json"
23
+ if not facts_file.is_file():
24
+ return []
25
+ try:
26
+ with open(facts_file, encoding="utf-8") as f:
27
+ data = json.load(f)
28
+ return data if isinstance(data, list) else []
29
+ except Exception:
30
+ return []
31
+
32
+ def read_graph(self, project_path: str) -> dict:
33
+ """Load memory graph edges."""
34
+ graph_file = Path(project_path) / ".c3" / "facts" / "memory_graph.json"
35
+ if not graph_file.is_file():
36
+ return {"edges": [], "adjacency": {}}
37
+ try:
38
+ with open(graph_file, encoding="utf-8") as f:
39
+ return json.load(f)
40
+ except Exception:
41
+ return {"edges": [], "adjacency": {}}
42
+
43
+ def read_fingerprints(self, project_path: str) -> list[dict]:
44
+ """Load session fingerprints."""
45
+ fp_file = Path(project_path) / ".c3" / "facts" / "session_fingerprints.json"
46
+ if not fp_file.is_file():
47
+ return []
48
+ try:
49
+ with open(fp_file, encoding="utf-8") as f:
50
+ data = json.load(f)
51
+ return data if isinstance(data, list) else []
52
+ except Exception:
53
+ return []
54
+
55
+ def get_fact_stats(self, project_path: str) -> dict:
56
+ """Compute summary statistics for a project's facts."""
57
+ facts = self.read_facts(project_path)
58
+ if not facts:
59
+ return {"total": 0, "by_category": {}, "by_tier": {}, "by_lifecycle": {}}
60
+
61
+ by_category: dict[str, int] = {}
62
+ by_lifecycle: dict[str, int] = {}
63
+ for f in facts:
64
+ cat = f.get("category", "general")
65
+ by_category[cat] = by_category.get(cat, 0) + 1
66
+ lc = f.get("lifecycle", "active")
67
+ by_lifecycle[lc] = by_lifecycle.get(lc, 0) + 1
68
+
69
+ tiers = self._scorer.tier_partition(facts)
70
+ by_tier = {tier: len(tier_facts) for tier, tier_facts in tiers.items()}
71
+
72
+ return {
73
+ "total": len(facts),
74
+ "by_category": by_category,
75
+ "by_tier": by_tier,
76
+ "by_lifecycle": by_lifecycle,
77
+ }
78
+
79
+ def get_graph_stats(self, project_path: str) -> dict:
80
+ """Compute graph statistics."""
81
+ graph = self.read_graph(project_path)
82
+ edges = graph.get("edges", [])
83
+ adjacency = graph.get("adjacency", {})
84
+
85
+ nodes = set()
86
+ edge_types: dict[str, int] = {}
87
+ for edge in edges:
88
+ nodes.add(edge.get("src", ""))
89
+ nodes.add(edge.get("dst", ""))
90
+ et = edge.get("type", "unknown")
91
+ edge_types[et] = edge_types.get(et, 0) + 1
92
+
93
+ # Count orphaned edges (referencing non-existent facts)
94
+ facts = self.read_facts(project_path)
95
+ fact_ids = {f.get("id") for f in facts if f.get("id")}
96
+ orphaned = sum(
97
+ 1 for e in edges
98
+ if e.get("src") not in fact_ids or e.get("dst") not in fact_ids
99
+ )
100
+
101
+ return {
102
+ "total_edges": len(edges),
103
+ "total_nodes": len(nodes),
104
+ "edge_types": edge_types,
105
+ "orphaned_edges": orphaned,
106
+ }
@@ -0,0 +1,182 @@
1
+ """Handles approved write-backs to project .c3/facts/."""
2
+
3
+ import json
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from oracle.config import ORACLE_DIR
9
+
10
+ _SUGGESTIONS_FILE = ORACLE_DIR / "suggestions.json"
11
+
12
+
13
+ def _load_suggestions() -> list[dict]:
14
+ try:
15
+ if _SUGGESTIONS_FILE.is_file():
16
+ with open(_SUGGESTIONS_FILE, encoding="utf-8") as f:
17
+ return json.load(f)
18
+ except Exception:
19
+ pass
20
+ return []
21
+
22
+
23
+ def _save_suggestions(suggestions: list[dict]):
24
+ ORACLE_DIR.mkdir(parents=True, exist_ok=True)
25
+ with open(_SUGGESTIONS_FILE, "w", encoding="utf-8") as f:
26
+ json.dump(suggestions, f, indent=2)
27
+
28
+
29
+ class MemoryWriter:
30
+ """Manages suggestion queue and approved write-backs to project memory."""
31
+
32
+ def suggest(self, project_path: str, suggestion_type: str, data: dict) -> dict:
33
+ """Create a pending suggestion for a project.
34
+
35
+ suggestion_type: 'merge_facts', 'archive_facts', 'add_fact'
36
+ data: type-specific payload
37
+ """
38
+ suggestions = _load_suggestions()
39
+ suggestion = {
40
+ "id": f"sug_{uuid.uuid4().hex[:12]}",
41
+ "project_path": project_path,
42
+ "type": suggestion_type,
43
+ "data": data,
44
+ "status": "pending",
45
+ "created_at": datetime.now(timezone.utc).isoformat(),
46
+ "resolved_at": None,
47
+ }
48
+ suggestions.append(suggestion)
49
+ _save_suggestions(suggestions)
50
+ return suggestion
51
+
52
+ def list_pending(self, project_path: str | None = None) -> list[dict]:
53
+ """Return pending suggestions, optionally filtered by project."""
54
+ suggestions = _load_suggestions()
55
+ pending = [s for s in suggestions if s.get("status") == "pending"]
56
+ if project_path:
57
+ pending = [s for s in pending if s.get("project_path") == project_path]
58
+ return pending
59
+
60
+ def approve_suggestion(self, suggestion_id: str) -> dict:
61
+ """Execute an approved suggestion and write to project memory."""
62
+ suggestions = _load_suggestions()
63
+ target = None
64
+ for s in suggestions:
65
+ if s["id"] == suggestion_id and s["status"] == "pending":
66
+ target = s
67
+ break
68
+ if not target:
69
+ return {"error": "Suggestion not found or already resolved"}
70
+
71
+ result = self._execute(target)
72
+ target["status"] = "approved"
73
+ target["resolved_at"] = datetime.now(timezone.utc).isoformat()
74
+ target["result"] = result
75
+ _save_suggestions(suggestions)
76
+ return {"approved": True, "id": suggestion_id, "result": result}
77
+
78
+ def dismiss_suggestion(self, suggestion_id: str) -> dict:
79
+ """Mark a suggestion as dismissed."""
80
+ suggestions = _load_suggestions()
81
+ for s in suggestions:
82
+ if s["id"] == suggestion_id and s["status"] == "pending":
83
+ s["status"] = "dismissed"
84
+ s["resolved_at"] = datetime.now(timezone.utc).isoformat()
85
+ _save_suggestions(suggestions)
86
+ return {"dismissed": True, "id": suggestion_id}
87
+ return {"error": "Suggestion not found or already resolved"}
88
+
89
+ def _execute(self, suggestion: dict) -> dict:
90
+ """Execute a suggestion write-back."""
91
+ stype = suggestion["type"]
92
+ project_path = suggestion["project_path"]
93
+ data = suggestion["data"]
94
+
95
+ if stype == "archive_facts":
96
+ return self._archive_facts(project_path, data.get("fact_ids", []))
97
+ elif stype == "merge_facts":
98
+ return self._merge_facts(project_path, data)
99
+ elif stype == "add_fact":
100
+ return self._add_fact(project_path, data)
101
+ return {"error": f"Unknown suggestion type: {stype}"}
102
+
103
+ def _load_project_facts(self, project_path: str) -> tuple[list[dict], Path]:
104
+ facts_file = Path(project_path) / ".c3" / "facts" / "facts.json"
105
+ if not facts_file.is_file():
106
+ return [], facts_file
107
+ try:
108
+ with open(facts_file, encoding="utf-8") as f:
109
+ return json.load(f), facts_file
110
+ except Exception:
111
+ return [], facts_file
112
+
113
+ def _save_project_facts(self, facts: list[dict], facts_file: Path):
114
+ facts_file.parent.mkdir(parents=True, exist_ok=True)
115
+ with open(facts_file, "w", encoding="utf-8") as f:
116
+ json.dump(facts, f, indent=2)
117
+
118
+ def _archive_facts(self, project_path: str, fact_ids: list[str]) -> dict:
119
+ """Set lifecycle=archived on specified facts."""
120
+ facts, fpath = self._load_project_facts(project_path)
121
+ archived = 0
122
+ for fact in facts:
123
+ if fact.get("id") in fact_ids:
124
+ fact["lifecycle"] = "archived"
125
+ archived += 1
126
+ self._save_project_facts(facts, fpath)
127
+ return {"archived": archived}
128
+
129
+ def _merge_facts(self, project_path: str, data: dict) -> dict:
130
+ """Merge duplicate facts: keep survivor, archive others."""
131
+ facts, fpath = self._load_project_facts(project_path)
132
+ survivor_id = data.get("survivor_id")
133
+ merge_ids = set(data.get("merge_ids", []))
134
+
135
+ survivor = None
136
+ for fact in facts:
137
+ if fact.get("id") == survivor_id:
138
+ survivor = fact
139
+ break
140
+ if not survivor:
141
+ return {"error": f"Survivor fact {survivor_id} not found"}
142
+
143
+ merged_count = 0
144
+ for fact in facts:
145
+ if fact.get("id") in merge_ids and fact.get("id") != survivor_id:
146
+ fact["lifecycle"] = "archived"
147
+ survivor["relevance_count"] = (
148
+ int(survivor.get("relevance_count", 0))
149
+ + int(fact.get("relevance_count", 0))
150
+ )
151
+ merged_count += 1
152
+
153
+ if data.get("merged_text"):
154
+ survivor["fact"] = data["merged_text"]
155
+
156
+ self._save_project_facts(facts, fpath)
157
+ return {"merged": merged_count, "survivor_id": survivor_id}
158
+
159
+ def _add_fact(self, project_path: str, data: dict) -> dict:
160
+ """Add a new fact (e.g., cross-project insight) to a project."""
161
+ facts, fpath = self._load_project_facts(project_path)
162
+ fact_id = uuid.uuid4().hex[:12]
163
+ now = datetime.now(timezone.utc).isoformat()
164
+ entry = {
165
+ "id": fact_id,
166
+ "fact": data.get("fact", ""),
167
+ "category": data.get("category", "oracle"),
168
+ "source_session": "oracle",
169
+ "timestamp": now,
170
+ "last_accessed_at": None,
171
+ "relevance_count": 0,
172
+ "confidence": float(data.get("confidence", 0.8)),
173
+ "source_quality": "oracle",
174
+ "lifecycle": "active",
175
+ "vector_id": fact_id,
176
+ "recall_sessions": [],
177
+ "confirmation_count": 0,
178
+ "contradiction_count": 0,
179
+ }
180
+ facts.append(entry)
181
+ self._save_project_facts(facts, fpath)
182
+ return {"added": True, "id": fact_id}