engramkit 0.1.1__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 (87) hide show
  1. engramkit/__init__.py +8 -0
  2. engramkit/__main__.py +4 -0
  3. engramkit/api/__init__.py +0 -0
  4. engramkit/api/helpers.py +85 -0
  5. engramkit/api/routes_chat.py +183 -0
  6. engramkit/api/routes_kg.py +116 -0
  7. engramkit/api/routes_memory.py +112 -0
  8. engramkit/api/routes_search.py +50 -0
  9. engramkit/api/routes_vaults.py +110 -0
  10. engramkit/api/server.py +106 -0
  11. engramkit/cli.py +363 -0
  12. engramkit/config.py +89 -0
  13. engramkit/dashboard_static/404/index.html +1 -0
  14. engramkit/dashboard_static/404.html +1 -0
  15. engramkit/dashboard_static/__next.$oc$slug.__PAGE__.txt +6 -0
  16. engramkit/dashboard_static/__next.$oc$slug.txt +5 -0
  17. engramkit/dashboard_static/__next._full.txt +20 -0
  18. engramkit/dashboard_static/__next._head.txt +6 -0
  19. engramkit/dashboard_static/__next._index.txt +6 -0
  20. engramkit/dashboard_static/__next._tree.txt +4 -0
  21. engramkit/dashboard_static/_next/static/UXdTcQ7rNV_suRXmc42PU/_buildManifest.js +11 -0
  22. engramkit/dashboard_static/_next/static/UXdTcQ7rNV_suRXmc42PU/_clientMiddlewareManifest.js +1 -0
  23. engramkit/dashboard_static/_next/static/UXdTcQ7rNV_suRXmc42PU/_ssgManifest.js +1 -0
  24. engramkit/dashboard_static/_next/static/chunks/01xlw8hd842-c.js +1 -0
  25. engramkit/dashboard_static/_next/static/chunks/03f8vq.h4g94f.js +31 -0
  26. engramkit/dashboard_static/_next/static/chunks/03~yq9q893hmn.js +1 -0
  27. engramkit/dashboard_static/_next/static/chunks/0e9pzjo74aj_r.js +1 -0
  28. engramkit/dashboard_static/_next/static/chunks/0h4a4y-~puu.x.js +1 -0
  29. engramkit/dashboard_static/_next/static/chunks/0idm9aj5df4bj.js +1 -0
  30. engramkit/dashboard_static/_next/static/chunks/0t2xr05rlu96l.js +1 -0
  31. engramkit/dashboard_static/_next/static/chunks/0wtesm2ne~5-y.js +5 -0
  32. engramkit/dashboard_static/_next/static/chunks/14fvgqz8fzs66.css +3 -0
  33. engramkit/dashboard_static/_next/static/chunks/turbopack-0i4hupr16-8uz.js +1 -0
  34. engramkit/dashboard_static/_next/static/media/4fa387ec64143e14-s.0q3udbd2bu5yp.woff2 +0 -0
  35. engramkit/dashboard_static/_next/static/media/7178b3e590c64307-s.11.cyxs5p-0z~.woff2 +0 -0
  36. engramkit/dashboard_static/_next/static/media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2 +0 -0
  37. engramkit/dashboard_static/_next/static/media/8a480f0b521d4e75-s.06d3mdzz5bre_.woff2 +0 -0
  38. engramkit/dashboard_static/_next/static/media/bbc41e54d2fcbd21-s.0gw~uztddq1df.woff2 +0 -0
  39. engramkit/dashboard_static/_next/static/media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2 +0 -0
  40. engramkit/dashboard_static/_next/static/media/favicon.0x3dzn~oxb6tn.ico +0 -0
  41. engramkit/dashboard_static/_not-found/__next._full.txt +17 -0
  42. engramkit/dashboard_static/_not-found/__next._head.txt +6 -0
  43. engramkit/dashboard_static/_not-found/__next._index.txt +6 -0
  44. engramkit/dashboard_static/_not-found/__next._not-found.__PAGE__.txt +5 -0
  45. engramkit/dashboard_static/_not-found/__next._not-found.txt +5 -0
  46. engramkit/dashboard_static/_not-found/__next._tree.txt +2 -0
  47. engramkit/dashboard_static/_not-found/index.html +1 -0
  48. engramkit/dashboard_static/_not-found/index.txt +17 -0
  49. engramkit/dashboard_static/favicon.ico +0 -0
  50. engramkit/dashboard_static/file.svg +1 -0
  51. engramkit/dashboard_static/globe.svg +1 -0
  52. engramkit/dashboard_static/index.html +1 -0
  53. engramkit/dashboard_static/index.txt +20 -0
  54. engramkit/dashboard_static/next.svg +1 -0
  55. engramkit/dashboard_static/vercel.svg +1 -0
  56. engramkit/dashboard_static/window.svg +1 -0
  57. engramkit/entities/__init__.py +0 -0
  58. engramkit/graph/__init__.py +0 -0
  59. engramkit/graph/knowledge_graph.py +182 -0
  60. engramkit/hooks/__init__.py +0 -0
  61. engramkit/hooks/claude_hook_handler.py +174 -0
  62. engramkit/hooks/git_hooks.py +147 -0
  63. engramkit/hooks/hook_manager.py +123 -0
  64. engramkit/ingest/__init__.py +0 -0
  65. engramkit/ingest/chunker.py +82 -0
  66. engramkit/ingest/git_differ.py +89 -0
  67. engramkit/ingest/pipeline.py +315 -0
  68. engramkit/ingest/secret_scanner.py +40 -0
  69. engramkit/mcp/__init__.py +0 -0
  70. engramkit/mcp/server.py +484 -0
  71. engramkit/memory/__init__.py +0 -0
  72. engramkit/memory/layers.py +171 -0
  73. engramkit/memory/token_budget.py +170 -0
  74. engramkit/search/__init__.py +0 -0
  75. engramkit/search/fts.py +78 -0
  76. engramkit/search/hybrid.py +126 -0
  77. engramkit/storage/__init__.py +0 -0
  78. engramkit/storage/chromadb_backend.py +72 -0
  79. engramkit/storage/gc.py +73 -0
  80. engramkit/storage/schema.py +111 -0
  81. engramkit/storage/vault.py +242 -0
  82. engramkit-0.1.1.dist-info/METADATA +504 -0
  83. engramkit-0.1.1.dist-info/RECORD +87 -0
  84. engramkit-0.1.1.dist-info/WHEEL +5 -0
  85. engramkit-0.1.1.dist-info/entry_points.txt +3 -0
  86. engramkit-0.1.1.dist-info/licenses/LICENSE +21 -0
  87. engramkit-0.1.1.dist-info/top_level.txt +1 -0
engramkit/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """EngramKit — AI memory system with hybrid search, git-aware ingestion, and garbage collection."""
2
+
3
+ import logging
4
+
5
+ # ChromaDB 0.6.x has a buggy Posthog telemetry client — silence it
6
+ logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)
7
+
8
+ __version__ = "0.1.0"
engramkit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Allow running as: python -m engramkit"""
2
+ from engramkit.cli import main
3
+
4
+ main()
File without changes
@@ -0,0 +1,85 @@
1
+ """Shared helpers and request models for API routes."""
2
+
3
+ from typing import Optional
4
+ from fastapi import HTTPException
5
+ from pydantic import BaseModel
6
+
7
+ from engramkit.config import ENGRAMKIT_HOME
8
+ from engramkit.storage.vault import Vault
9
+ from engramkit.graph.knowledge_graph import KnowledgeGraph
10
+
11
+
12
+ def get_vault_by_id(vault_id: str) -> Vault:
13
+ vault_path = ENGRAMKIT_HOME / "vaults" / vault_id
14
+ if not vault_path.exists():
15
+ raise HTTPException(404, f"Vault {vault_id} not found")
16
+ vault = Vault(vault_path)
17
+ vault.open()
18
+ return vault
19
+
20
+
21
+ def get_kg(vault: Vault) -> KnowledgeGraph:
22
+ return KnowledgeGraph(str(vault.vault_path / "knowledge_graph.sqlite3"))
23
+
24
+
25
+ # ── Request Models ────────────────────────────────────────────────────────
26
+
27
+ class CreateVaultRequest(BaseModel):
28
+ repo_path: str
29
+
30
+ class SearchRequest(BaseModel):
31
+ query: str
32
+ wing: Optional[str] = None
33
+ room: Optional[str] = None
34
+ n_results: int = 5
35
+
36
+ class MineRequest(BaseModel):
37
+ wing: Optional[str] = None
38
+ room: str = "general"
39
+ full: bool = False
40
+ dry_run: bool = False
41
+
42
+ class GCRequest(BaseModel):
43
+ retention_days: int = 30
44
+ dry_run: bool = False
45
+
46
+ class SaveRequest(BaseModel):
47
+ content: str
48
+ wing: Optional[str] = None
49
+ room: str = "general"
50
+ importance: float = 3.0
51
+
52
+ class DiaryRequest(BaseModel):
53
+ content: str
54
+ wing: str = "diary"
55
+
56
+ class AddTripleRequest(BaseModel):
57
+ subject: str
58
+ predicate: str
59
+ object: str
60
+ valid_from: Optional[str] = None
61
+ valid_to: Optional[str] = None
62
+ confidence: float = 1.0
63
+
64
+ class InvalidateRequest(BaseModel):
65
+ subject: str
66
+ predicate: str
67
+ object: str
68
+ ended: Optional[str] = None
69
+
70
+ class UpdateChunkRequest(BaseModel):
71
+ importance: Optional[float] = None
72
+
73
+ class ConfigUpdateRequest(BaseModel):
74
+ key: str
75
+ value: str
76
+
77
+ class ChatRequest(BaseModel):
78
+ message: str
79
+ mode: str = "rag" # "rag" = EngramKit search + Claude, "direct" = Claude only (no RAG)
80
+ vault_id: Optional[str] = None
81
+ vault_ids: Optional[list] = None
82
+ n_context: int = 10
83
+ model: str = "claude-sonnet-4-20250514"
84
+ history: list = []
85
+ pinned_chunks: list = []
@@ -0,0 +1,183 @@
1
+ """RAG chat endpoint — search vault + stream via Claude Agent SDK."""
2
+
3
+ import json
4
+ from fastapi import APIRouter, HTTPException
5
+ from fastapi.responses import StreamingResponse
6
+
7
+ from engramkit.search.hybrid import hybrid_search
8
+ from engramkit.api.helpers import get_vault_by_id, ChatRequest
9
+
10
+ router = APIRouter(prefix="/api", tags=["chat"])
11
+
12
+
13
+ def _format_history(history: list) -> str:
14
+ """Format conversation history for the prompt."""
15
+ if not history:
16
+ return ""
17
+ lines = ["## Conversation History:"]
18
+ for h in history[-10:]: # last 10 messages
19
+ role = h.get("role", "user")
20
+ content = h.get("content", "")[:500] # truncate long messages
21
+ lines.append(f"**{role}:** {content}")
22
+ lines.append("")
23
+ return "\n".join(lines) + "\n"
24
+
25
+
26
+ def _extract_usage(msg):
27
+ """Extract token counts from ResultMessage."""
28
+ input_tokens = output_tokens = cache_read = 0
29
+ msg_usage = getattr(msg, 'usage', None)
30
+ if isinstance(msg_usage, dict):
31
+ input_tokens = msg_usage.get('input_tokens', 0) or 0
32
+ output_tokens = msg_usage.get('output_tokens', 0) or 0
33
+ cache_read = msg_usage.get('cache_read_input_tokens', 0) or 0
34
+ if input_tokens == 0:
35
+ for md in (getattr(msg, 'model_usage', None) or {}).values():
36
+ if isinstance(md, dict):
37
+ input_tokens += md.get('inputTokens', 0) or 0
38
+ output_tokens += md.get('outputTokens', 0) or 0
39
+ cache_read += md.get('cacheReadInputTokens', 0) or 0
40
+ return input_tokens, output_tokens, cache_read
41
+
42
+
43
+ @router.post("/chat")
44
+ async def chat(req: ChatRequest):
45
+ # 1. Resolve vault IDs + repo paths
46
+ vault_ids = req.vault_ids or ([req.vault_id] if req.vault_id else [])
47
+ if not vault_ids:
48
+ raise HTTPException(400, "No vault selected")
49
+
50
+ repo_names = []
51
+ repo_paths = []
52
+ for vid in vault_ids:
53
+ vault = get_vault_by_id(vid)
54
+ try:
55
+ rp = vault.get_meta("repo_path", "unknown")
56
+ repo_names.append(rp.split("/")[-1] if rp else vid)
57
+ if rp: repo_paths.append(rp)
58
+ finally:
59
+ vault.close()
60
+
61
+ repo_name = ", ".join(repo_names)
62
+
63
+ # 2. Build prompt based on mode
64
+ results = []
65
+ if req.mode == "rag":
66
+ # Search across vaults
67
+ for vid in vault_ids:
68
+ vault = get_vault_by_id(vid)
69
+ try:
70
+ hits = hybrid_search(req.message, vault, n_results=req.n_context)
71
+ rp = vault.get_meta("repo_path", "unknown")
72
+ rn = rp.split("/")[-1] if rp else vid
73
+ for r in hits: r["_repo"] = rn
74
+ results.extend(hits)
75
+ finally:
76
+ vault.close()
77
+
78
+ results.sort(key=lambda x: x.get("score", 0), reverse=True)
79
+ results = results[:req.n_context]
80
+
81
+ context_chunks = [
82
+ f"[{r.get('_repo', '')}/{r['file_path']}] (score: {r['score']:.4f})\n{r['content']}"
83
+ for r in results
84
+ ]
85
+ context = "\n\n---\n\n".join(context_chunks) if context_chunks else "No relevant context."
86
+
87
+ pinned = ""
88
+ if req.pinned_chunks:
89
+ pinned = "\n\n---\n\n".join(f"[{pc.get('file','?')}] (pinned)\n{pc.get('content','')}" for pc in req.pinned_chunks)
90
+
91
+ full_context = f"## Pinned:\n{pinned}\n\n## Auto-Retrieved:\n{context}" if pinned else context
92
+
93
+ prompt = f"""You are a code assistant for the "{repo_name}" codebase.
94
+
95
+ RULES:
96
+ 1. Answer PRIMARILY from the pre-searched code chunks below
97
+ 2. Only use Read/Grep if chunks clearly don't contain the answer (max 2-3 tool calls)
98
+ 3. Do NOT explore the entire codebase — be focused
99
+ 4. Reference specific file paths
100
+ 5. Be concise
101
+
102
+ ## Pre-Searched Code Chunks:
103
+ {full_context}
104
+
105
+ {_format_history(req.history)}## Question:
106
+ {req.message}"""
107
+
108
+ else:
109
+ # Direct mode — no RAG, just Claude + tools
110
+ prompt = f"""You are a code assistant for the "{repo_name}" codebase.
111
+
112
+ Answer the question using your tools (Read, Grep, Glob). Be concise.
113
+
114
+ {_format_history(req.history)}## Question:
115
+ {req.message}"""
116
+
117
+ # 3. Stream response
118
+ async def generate():
119
+ try:
120
+ from claude_agent_sdk import (
121
+ query as sdk_query, ClaudeAgentOptions,
122
+ AssistantMessage, TextBlock, ToolUseBlock, ResultMessage, StreamEvent,
123
+ )
124
+ except ImportError:
125
+ yield f"data: {json.dumps({'type': 'text', 'text': 'Error: chat feature requires the [chat] extra. Install with: pip install engramkit[chat]'})}\n\n"
126
+ yield f"data: {json.dumps({'type': 'done'})}\n\n"
127
+ return
128
+
129
+ # Send mode indicator
130
+ yield f"data: {json.dumps({'type': 'mode', 'mode': req.mode})}\n\n"
131
+
132
+ # Send sources (only in RAG mode)
133
+ if results:
134
+ sources = [{
135
+ "file": r.get("file_path", "?"), "score": r.get("score", 0),
136
+ "content": r.get("content", ""), "wing": r.get("wing", ""),
137
+ "room": r.get("room", ""), "content_hash": r.get("content_hash", ""),
138
+ } for r in results]
139
+ yield f"data: {json.dumps({'type': 'sources', 'sources': sources})}\n\n"
140
+
141
+ got_content = False
142
+ tool_calls = 0
143
+
144
+ try:
145
+ async for msg in sdk_query(
146
+ prompt=prompt,
147
+ options=ClaudeAgentOptions(
148
+ max_turns=None,
149
+ permission_mode="auto",
150
+ model=req.model or None,
151
+ include_partial_messages=True,
152
+ cwd=repo_paths[0] if repo_paths else None,
153
+ ),
154
+ ):
155
+ if isinstance(msg, StreamEvent):
156
+ event = msg.event if hasattr(msg, 'event') else {}
157
+ if isinstance(event, dict) and event.get("type") == "content_block_delta":
158
+ delta = event.get("delta", {})
159
+ if delta.get("type") == "text_delta" and delta.get("text"):
160
+ got_content = True
161
+ yield f"data: {json.dumps({'type': 'text', 'text': delta['text']})}\n\n"
162
+
163
+ elif isinstance(msg, AssistantMessage):
164
+ for block in msg.content:
165
+ if isinstance(block, ToolUseBlock):
166
+ tool_calls += 1
167
+ yield f"data: {json.dumps({'type': 'tool_call', 'tool': block.name, 'count': tool_calls})}\n\n"
168
+ elif isinstance(block, TextBlock) and block.text and not got_content:
169
+ got_content = True
170
+ yield f"data: {json.dumps({'type': 'text', 'text': block.text})}\n\n"
171
+
172
+ elif isinstance(msg, ResultMessage):
173
+ if not got_content and msg.result:
174
+ yield f"data: {json.dumps({'type': 'text', 'text': msg.result})}\n\n"
175
+ input_tokens, output_tokens, cache_read = _extract_usage(msg)
176
+ yield f"data: {json.dumps({'type': 'usage', 'mode': req.mode, 'total_cost_usd': getattr(msg, 'total_cost_usd', 0) or 0, 'duration_ms': getattr(msg, 'duration_ms', 0) or 0, 'tool_calls': tool_calls, 'num_turns': getattr(msg, 'num_turns', 0) or 0, 'input_tokens': input_tokens, 'output_tokens': output_tokens, 'cache_read_tokens': cache_read})}\n\n"
177
+
178
+ except Exception as e:
179
+ yield f"data: {json.dumps({'type': 'text', 'text': f'Error: {str(e)}'})}\n\n"
180
+
181
+ yield f"data: {json.dumps({'type': 'done'})}\n\n"
182
+
183
+ return StreamingResponse(generate(), media_type="text/event-stream")
@@ -0,0 +1,116 @@
1
+ """Knowledge graph endpoints."""
2
+
3
+ from typing import Optional
4
+ from fastapi import APIRouter
5
+
6
+ from engramkit.api.helpers import get_vault_by_id, get_kg, AddTripleRequest, InvalidateRequest
7
+ from engramkit.storage.gc import run_gc as _run_gc
8
+ from engramkit.api.helpers import GCRequest
9
+
10
+ router = APIRouter(prefix="/api", tags=["knowledge-graph"])
11
+
12
+
13
+ @router.get("/vaults/{vault_id}/kg/stats")
14
+ def kg_stats(vault_id: str):
15
+ vault = get_vault_by_id(vault_id)
16
+ try:
17
+ kg = get_kg(vault); s = kg.stats(); kg.close(); return s
18
+ finally:
19
+ vault.close()
20
+
21
+
22
+ @router.get("/vaults/{vault_id}/kg/entities")
23
+ def kg_entities(vault_id: str):
24
+ vault = get_vault_by_id(vault_id)
25
+ try:
26
+ kg = get_kg(vault)
27
+ rows = kg.conn.execute("SELECT * FROM entities ORDER BY name").fetchall()
28
+ kg.close(); return [dict(r) for r in rows]
29
+ finally:
30
+ vault.close()
31
+
32
+
33
+ @router.get("/vaults/{vault_id}/kg/entity/{name}")
34
+ def kg_entity(vault_id: str, name: str, as_of: Optional[str] = None, direction: str = "both"):
35
+ vault = get_vault_by_id(vault_id)
36
+ try:
37
+ kg = get_kg(vault)
38
+ facts = kg.query_entity(name, as_of=as_of, direction=direction)
39
+ kg.close(); return {"entity": name, "facts": facts, "count": len(facts)}
40
+ finally:
41
+ vault.close()
42
+
43
+
44
+ @router.get("/vaults/{vault_id}/kg/timeline")
45
+ def kg_timeline(vault_id: str, entity: Optional[str] = None):
46
+ vault = get_vault_by_id(vault_id)
47
+ try:
48
+ kg = get_kg(vault); t = kg.timeline(entity); kg.close()
49
+ return {"timeline": t, "count": len(t)}
50
+ finally:
51
+ vault.close()
52
+
53
+
54
+ @router.post("/vaults/{vault_id}/kg/triples")
55
+ def kg_add(vault_id: str, req: AddTripleRequest):
56
+ vault = get_vault_by_id(vault_id)
57
+ try:
58
+ kg = get_kg(vault)
59
+ tid = kg.add_triple(req.subject, req.predicate, req.object,
60
+ valid_from=req.valid_from, valid_to=req.valid_to, confidence=req.confidence)
61
+ kg.close(); return {"added": True, "triple_id": tid}
62
+ finally:
63
+ vault.close()
64
+
65
+
66
+ @router.patch("/vaults/{vault_id}/kg/triples/invalidate")
67
+ def kg_invalidate(vault_id: str, req: InvalidateRequest):
68
+ vault = get_vault_by_id(vault_id)
69
+ try:
70
+ kg = get_kg(vault)
71
+ kg.invalidate(req.subject, req.predicate, req.object, ended=req.ended)
72
+ kg.close(); return {"invalidated": True}
73
+ finally:
74
+ vault.close()
75
+
76
+
77
+ @router.get("/vaults/{vault_id}/kg/graph")
78
+ def kg_graph(vault_id: str):
79
+ vault = get_vault_by_id(vault_id)
80
+ try:
81
+ kg = get_kg(vault)
82
+ entities = kg.conn.execute("SELECT id, name, type FROM entities").fetchall()
83
+ triples = kg.conn.execute(
84
+ """SELECT s.name as source, t.predicate, o.name as target, t.valid_to
85
+ FROM triples t JOIN entities s ON t.subject=s.id JOIN entities o ON t.object=o.id"""
86
+ ).fetchall()
87
+ kg.close()
88
+ return {
89
+ "nodes": [{"id": r["id"], "name": r["name"], "type": r["type"]} for r in entities],
90
+ "edges": [{"source": r["source"], "target": r["target"], "predicate": r["predicate"],
91
+ "current": r["valid_to"] is None} for r in triples],
92
+ }
93
+ finally:
94
+ vault.close()
95
+
96
+
97
+ # ── GC ────────────────────────────────────────────────────────────────────
98
+
99
+ @router.post("/vaults/{vault_id}/gc")
100
+ def gc_vault(vault_id: str, req: GCRequest):
101
+ vault = get_vault_by_id(vault_id)
102
+ try:
103
+ _run_gc(vault, dry_run=req.dry_run, retention_days=req.retention_days)
104
+ return {"completed": True, "dry_run": req.dry_run}
105
+ finally:
106
+ vault.close()
107
+
108
+
109
+ @router.get("/vaults/{vault_id}/gc/log")
110
+ def gc_log(vault_id: str, limit: int = 100):
111
+ vault = get_vault_by_id(vault_id)
112
+ try:
113
+ rows = vault.conn.execute("SELECT * FROM gc_log ORDER BY performed_at DESC LIMIT ?", (limit,)).fetchall()
114
+ return [dict(r) for r in rows]
115
+ finally:
116
+ vault.close()
@@ -0,0 +1,112 @@
1
+ """Memory, save, diary, config, and hooks endpoints."""
2
+
3
+ from typing import Optional
4
+ from fastapi import APIRouter, HTTPException
5
+
6
+ from engramkit.memory.layers import MemoryStack
7
+ from engramkit.memory.token_budget import TokenBudget, count_tokens
8
+ from engramkit.ingest.chunker import content_hash
9
+ from engramkit.api.helpers import get_vault_by_id, SaveRequest, DiaryRequest, ConfigUpdateRequest
10
+
11
+ router = APIRouter(prefix="/api", tags=["memory"])
12
+
13
+
14
+ @router.get("/vaults/{vault_id}/memory/wakeup")
15
+ def memory_wakeup(vault_id: str, wing: Optional[str] = None, l1_tokens: int = 1000):
16
+ vault = get_vault_by_id(vault_id)
17
+ try:
18
+ stack = MemoryStack(vault, TokenBudget(l1_max=l1_tokens))
19
+ result = stack.wake_up(wing=wing)
20
+ return {
21
+ "context": result["text"], "total_tokens": result["total_tokens"],
22
+ "l0": {"tokens": result["l0_report"].tokens_used, "budget": result["l0_report"].tokens_budget},
23
+ "l1": {"tokens": result["l1_report"].tokens_used, "budget": result["l1_report"].tokens_budget,
24
+ "loaded": result["l1_report"].chunks_loaded, "deduped": result["l1_report"].chunks_skipped_dedup},
25
+ }
26
+ finally:
27
+ vault.close()
28
+
29
+
30
+ @router.get("/vaults/{vault_id}/memory/recall")
31
+ def memory_recall(vault_id: str, wing: Optional[str] = None, room: Optional[str] = None, n_results: int = 10):
32
+ vault = get_vault_by_id(vault_id)
33
+ try:
34
+ result = MemoryStack(vault).recall(wing=wing, room=room, n_results=n_results)
35
+ return {"text": result["text"], "tokens": result["report"].tokens_used, "chunks_loaded": result["report"].chunks_loaded}
36
+ finally:
37
+ vault.close()
38
+
39
+
40
+ # ── Save & Diary ──────────────────────────────────────────────────────────
41
+
42
+ @router.post("/vaults/{vault_id}/save")
43
+ def save_content(vault_id: str, req: SaveRequest):
44
+ vault = get_vault_by_id(vault_id)
45
+ try:
46
+ chash = content_hash(req.content)
47
+ wing = req.wing or vault.get_meta("wing") or "default"
48
+ vault.batch_upsert_chunks([{
49
+ "content_hash": chash, "content": req.content, "file_path": "manual_save", "file_hash": chash,
50
+ "wing": wing, "room": req.room, "generation": vault.current_generation(),
51
+ "importance": req.importance, "is_secret": 0,
52
+ }])
53
+ return {"saved": True, "content_hash": chash, "tokens": count_tokens(req.content)}
54
+ finally:
55
+ vault.close()
56
+
57
+
58
+ @router.post("/vaults/{vault_id}/diary")
59
+ def write_diary(vault_id: str, req: DiaryRequest):
60
+ from datetime import datetime
61
+ vault = get_vault_by_id(vault_id)
62
+ try:
63
+ entry = f"[{datetime.now().isoformat()}] {req.content}"
64
+ chash = content_hash(entry)
65
+ vault.batch_upsert_chunks([{
66
+ "content_hash": chash, "content": entry, "file_path": "diary", "file_hash": chash,
67
+ "wing": f"agent_{req.wing}", "room": "diary", "generation": vault.current_generation(),
68
+ "importance": 2.0, "is_secret": 0,
69
+ }])
70
+ return {"saved": True, "content_hash": chash}
71
+ finally:
72
+ vault.close()
73
+
74
+
75
+ # ── Config & Hooks ────────────────────────────────────────────────────────
76
+
77
+ @router.get("/config")
78
+ def get_config():
79
+ from engramkit.config import DEFAULTS
80
+ return DEFAULTS
81
+
82
+
83
+ @router.get("/vaults/{vault_id}/config")
84
+ def get_vault_config(vault_id: str):
85
+ vault = get_vault_by_id(vault_id)
86
+ try:
87
+ rows = vault.conn.execute("SELECT key, value FROM vault_meta").fetchall()
88
+ return {r["key"]: r["value"] for r in rows}
89
+ finally:
90
+ vault.close()
91
+
92
+
93
+ @router.patch("/vaults/{vault_id}/config")
94
+ def update_vault_config(vault_id: str, req: ConfigUpdateRequest):
95
+ vault = get_vault_by_id(vault_id)
96
+ try:
97
+ vault.set_meta(req.key, req.value)
98
+ return {"set": True, "key": req.key, "value": req.value}
99
+ finally:
100
+ vault.close()
101
+
102
+
103
+ @router.post("/vaults/{vault_id}/hooks/install")
104
+ def install_hooks(vault_id: str):
105
+ from engramkit.hooks.git_hooks import install_hooks as _install
106
+ vault = get_vault_by_id(vault_id)
107
+ try:
108
+ repo_path = vault.get_meta("repo_path")
109
+ if not repo_path: raise HTTPException(400, "No repo_path")
110
+ _install(repo_path); return {"installed": True}
111
+ finally:
112
+ vault.close()
@@ -0,0 +1,50 @@
1
+ """Search and mining endpoints."""
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from engramkit.storage.vault import VaultManager
6
+ from engramkit.search.hybrid import hybrid_search
7
+ from engramkit.api.helpers import get_vault_by_id, SearchRequest, MineRequest
8
+
9
+ router = APIRouter(prefix="/api", tags=["search"])
10
+
11
+
12
+ @router.post("/search")
13
+ def global_search(req: SearchRequest):
14
+ all_results = []
15
+ for vault_info in VaultManager.list_vaults():
16
+ vault = get_vault_by_id(vault_info["vault_id"])
17
+ try:
18
+ results = hybrid_search(req.query, vault, req.n_results, req.wing, req.room)
19
+ for r in results:
20
+ r["vault_id"] = vault_info["vault_id"]
21
+ r["repo_path"] = vault_info["repo_path"]
22
+ all_results.extend(results)
23
+ finally:
24
+ vault.close()
25
+ all_results.sort(key=lambda x: x.get("score", 0), reverse=True)
26
+ return {"query": req.query, "results": all_results[:req.n_results], "count": len(all_results)}
27
+
28
+
29
+ @router.post("/vaults/{vault_id}/search")
30
+ def vault_search(vault_id: str, req: SearchRequest):
31
+ vault = get_vault_by_id(vault_id)
32
+ try:
33
+ results = hybrid_search(req.query, vault, req.n_results, req.wing, req.room)
34
+ return {"query": req.query, "results": results, "count": len(results)}
35
+ finally:
36
+ vault.close()
37
+
38
+
39
+ @router.post("/vaults/{vault_id}/mine")
40
+ def mine_vault(vault_id: str, req: MineRequest):
41
+ from engramkit.ingest.pipeline import mine
42
+ vault = get_vault_by_id(vault_id)
43
+ repo_path = vault.get_meta("repo_path")
44
+ if not repo_path:
45
+ vault.close()
46
+ raise HTTPException(400, "No repo_path stored for this vault")
47
+ try:
48
+ return mine(repo_path, vault, wing=req.wing, room=req.room, full=req.full, dry_run=req.dry_run)
49
+ finally:
50
+ vault.close()
@@ -0,0 +1,110 @@
1
+ """Vault CRUD + files + chunks endpoints."""
2
+
3
+ import shutil
4
+ from typing import Optional
5
+ from fastapi import APIRouter, HTTPException
6
+
7
+ from engramkit.config import ENGRAMKIT_HOME
8
+ from engramkit.storage.vault import VaultManager
9
+ from engramkit.api.helpers import get_vault_by_id, CreateVaultRequest, UpdateChunkRequest
10
+
11
+ router = APIRouter(prefix="/api", tags=["vaults"])
12
+
13
+
14
+ @router.get("/vaults")
15
+ def list_vaults():
16
+ return VaultManager.list_vaults()
17
+
18
+
19
+ @router.post("/vaults")
20
+ def create_vault(req: CreateVaultRequest):
21
+ vault = VaultManager.get_vault(req.repo_path)
22
+ vault_id = VaultManager.vault_id(req.repo_path)
23
+ stats = vault.stats()
24
+ vault.close()
25
+ return {"vault_id": vault_id, "repo_path": req.repo_path, **stats}
26
+
27
+
28
+ @router.get("/vaults/{vault_id}")
29
+ def get_vault(vault_id: str):
30
+ vault = get_vault_by_id(vault_id)
31
+ try:
32
+ stats = vault.stats()
33
+ meta = {
34
+ "repo_path": vault.get_meta("repo_path", "unknown"),
35
+ "wing": vault.get_meta("wing"),
36
+ "last_commit": vault.get_meta("last_commit"),
37
+ "last_branch": vault.get_meta("last_branch"),
38
+ }
39
+ return {"vault_id": vault_id, **meta, **stats}
40
+ finally:
41
+ vault.close()
42
+
43
+
44
+ @router.delete("/vaults/{vault_id}")
45
+ def delete_vault(vault_id: str):
46
+ vault_path = ENGRAMKIT_HOME / "vaults" / vault_id
47
+ if not vault_path.exists():
48
+ raise HTTPException(404, "Vault not found")
49
+ shutil.rmtree(vault_path)
50
+ return {"deleted": True}
51
+
52
+
53
+ # ── Files & Chunks ────────────────────────────────────────────────────────
54
+
55
+ @router.get("/vaults/{vault_id}/files")
56
+ def list_files(vault_id: str):
57
+ vault = get_vault_by_id(vault_id)
58
+ try:
59
+ rows = vault.conn.execute("SELECT * FROM files WHERE is_deleted = 0 ORDER BY file_path").fetchall()
60
+ return [dict(r) for r in rows]
61
+ finally:
62
+ vault.close()
63
+
64
+
65
+ @router.get("/vaults/{vault_id}/chunks")
66
+ def list_chunks(
67
+ vault_id: str, wing: Optional[str] = None, room: Optional[str] = None,
68
+ is_stale: Optional[bool] = None, is_secret: Optional[bool] = None,
69
+ page: int = 1, per_page: int = 50,
70
+ ):
71
+ vault = get_vault_by_id(vault_id)
72
+ try:
73
+ sql, params = "SELECT * FROM chunks WHERE 1=1", []
74
+ if wing: sql += " AND wing = ?"; params.append(wing)
75
+ if room: sql += " AND room = ?"; params.append(room)
76
+ if is_stale is not None: sql += " AND is_stale = ?"; params.append(1 if is_stale else 0)
77
+ if is_secret is not None: sql += " AND is_secret = ?"; params.append(1 if is_secret else 0)
78
+
79
+ total = vault.conn.execute(sql.replace("SELECT *", "SELECT COUNT(*) as c"), params).fetchone()["c"]
80
+ sql += " ORDER BY updated_at DESC LIMIT ? OFFSET ?"
81
+ params.extend([per_page, (page - 1) * per_page])
82
+ rows = vault.conn.execute(sql, params).fetchall()
83
+
84
+ return {"chunks": [dict(r) for r in rows], "total": total, "page": page, "per_page": per_page,
85
+ "pages": (total + per_page - 1) // per_page}
86
+ finally:
87
+ vault.close()
88
+
89
+
90
+ @router.get("/vaults/{vault_id}/chunks/{content_hash}")
91
+ def get_chunk(vault_id: str, content_hash: str):
92
+ vault = get_vault_by_id(vault_id)
93
+ try:
94
+ row = vault.conn.execute("SELECT * FROM chunks WHERE content_hash = ?", (content_hash,)).fetchone()
95
+ if not row: raise HTTPException(404, "Chunk not found")
96
+ return dict(row)
97
+ finally:
98
+ vault.close()
99
+
100
+
101
+ @router.patch("/vaults/{vault_id}/chunks/{content_hash}")
102
+ def update_chunk(vault_id: str, content_hash: str, req: UpdateChunkRequest):
103
+ vault = get_vault_by_id(vault_id)
104
+ try:
105
+ if req.importance is not None:
106
+ vault.conn.execute("UPDATE chunks SET importance = ? WHERE content_hash = ?", (req.importance, content_hash))
107
+ vault.conn.commit()
108
+ return {"updated": True}
109
+ finally:
110
+ vault.close()