Yash030 commited on
Commit
fbcb300
·
1 Parent(s): 2816e70

refactor: folder-based memory model replacing session-based storage

Browse files

- Replace session-based KV scopes with (folder_path, agent_id) pairs
- Add normalize_folder_path, folder_observe, folder_search, folder_timeline, folder_graph_build
- Update forget/health_check/export_data/rebuild_index for folder model
- Add migrate_sessions_to_folders (non-destructive migration)
- Rewrite REST layer: /folders, /folder/observations, /timeline, /graph, /migrate
- Trim MCP tools from 31 to 11; add memory_folders, memory_folder_observations, memory_timeline
- Rebuild viewer to 4 tabs: Folders, Memories, Graph, Timeline
- Add pytest test suite: test_normalize, test_folder_observe, test_forget, test_timeline, test_graph, test_migration
- Add pytest==8.3.5 and hypothesis==6.131.15 to requirements.txt

.kiro/specs/folder-based-memory/.config.kiro ADDED
@@ -0,0 +1 @@
 
 
1
+ {"specId": "44d10716-f6a0-45b2-9627-5d567a5ef29f", "workflowType": "design-first", "specType": "feature"}
.kiro/specs/folder-based-memory/design.md ADDED
@@ -0,0 +1,1026 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Design Document: Folder-Based Memory
2
+
3
+ ## Overview
4
+
5
+ This document describes the complete restructure of agentmemory-python from a **session-based** architecture to a **folder-based memory** model. The primary unit of storage shifts from `(session_id)` to `(folder_path, agent_id)` — each agent accumulates observations scoped to the folder it is working in, with no concept of "sessions". Long-term memories remain global. The viewer, MCP tools, and REST API are all reshaped around this model.
6
+
7
+ The redesign eliminates sessions, lessons, slots, actions, crystals, and artefacts entirely. What remains is: folder observations, global memories, BM25 + optional vector search, a folder-based graph, a folder activity feed (timeline), privacy stripping, and the MCP tool-calling interface.
8
+
9
+ ---
10
+
11
+ ## Architecture
12
+
13
+ ### High-Level System Diagram
14
+
15
+ ```mermaid
16
+ graph TD
17
+ subgraph Agents
18
+ A1[Kiro]
19
+ A2[Claude]
20
+ A3[Cursor]
21
+ end
22
+
23
+ subgraph MCP / REST API ["MCP / REST API (src/app.py)"]
24
+ T1[agent_observe]
25
+ T2[memory_recall]
26
+ T3[memory_save / agent_remember]
27
+ T4[memory_smart_search]
28
+ T5[memory_export]
29
+ T6[memory_forget]
30
+ T7[memory_diagnose]
31
+ end
32
+
33
+ subgraph Business Logic ["Business Logic (src/functions.py)"]
34
+ F1[folder_observe]
35
+ F2[folder_search]
36
+ F3[remember / forget]
37
+ F4[folder_timeline]
38
+ F5[folder_graph_build]
39
+ F6[health_check / export]
40
+ F7[strip_private_data]
41
+ F8[IndexPersistence]
42
+ end
43
+
44
+ subgraph Storage ["Storage (src/db.py — SQLite KV)"]
45
+ S1["mem:folders (index)"]
46
+ S2["mem:folder:{path}:{agent} (observations + metadata)"]
47
+ S3[mem:memories]
48
+ S4["mem:index:bm25:* (sharded BM25)"]
49
+ S5[audit_log]
50
+ end
51
+
52
+ subgraph Search ["Search (src/search.py)"]
53
+ SR1[SearchIndex BM25]
54
+ SR2[VectorIndex cosine]
55
+ SR3[HybridSearch RRF]
56
+ end
57
+
58
+ subgraph Viewer ["Viewer (src/viewer/index.html)"]
59
+ V1[Folders tab]
60
+ V2[Memories tab]
61
+ V3[Graph tab]
62
+ V4[Timeline tab]
63
+ end
64
+
65
+ A1 & A2 & A3 --> T1 & T2 & T3 & T4 & T5 & T6 & T7
66
+ T1 --> F1
67
+ T2 & T4 --> F2
68
+ T3 --> F3
69
+ T5 --> F6
70
+ T6 --> F3
71
+ T7 --> F6
72
+
73
+ F1 --> F7
74
+ F1 --> S2
75
+ F1 --> SR1 & SR2
76
+ F1 --> S1
77
+ F3 --> S3
78
+ F2 --> SR3
79
+ SR1 & SR2 --> SR3
80
+ F8 --> S4
81
+ SR1 & SR2 --> F8
82
+
83
+ F4 --> S2
84
+ F5 --> S1 & S2 & S3
85
+
86
+ S2 & S3 & S4 & S5 --> Storage
87
+
88
+ Viewer --> T2 & T4
89
+ F1 -.->|WebSocket broadcast| Viewer
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Sequence Diagrams
95
+
96
+ ### agent_observe — Folder Observation Pipeline
97
+
98
+ ```mermaid
99
+ sequenceDiagram
100
+ participant Agent
101
+ participant REST as REST /agent/observe
102
+ participant Fn as functions.folder_observe()
103
+ participant Privacy as strip_private_data()
104
+ participant KV as StateKV (SQLite)
105
+ participant BM25 as SearchIndex
106
+ participant Vec as VectorIndex
107
+ participant WS as WebSocket broadcast
108
+
109
+ Agent->>REST: POST /agentmemory/agent/observe\n{agentId, folderPath, text, type, title}
110
+ REST->>Fn: folder_observe(kv, payload)
111
+ Fn->>Privacy: strip_private_data(text)
112
+ Privacy-->>Fn: sanitized_text
113
+ Fn->>KV: set(KV.folder_obs(folder_path, agent_id), obs_id, obs)
114
+ Fn->>KV: upsert(KV.folder_meta(folder_path, agent_id), metadata)
115
+ Fn->>KV: ensure folder_path in KV.folders index
116
+ Fn->>BM25: add(obs)
117
+ Fn->>Vec: add(obs_id, embedding) [if provider set]
118
+ Fn->>KV: audit_log entry
119
+ Fn->>WS: broadcast compressed_observation
120
+ Fn-->>REST: {observationId}
121
+ REST-->>Agent: 201 {observationId}
122
+ ```
123
+
124
+ ### memory_recall — Hybrid Search
125
+
126
+ ```mermaid
127
+ sequenceDiagram
128
+ participant Agent
129
+ participant REST as REST /search
130
+ participant Fn as functions.folder_search()
131
+ participant BM25 as SearchIndex
132
+ participant Vec as VectorIndex
133
+ participant RRF as HybridSearch
134
+ participant KV as StateKV
135
+
136
+ Agent->>REST: POST /agentmemory/search\n{query, limit, agentId?, folderPath?}
137
+ REST->>Fn: folder_search(kv, query, opts)
138
+ Fn->>BM25: search(query, limit*2)
139
+ BM25-->>Fn: [{obsId, score}]
140
+ Fn->>Vec: search(embedding, limit*2) [if available]
141
+ Vec-->>Fn: [{obsId, score}]
142
+ Fn->>RRF: fuse(bm25_results, vector_results)
143
+ RRF-->>Fn: [{obsId, combinedScore}]
144
+ Fn->>KV: hydrate each obsId → full observation
145
+ Fn-->>REST: [{observation, score, folderPath, agentId}]
146
+ REST-->>Agent: 200 [{...}]
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Components and Interfaces
152
+
153
+ ### Component 1: KV Scope Registry (src/functions.py — `KV` class)
154
+
155
+ **Purpose**: Single source of truth for all KV scope names.
156
+
157
+ **New scopes (replaces session-based scopes):**
158
+
159
+ ```python
160
+ class KV:
161
+ # Folder memory index — stores list of all known folder_paths
162
+ folders = "mem:folders"
163
+
164
+ # Per-(folder, agent) observations list
165
+ # key = obs_id, value = observation dict
166
+ @staticmethod
167
+ def folder_obs(folder_path: str, agent_id: str) -> str:
168
+ safe_path = folder_path.replace("\\", "/").strip("/")
169
+ safe_agent = agent_id.strip()
170
+ return f"mem:folder:{safe_path}:{safe_agent}"
171
+
172
+ # Per-(folder, agent) metadata (last_updated, summary, obs_count)
173
+ @staticmethod
174
+ def folder_meta(folder_path: str, agent_id: str) -> str:
175
+ safe_path = folder_path.replace("\\", "/").strip("/")
176
+ safe_agent = agent_id.strip()
177
+ return f"mem:foldermeta:{safe_path}:{safe_agent}"
178
+
179
+ # Global long-term memories (unchanged)
180
+ memories = "mem:memories"
181
+
182
+ # BM25 index shards (unchanged)
183
+ bm25Index = "mem:index:bm25"
184
+
185
+ # Audit scope (unchanged)
186
+ audit = "mem:audit"
187
+ ```
188
+
189
+ **Scopes removed**: `sessions`, `obs:{session_id}`, `lessons`, `slots`, `slots:global`, `slots:{project}`, `actions`, `action-edges`, `crystals`, `sketches`, `facets`, `sentinels`, `signals`, `checkpoints`, `routines`, `routine-runs`, `profiles`, `summaries`, `semantic`, `procedural`, `commits`, `leases`, `mesh`, `recent-searches`, `claude-bridge`, `insights`, `retention`, `access`, `image-refs`
190
+
191
+ **Scopes kept**: `memories`, `index:bm25:*`, `audit`, `relations` (repurposed for folder graph edges)
192
+
193
+ ---
194
+
195
+ ### Component 2: FolderMemory — Core Data Model
196
+
197
+ **Purpose**: Represents a single `(folder_path, agent_id)` memory slice.
198
+
199
+ ```python
200
+ # Observation stored under KV.folder_obs(folder_path, agent_id)
201
+ FolderObservation = {
202
+ "id": str, # generate_id("fobs")
203
+ "folderPath": str, # normalized absolute path, forward-slashes
204
+ "agentId": str, # e.g. "kiro", "claude", "cursor"
205
+ "timestamp": str, # ISO 8601 UTC
206
+ "text": str, # sanitized observation text (private data stripped)
207
+ "type": str, # "file_edit" | "command_run" | "search" | "conversation" | "other" ...
208
+ "title": str, # short summary ≤ 80 chars
209
+ "concepts": list[str],# extracted concept tags
210
+ "files": list[str], # file paths referenced in this observation
211
+ "importance": int, # 1-10, default 5
212
+ }
213
+
214
+ # Metadata stored under KV.folder_meta(folder_path, agent_id)
215
+ FolderMeta = {
216
+ "folderPath": str,
217
+ "agentId": str,
218
+ "lastUpdated": str, # ISO timestamp of most recent observation
219
+ "obsCount": int,
220
+ "summary": str | None, # optional LLM-generated or auto-compressed summary
221
+ }
222
+
223
+ # Entry in the global folders index (KV.folders, key = "{folder_path}:{agent_id}")
224
+ FolderIndexEntry = {
225
+ "folderPath": str,
226
+ "agentId": str,
227
+ "lastUpdated": str,
228
+ "obsCount": int,
229
+ }
230
+ ```
231
+
232
+ ---
233
+
234
+ ### Component 3: `folder_observe()` — Observation Ingestion
235
+
236
+ **Purpose**: Replace `observe()`. Accept a folder path + agent ID instead of session ID.
237
+
238
+ **Interface**:
239
+
240
+ ```python
241
+ def folder_observe(kv: StateKV, payload: dict) -> dict:
242
+ """
243
+ Required payload fields:
244
+ folderPath: str — absolute path of working directory
245
+ agentId: str — identity of the agent making the observation
246
+ text: str — human-readable observation content
247
+ timestamp: str — ISO 8601 UTC
248
+
249
+ Optional payload fields:
250
+ type: str — observation type (default "other")
251
+ title: str — short title (auto-generated from text if absent)
252
+ concepts: list[str] — tags
253
+ files: list[str] — referenced file paths
254
+ importance: int — 1-10 (default 5)
255
+
256
+ Returns: {"observationId": str}
257
+ Raises: ValueError if required fields are missing or folder cap exceeded.
258
+ """
259
+ ```
260
+
261
+ **Responsibilities**:
262
+ - Validate `folderPath`, `agentId`, `text`, `timestamp`
263
+ - Call `strip_private_data(text)` before any storage
264
+ - Normalize `folderPath`: `os.path.normpath`, convert separators to `/`
265
+ - Generate `obs_id = generate_id("fobs")`
266
+ - Build observation dict, cap text at 4000 chars (same spirit as current `MAX_OBS_PER_SESSION`)
267
+ - Write to `KV.folder_obs(folder_path, agent_id)` under key `obs_id`
268
+ - Upsert `KV.folder_meta(folder_path, agent_id)`: increment `obsCount`, update `lastUpdated`
269
+ - Upsert `KV.folders` index entry (key = `"{folder_path}:{agent_id}"`)
270
+ - Add to BM25 index (`_bm25_index.add(obs)`)
271
+ - Add to vector index if provider available
272
+ - Schedule index persistence save
273
+ - Write audit log entry
274
+ - Broadcast to WebSocket viewers
275
+
276
+ ---
277
+
278
+ ### Component 4: `folder_search()` — Hybrid Search
279
+
280
+ **Purpose**: Search across all folder observations (and memories) using the existing BM25+vector pipeline.
281
+
282
+ ```python
283
+ def folder_search(
284
+ kv: StateKV,
285
+ query: str,
286
+ limit: int = 20,
287
+ folder_path: Optional[str] = None,
288
+ agent_id: Optional[str] = None,
289
+ ) -> list[dict]:
290
+ """
291
+ Returns hydrated observations sorted by relevance.
292
+ Optionally filtered to a specific folder or agent.
293
+ Combines BM25 + vector via RRF (existing HybridSearch).
294
+ Also includes matching global memories in results.
295
+ """
296
+ ```
297
+
298
+ ---
299
+
300
+ ### Component 5: `folder_timeline()` — Activity Feed
301
+
302
+ **Purpose**: Replace session-based timeline with a folder activity feed.
303
+
304
+ ```python
305
+ def folder_timeline(
306
+ kv: StateKV,
307
+ limit: int = 100,
308
+ folder_path: Optional[str] = None,
309
+ agent_id: Optional[str] = None,
310
+ before: Optional[str] = None, # ISO timestamp upper bound
311
+ after: Optional[str] = None, # ISO timestamp lower bound
312
+ ) -> list[dict]:
313
+ """
314
+ Collects observations across all (folder, agent) pairs from KV.folders index.
315
+ Filters applied if folder_path or agent_id are provided.
316
+ Sorts by timestamp descending.
317
+ Returns at most `limit` entries.
318
+ """
319
+ ```
320
+
321
+ ---
322
+
323
+ ### Component 6: `folder_graph_build()` — Graph Data
324
+
325
+ **Purpose**: Build the graph payload for the viewer's Graph tab.
326
+
327
+ ```python
328
+ def folder_graph_build(kv: StateKV) -> dict:
329
+ """
330
+ Nodes: one per unique folder_path that has at least one (folder, agent) entry.
331
+ Node fields: {id, label, folderPath, agentIds, obsCount, color (from folderColor())}
332
+
333
+ Edges:
334
+ - Same-parent edge: two folders sharing the same parent directory.
335
+ - Cross-reference edge: folder A's observations mention folder B's path.
336
+ - Agent-shared edge: two folders have the same agentId with observations.
337
+
338
+ Returns: {"nodes": [...], "edges": [...]}
339
+ """
340
+ ```
341
+
342
+ ---
343
+
344
+ ### Component 7: `remember()` / `forget()` — Global Memories (unchanged interface)
345
+
346
+ These functions remain as-is. `remember()` performs Jaccard deduplication at > 0.7 threshold and versions memories (`isLatest`, `parentId`). `forget()` now accepts either `memoryId` for global memories, or `folderPath` + `agentId` (+ optional `observationIds`) to delete folder observations.
347
+
348
+ **Updated `forget()` signature**:
349
+
350
+ ```python
351
+ def forget(kv: StateKV, data: dict) -> dict:
352
+ """
353
+ Accepts:
354
+ memoryId: str — delete a global memory
355
+ folderPath + agentId: str — delete all observations for that (folder, agent)
356
+ folderPath + agentId + observationIds: list[str] — delete specific obs
357
+ """
358
+ ```
359
+
360
+ ---
361
+
362
+ ### Component 8: `health_check()` / `export_data()` — Diagnostics
363
+
364
+ **`health_check()`** updated to report folder counts instead of session counts:
365
+
366
+ ```python
367
+ {
368
+ "status": "ok",
369
+ "folderCount": int, # distinct folder paths with memory
370
+ "agentCount": int, # distinct agent IDs
371
+ "pairCount": int, # (folder, agent) pairs
372
+ "observationCount": int, # total observations across all pairs
373
+ "memoryCount": int, # global memories
374
+ "bm25IndexSize": int,
375
+ "vectorIndexSize": int,
376
+ "dbPath": str,
377
+ }
378
+ ```
379
+
380
+ **`export_data()`** updated structure:
381
+
382
+ ```python
383
+ {
384
+ "folders": [
385
+ {
386
+ "folderPath": str,
387
+ "agentId": str,
388
+ "meta": FolderMeta,
389
+ "observations": [FolderObservation, ...]
390
+ }
391
+ ],
392
+ "memories": [Memory, ...],
393
+ "exportedAt": str,
394
+ "version": "2.0",
395
+ }
396
+ ```
397
+
398
+ ---
399
+
400
+ ## Data Models
401
+
402
+ ### FolderObservation
403
+
404
+ ```python
405
+ {
406
+ "id": str, # "fobs_<ts>_<rand12>"
407
+ "folderPath": str, # "/Users/alice/projects/my-app/src" (normalized, forward slashes)
408
+ "agentId": str, # "kiro" | "claude" | "cursor" | any string
409
+ "timestamp": str, # "2025-01-15T10:30:00.000Z"
410
+ "text": str, # sanitized text, max 4000 chars
411
+ "type": str, # same infer_type() values as current: "file_edit", "command_run", etc.
412
+ "title": str, # ≤ 80 chars, auto-generated from text if absent
413
+ "concepts": list, # ["auth", "middleware"]
414
+ "files": list, # ["src/app.py", "src/db.py"]
415
+ "importance": int, # 1–10
416
+ }
417
+ ```
418
+
419
+ **Validation rules**:
420
+ - `folderPath` must be non-empty after normalization
421
+ - `agentId` must be non-empty after stripping
422
+ - `text` must be non-empty before stripping; after `strip_private_data()` may contain `[REDACTED]`
423
+ - `timestamp` must be a parseable ISO 8601 string
424
+ - `importance` clamped to 1–10 range
425
+
426
+ ### FolderMeta
427
+
428
+ ```python
429
+ {
430
+ "folderPath": str,
431
+ "agentId": str,
432
+ "lastUpdated": str, # ISO timestamp
433
+ "obsCount": int, # incremented on each observation
434
+ "summary": str | None,
435
+ }
436
+ ```
437
+
438
+ ### FolderIndexEntry (stored in `mem:folders`)
439
+
440
+ ```python
441
+ # key = "{safe_folder_path}:{agent_id}"
442
+ {
443
+ "folderPath": str,
444
+ "agentId": str,
445
+ "lastUpdated": str,
446
+ "obsCount": int,
447
+ }
448
+ ```
449
+
450
+ ### Memory (unchanged)
451
+
452
+ ```python
453
+ {
454
+ "id": str,
455
+ "createdAt": str,
456
+ "updatedAt": str,
457
+ "type": str, # "fact" | "preference" | "architecture" | "bug" | "workflow" | "pattern"
458
+ "title": str,
459
+ "content": str,
460
+ "concepts": list,
461
+ "files": list,
462
+ "strength": int, # 1–10
463
+ "version": int,
464
+ "parentId": str | None,
465
+ "supersedes": list,
466
+ "isLatest": bool,
467
+ "agentId": str | None,
468
+ "project": str | None, # optional — retained for backward compat during migration
469
+ }
470
+ ```
471
+
472
+ ---
473
+
474
+ ## Algorithmic Pseudocode
475
+
476
+ ### Main Observation Ingestion Algorithm
477
+
478
+ ```pascal
479
+ ALGORITHM folder_observe(kv, payload)
480
+ INPUT: payload dict with folderPath, agentId, text, timestamp, [type, title, concepts, files, importance]
481
+ OUTPUT: {"observationId": obs_id}
482
+
483
+ BEGIN
484
+ ASSERT payload.folderPath IS NOT EMPTY
485
+ ASSERT payload.agentId IS NOT EMPTY
486
+ ASSERT payload.text IS NOT EMPTY
487
+ ASSERT payload.timestamp IS valid ISO 8601
488
+
489
+ // 1. Normalize and sanitize
490
+ folder_path ← normalize_path(payload.folderPath) // os.path.normpath, forward-slashes
491
+ agent_id ← payload.agentId.strip()
492
+ safe_text ← strip_private_data(payload.text)
493
+ safe_text ← safe_text[:4000] // hard cap
494
+
495
+ // 2. Build observation
496
+ obs_id ← generate_id("fobs")
497
+ obs ← {
498
+ id: obs_id,
499
+ folderPath: folder_path,
500
+ agentId: agent_id,
501
+ timestamp: payload.timestamp,
502
+ text: safe_text,
503
+ type: payload.type OR infer_type(safe_text),
504
+ title: payload.title OR safe_text[:80],
505
+ concepts: payload.concepts OR [],
506
+ files: extract_files(payload) OR [],
507
+ importance: CLAMP(payload.importance OR 5, 1, 10),
508
+ }
509
+
510
+ // 3. Persist observation
511
+ obs_scope ← KV.folder_obs(folder_path, agent_id)
512
+ kv.set(obs_scope, obs_id, obs)
513
+
514
+ // 4. Upsert folder metadata
515
+ meta_scope ← KV.folder_meta(folder_path, agent_id)
516
+ meta ← kv.get(meta_scope, "meta") OR {folderPath: folder_path, agentId: agent_id, obsCount: 0}
517
+ meta.obsCount ← meta.obsCount + 1
518
+ meta.lastUpdated ← payload.timestamp
519
+ kv.set(meta_scope, "meta", meta)
520
+
521
+ // 5. Upsert global folders index
522
+ index_key ← f"{folder_path}:{agent_id}"
523
+ kv.set(KV.folders, index_key, {folderPath: folder_path, agentId: agent_id,
524
+ lastUpdated: meta.lastUpdated, obsCount: meta.obsCount})
525
+
526
+ // 6. Index for search
527
+ _bm25_index.add(obs)
528
+ IF embedding_provider IS NOT NULL THEN
529
+ vector_index_add_guarded(obs_id, folder_path, obs.title + " " + obs.text, {})
530
+ END IF
531
+
532
+ IF _index_persistence IS NOT NULL THEN
533
+ _index_persistence.schedule_save()
534
+ END IF
535
+
536
+ // 7. Audit + broadcast
537
+ kv.commit_version(f"folder_observe: {obs_id}", agent_id)
538
+ broadcast_stream({"type": "folder_observation", "folderPath": folder_path, "agentId": agent_id, "data": obs})
539
+
540
+ RETURN {"observationId": obs_id}
541
+ END
542
+ ```
543
+
544
+ **Preconditions:**
545
+ - `payload` contains non-empty `folderPath`, `agentId`, `text`, `timestamp`
546
+ - `kv` is an initialized `StateKV` instance
547
+
548
+ **Postconditions:**
549
+ - Observation is written to `KV.folder_obs(folder_path, agent_id)` under `obs_id`
550
+ - `KV.folder_meta(folder_path, agent_id)` has `obsCount` incremented and `lastUpdated` set
551
+ - `KV.folders` index has an entry for `(folder_path, agent_id)`
552
+ - BM25 index contains `obs_id`
553
+ - Audit log contains one new entry
554
+
555
+ **Loop invariants:** N/A (no loops in the main path)
556
+
557
+ ---
558
+
559
+ ### Folder Timeline Algorithm
560
+
561
+ ```pascal
562
+ ALGORITHM folder_timeline(kv, limit, folder_path?, agent_id?, before?, after?)
563
+ INPUT: optional filters; limit defaults to 100
564
+ OUTPUT: list of FolderObservation sorted by timestamp DESC
565
+
566
+ BEGIN
567
+ index_entries ← kv.list(KV.folders)
568
+
569
+ // Apply folder/agent filters to index
570
+ IF folder_path IS NOT NULL THEN
571
+ index_entries ← FILTER index_entries WHERE entry.folderPath = folder_path
572
+ END IF
573
+ IF agent_id IS NOT NULL THEN
574
+ index_entries ← FILTER index_entries WHERE entry.agentId = agent_id
575
+ END IF
576
+
577
+ all_obs ← []
578
+
579
+ FOR each entry IN index_entries DO
580
+ // Loop invariant: all_obs contains only observations that match filters applied so far
581
+ obs_scope ← KV.folder_obs(entry.folderPath, entry.agentId)
582
+ obs_list ← kv.list(obs_scope)
583
+
584
+ // Apply timestamp filters
585
+ IF before IS NOT NULL THEN
586
+ obs_list ← FILTER obs_list WHERE obs.timestamp < before
587
+ END IF
588
+ IF after IS NOT NULL THEN
589
+ obs_list ← FILTER obs_list WHERE obs.timestamp > after
590
+ END IF
591
+
592
+ all_obs.extend(obs_list)
593
+ END FOR
594
+
595
+ all_obs.sort(key=lambda o: o.timestamp, reverse=True)
596
+ RETURN all_obs[:limit]
597
+ END
598
+ ```
599
+
600
+ **Preconditions:**
601
+ - `kv` is initialized
602
+ - `limit` ≥ 1
603
+
604
+ **Postconditions:**
605
+ - Returns at most `limit` observations
606
+ - All returned observations satisfy the provided filter conditions
607
+ - Results are ordered by `timestamp` descending
608
+
609
+ **Loop invariant:** After each iteration, `all_obs` contains only observations from folders/agents matching the filter; all observations are within the timestamp range if filters were given.
610
+
611
+ ---
612
+
613
+ ### Folder Graph Build Algorithm
614
+
615
+ ```pascal
616
+ ALGORITHM folder_graph_build(kv)
617
+ INPUT: none (reads from KV)
618
+ OUTPUT: {nodes: list[GraphNode], edges: list[GraphEdge]}
619
+
620
+ BEGIN
621
+ index_entries ← kv.list(KV.folders)
622
+
623
+ // Build node map keyed by folder_path
624
+ folder_map ← {} // folder_path → {folderPath, agentIds: set, obsCount, color}
625
+ pair_obs_texts ← {} // (folder_path, agent_id) → concatenated obs text
626
+
627
+ FOR each entry IN index_entries DO
628
+ fp ← entry.folderPath
629
+ IF fp NOT IN folder_map THEN
630
+ folder_map[fp] ← {folderPath: fp, agentIds: set(), obsCount: 0, color: folderColor(fp)}
631
+ END IF
632
+ folder_map[fp].agentIds.add(entry.agentId)
633
+ folder_map[fp].obsCount ← folder_map[fp].obsCount + entry.obsCount
634
+
635
+ // Collect obs text for cross-reference edge detection
636
+ obs_list ← kv.list(KV.folder_obs(fp, entry.agentId))
637
+ combined ← JOIN [o.text + " " + o.title FOR o IN obs_list]
638
+ pair_obs_texts[(fp, entry.agentId)] ← combined
639
+ END FOR
640
+
641
+ nodes ← [build_node(fp, info) FOR fp, info IN folder_map.items()]
642
+
643
+ edges ← []
644
+ folder_paths ← list(folder_map.keys())
645
+
646
+ // Edge type 1: same-parent edges
647
+ FOR i IN range(len(folder_paths)) DO
648
+ FOR j IN range(i+1, len(folder_paths)) DO
649
+ // Loop invariant: all pairs (a, b) with a < j and b ≤ j have been evaluated
650
+ a ← folder_paths[i]
651
+ b ← folder_paths[j]
652
+ IF parent(a) = parent(b) THEN
653
+ edges.append({source: a, target: b, type: "same-parent"})
654
+ END IF
655
+ END FOR
656
+ END FOR
657
+
658
+ // Edge type 2: cross-reference edges (folder A's text mentions folder B's path)
659
+ FOR (fp_a, agent_a), text_a IN pair_obs_texts.items() DO
660
+ FOR fp_b IN folder_paths DO
661
+ IF fp_b ≠ fp_a AND fp_b IN text_a THEN
662
+ IF NOT edge_exists(edges, fp_a, fp_b, "cross-ref") THEN
663
+ edges.append({source: fp_a, target: fp_b, type: "cross-ref"})
664
+ END IF
665
+ END IF
666
+ END FOR
667
+ END FOR
668
+
669
+ // Edge type 3: agent-shared edges (two folders with same agent)
670
+ agent_to_folders ← group folder_paths by agentIds
671
+ FOR agent_id, fps IN agent_to_folders.items() DO
672
+ FOR i IN range(len(fps)) DO
673
+ FOR j IN range(i+1, len(fps)) DO
674
+ edges.append({source: fps[i], target: fps[j], type: "agent-shared", agentId: agent_id})
675
+ END FOR
676
+ END FOR
677
+ END FOR
678
+
679
+ RETURN {nodes: nodes, edges: DEDUPLICATE(edges)}
680
+ END
681
+ ```
682
+
683
+ **Preconditions:** `kv` is initialized; `KV.folders` may be empty (returns `{nodes: [], edges: []}`)
684
+
685
+ **Postconditions:**
686
+ - One node per unique `folder_path` in `KV.folders`
687
+ - Edges only connect distinct nodes
688
+ - No duplicate edges (same source, target, type)
689
+
690
+ ---
691
+
692
+ ## Key Functions with Formal Specifications
693
+
694
+ ### `normalize_folder_path(path: str) -> str`
695
+
696
+ ```python
697
+ def normalize_folder_path(path: str) -> str
698
+ ```
699
+
700
+ **Preconditions:** `path` is a non-empty string
701
+ **Postconditions:**
702
+ - Returns path with OS separators replaced by `/`
703
+ - No leading or trailing slashes
704
+ - `os.path.normpath` applied (resolves `..`, double slashes)
705
+ - Result is non-empty
706
+
707
+ ### `folder_observe(kv, payload) -> dict`
708
+
709
+ **Preconditions:** `kv` initialized; `payload` has `folderPath`, `agentId`, `text`, `timestamp`
710
+ **Postconditions:**
711
+ - `result["observationId"]` is a string matching pattern `fobs_*`
712
+ - `kv.get(KV.folder_obs(fp, aid), result["observationId"])` returns the stored obs
713
+ - `kv.get(KV.folder_meta(fp, aid), "meta")["obsCount"]` is incremented by 1
714
+ - `kv.get(KV.folders, f"{fp}:{aid}")` is non-None
715
+
716
+ ### `folder_timeline(kv, limit, ...) -> list`
717
+
718
+ **Preconditions:** `kv` initialized; `limit ≥ 1`
719
+ **Postconditions:**
720
+ - `len(result) ≤ limit`
721
+ - `all(result[i]["timestamp"] >= result[i+1]["timestamp"] for i in range(len(result)-1))`
722
+ - If `folder_path` filter set: all `result[i]["folderPath"] == folder_path`
723
+ - If `agent_id` filter set: all `result[i]["agentId"] == agent_id`
724
+
725
+ ### `forget(kv, data) -> dict`
726
+
727
+ **Preconditions:** At least one of `memoryId`, `folderPath+agentId` is present
728
+ **Postconditions:**
729
+ - If `memoryId` given: `kv.get(KV.memories, memoryId)` returns None
730
+ - If `folderPath+agentId` given (no obs IDs): all observations in `KV.folder_obs(fp, aid)` are deleted; `KV.folders` index entry is removed; `KV.folder_meta(fp, aid)` is deleted
731
+ - `result["deleted"]` equals number of items actually removed from storage
732
+
733
+ ---
734
+
735
+ ## Migration Strategy from Current Schema
736
+
737
+ ### Migration Approach: Non-Destructive Lazy Read
738
+
739
+ The migration does not require a destructive DB transformation at startup. Instead:
740
+
741
+ 1. **New data** is written using the folder-based schema immediately after deployment.
742
+ 2. **Old session data** is left in the DB under its original scopes (`mem:sessions`, `mem:obs:*`, etc.) and can be optionally migrated via a `POST /agentmemory/migrate` endpoint.
743
+ 3. The search index rebuild (`rebuild_index()`) is updated to read from both old observation scopes and new folder observation scopes — this ensures the BM25 index is populated regardless of which format data exists in.
744
+
745
+ ### Migration Endpoint
746
+
747
+ ```pascal
748
+ ALGORITHM migrate_sessions_to_folders(kv, dry_run=False)
749
+ INPUT: kv, dry_run flag
750
+ OUTPUT: {migrated_sessions, migrated_observations, errors}
751
+
752
+ BEGIN
753
+ sessions ← kv.list("mem:sessions")
754
+ migrated_sessions ← 0
755
+ migrated_observations ← 0
756
+ errors ← []
757
+
758
+ FOR each session IN sessions DO
759
+ folder_path ← session.cwd OR session.project OR "unknown"
760
+ agent_id ← session.agentId OR "unknown"
761
+ obs_list ← kv.list(KV.observations(session.id)) // "mem:obs:{session_id}"
762
+
763
+ FOR each obs IN obs_list DO
764
+ IF obs.id ends with ":raw" THEN CONTINUE END IF // skip raw duplicates
765
+
766
+ folder_obs ← {
767
+ id: obs.id,
768
+ folderPath: normalize_folder_path(folder_path),
769
+ agentId: agent_id,
770
+ timestamp: obs.timestamp,
771
+ text: obs.narrative OR obs.title OR "",
772
+ type: obs.type OR "other",
773
+ title: obs.title OR "",
774
+ concepts: obs.concepts OR [],
775
+ files: obs.files OR [],
776
+ importance: obs.importance OR 5,
777
+ }
778
+
779
+ IF NOT dry_run THEN
780
+ kv.set(KV.folder_obs(folder_path, agent_id), obs.id, folder_obs)
781
+ migrated_observations ← migrated_observations + 1
782
+ END IF
783
+ END FOR
784
+
785
+ IF NOT dry_run THEN
786
+ // Upsert metadata + index
787
+ upsert_folder_meta(kv, folder_path, agent_id, len(obs_list), session.updatedAt)
788
+ migrated_sessions ← migrated_sessions + 1
789
+ END IF
790
+ END FOR
791
+
792
+ RETURN {migrated_sessions, migrated_observations, errors}
793
+ END
794
+ ```
795
+
796
+ **The old scopes are never deleted automatically** — they can be cleaned up manually after verifying the migration via `POST /agentmemory/migrate?cleanup=true`.
797
+
798
+ ---
799
+
800
+ ## MCP Tools: Kept, Renamed, and Dropped
801
+
802
+ ### Kept / Repurposed (same tool name, redirected to folder storage)
803
+
804
+ | Tool | New Behavior |
805
+ |------|-------------|
806
+ | `agent_observe` | Required: `folderPath`, `agentId`, `text`. Logs to `(folder_path, agent_id)` scope. `sessionId` accepted but ignored. |
807
+ | `memory_recall` | Searches across all folder observations + global memories via BM25+vector. Optional `folderPath` / `agentId` filters. |
808
+ | `memory_save` | Unchanged — saves to global memories |
809
+ | `agent_remember` | Unchanged — saves to global memories |
810
+ | `memory_smart_search` | Same as `memory_recall` (hybrid search) |
811
+ | `memory_export` | Exports folders + observations + memories in v2 format |
812
+ | `memory_forget` | Accepts `memoryId` (global) OR `folderPath+agentId` (folder observations) |
813
+ | `memory_diagnose` | Returns folder/agent/observation counts instead of session counts |
814
+
815
+ ### New Tools
816
+
817
+ | Tool | Description |
818
+ |------|-------------|
819
+ | `memory_folders` | List all (folder, agent) pairs with obsCount and lastUpdated |
820
+ | `memory_folder_observations` | Get observations for a specific `(folderPath, agentId)` pair |
821
+ | `memory_timeline` | Folder activity feed — observations sorted by time, filterable by folderPath/agentId |
822
+
823
+ ### Dropped Tools (removed from schema)
824
+
825
+ `memory_sessions`, `memory_sessions_list`, `memory_observations` (old session-based version), `memory_profile`, `memory_lessons`, `memory_lesson_save`, `memory_lesson_recall`, `memory_lesson_search`, `memory_consolidate`, `memory_reflect`, `memory_crystallize`, `memory_slot_list`, `memory_slot_get`, `memory_slot_create`, `memory_slot_append`, `memory_slot_replace`, `memory_slot_delete`, `memory_action_create`, `memory_action_update`, `memory_frontier`, `memory_antigravity_sync`, `memory_antigravity_sync_all`
826
+
827
+ ---
828
+
829
+ ## Viewer Dashboard Restructure
830
+
831
+ ### New Tab Structure
832
+
833
+ | Tab | Content |
834
+ |-----|---------|
835
+ | **Folders** | List all `(folder, agent)` pairs — shows `folderPath`, `agentId`, `obsCount`, `lastUpdated`. Click a row to drill into that pair's observations. |
836
+ | **Memories** | Unchanged — global long-term memories with search |
837
+ | **Graph** | Force-directed graph where nodes = folder paths, edges = same-parent / cross-ref / agent-shared relationships. Uses existing `folderColor()` function. |
838
+ | **Timeline** | All observations across all pairs, sorted by timestamp desc. Filter bar: folder path (text match), agent dropdown. |
839
+
840
+ ### Removed Tabs
841
+
842
+ Sessions, Lessons, Slots, Actions, Replay, Profile, Crystals
843
+
844
+ ### Viewer Data Endpoints (New REST calls)
845
+
846
+ ```
847
+ GET /agentmemory/folders → [{folderPath, agentId, obsCount, lastUpdated}]
848
+ GET /agentmemory/folder/observations?folderPath=&agentId= → {observations: [...]}
849
+ POST /agentmemory/timeline → {observations: [...]} (body: {folderPath?, agentId?, limit?, before?, after?})
850
+ GET /agentmemory/graph → {nodes: [...], edges: [...]}
851
+ ```
852
+
853
+ ### Folders Tab — Drill-Down Detail View
854
+
855
+ When a `(folder, agent)` row is clicked:
856
+ - Header shows `folderPath` + `agentId` badge
857
+ - Observations listed chronologically (oldest first), each showing `timestamp`, `type` badge, `title`, `text` excerpt
858
+ - `summary` field shown if present (LLM or auto-generated)
859
+ - "Delete folder memory" button calls `POST /agentmemory/forget` with `{folderPath, agentId}`
860
+
861
+ ---
862
+
863
+ ## Error Handling
864
+
865
+ ### Error Scenario 1: Missing Required Fields in `agent_observe`
866
+
867
+ **Condition**: `folderPath`, `agentId`, or `text` missing from payload
868
+ **Response**: HTTP 400 `{"error": "folderPath, agentId, and text are required"}`
869
+ **Recovery**: Agent retries with all required fields
870
+
871
+ ### Error Scenario 2: Observation Cap Reached
872
+
873
+ **Condition**: `(folder_path, agent_id)` pair has reached `MAX_OBS_PER_FOLDER` (env var, default 2000)
874
+ **Response**: HTTP 400 `{"error": "Folder observation limit reached (2000)"}`
875
+ **Recovery**: Agent calls `memory_forget` to prune old observations, or increases the cap
876
+
877
+ ### Error Scenario 3: Path Traversal Attempt
878
+
879
+ **Condition**: `folderPath` contains `..` that after normalization escapes expected boundaries
880
+ **Response**: HTTP 400 `{"error": "Invalid folderPath"}`
881
+ **Recovery**: Agent provides an absolute, non-traversal path
882
+
883
+ ### Error Scenario 4: Search Index Empty on Startup
884
+
885
+ **Condition**: BM25 index is empty (fresh install or corrupted shard)
886
+ **Response**: Search returns empty results; background thread rebuilds index from all folder observations
887
+ **Recovery**: Automatic — `rebuild_index()` iterates `KV.folders` index and re-adds all observations
888
+
889
+ ### Error Scenario 5: Migration Endpoint Failure
890
+
891
+ **Condition**: Old session data is malformed or a session has no `cwd`/`project`
892
+ **Response**: `errors` list in migration response captures per-session failures; migration continues for other sessions
893
+ **Recovery**: Manual cleanup; old scopes remain untouched
894
+
895
+ ---
896
+
897
+ ## Testing Strategy
898
+
899
+ ### Unit Testing Approach
900
+
901
+ Use `pytest`. No test runner currently configured — add `pytest` to `requirements.txt` dev deps.
902
+
903
+ Key unit test targets:
904
+ - `normalize_folder_path()`: edge cases (Windows paths, trailing slashes, `..` segments, UNC paths)
905
+ - `strip_private_data()`: existing coverage patterns + folder-path-specific cases
906
+ - `folder_observe()`: required field validation, obs ID generation, meta increment, index upsert
907
+ - `forget()` with folder targets: verifies all scopes cleaned up
908
+ - `folder_timeline()`: timestamp ordering, filter correctness, limit enforcement
909
+ - `folder_graph_build()`: node deduplication, edge type correctness, empty case
910
+
911
+ ### Property-Based Testing Approach
912
+
913
+ Use `hypothesis` for:
914
+ - Any `normalize_folder_path(p)` — result is always a non-empty string with no trailing slash
915
+ - `folder_timeline()` result is always sorted (timestamps non-increasing)
916
+ - `folder_observe()` followed by `forget()` leaves zero observations for that pair
917
+
918
+ ### Integration Testing Approach
919
+
920
+ End-to-end test using a temp SQLite DB:
921
+ 1. Call `folder_observe()` with 3 different `(folder, agent)` combinations
922
+ 2. Assert all 3 are returned by `folder_timeline()` in correct order
923
+ 3. Assert `folder_search("some keyword")` returns the matching observation
924
+ 4. Assert `folder_graph_build()` produces 3 nodes and correct edges
925
+ 5. Call `forget({folderPath, agentId})` and assert observations deleted from search index
926
+
927
+ ---
928
+
929
+ ## Performance Considerations
930
+
931
+ - **Observation scope keys**: `mem:folder:{path}:{agent}` keys can become very long for deep paths. The `kv.list()` call uses `SELECT ... WHERE scope = ?` which is an exact match — long scope strings are fine for SQLite. The `idx_kv_scope` index on `kv_store(scope)` ensures O(log N) lookup.
932
+ - **Timeline query cost**: Reading all observations across all folders is O(total_observations). For large deployments, this is mitigated by the `limit` cap and the timestamp filter being applied in Python (post-fetch). A future optimization is to add a `ts` column to `kv_store` for SQL-level filtering.
933
+ - **Graph build cost**: `folder_graph_build()` does full text scanning of observations for cross-reference edges. This is acceptable for the viewer (user-triggered) but should not run on every request. Cache the graph payload with a 30-second TTL or invalidate on any new `folder_observe()`.
934
+ - **BM25 rebuild**: `rebuild_index()` iterates all scopes. With the new schema, it must iterate `KV.folders` index entries and load each pair's observations — same O(N) complexity as before.
935
+ - **Existing search.py and IndexPersistence are unchanged** — no performance regression in the search pipeline.
936
+
937
+ ---
938
+
939
+ ## Security Considerations
940
+
941
+ - **Path normalization** before storage prevents `..` traversal in KV scope keys. `os.path.normpath()` is called before any KV write.
942
+ - **`strip_private_data()`** is called on `text` before storage — same patterns as current (API keys, JWTs, Bearer tokens, etc.).
943
+ - **Auth**: All new endpoints follow the existing `check_auth()` pattern using `hmac.compare_digest`.
944
+ - **Scope injection**: `folder_path` and `agent_id` are embedded in KV scope strings. Both are normalized (control chars stripped, length capped at 512 chars) before scope construction to prevent scope collision or injection via crafted path strings.
945
+ - **`MAX_OBS_PER_FOLDER`** cap prevents unbounded storage growth per pair, mitigating denial-of-service via observation flooding.
946
+
947
+ ---
948
+
949
+ ## Dependencies
950
+
951
+ All dependencies are already present in `requirements.txt`. No new packages required:
952
+
953
+ | Package | Used for |
954
+ |---------|----------|
955
+ | `flask` | REST server |
956
+ | `flask-sock` | WebSocket broadcaster |
957
+ | `python-dateutil` | ISO timestamp parsing in timeline filters |
958
+ | `huggingface_hub` | HF Space sync (unchanged) |
959
+
960
+ Optional (already supported):
961
+ | Package | Used for |
962
+ |---------|----------|
963
+ | `requests` | Gemini embedding API calls |
964
+ | `jieba` | CJK segmentation in BM25 tokenizer |
965
+
966
+ New dev dependency for tests:
967
+ | Package | Used for |
968
+ |---------|----------|
969
+ | `pytest` | Unit and integration tests |
970
+ | `hypothesis` | Property-based tests |
971
+
972
+ ---
973
+
974
+ ## Correctness Properties
975
+
976
+ The following properties must hold universally across the system:
977
+
978
+ ### Property 1: Pair Isolation
979
+
980
+ For all `(folder_path_a, agent_a)` and `(folder_path_b, agent_b)` where the pairs are distinct, observations written to pair A are never returned when reading pair B's observations directly.
981
+
982
+ `∀ a ≠ b: kv.list(KV.folder_obs(a)) ∩ kv.list(KV.folder_obs(b)) = ∅`
983
+
984
+ ### Property 2: Observation Count Consistency
985
+
986
+ After any sequence of `folder_observe()` calls for a pair, `KV.folder_meta(fp, aid)["obsCount"]` equals the number of observations stored in `KV.folder_obs(fp, aid)`.
987
+
988
+ `obsCount = len(kv.list(KV.folder_obs(fp, aid)))`
989
+
990
+ ### Property 3: Index Coverage
991
+
992
+ Every `(folder_path, agent_id)` pair that has at least one observation is present in the global `KV.folders` index.
993
+
994
+ `∀ (fp, aid): len(kv.list(KV.folder_obs(fp, aid))) > 0 ⟹ kv.get(KV.folders, f"{fp}:{aid}") IS NOT NULL`
995
+
996
+ ### Property 4: Privacy Invariant
997
+
998
+ No observation stored in `KV.folder_obs(fp, aid)` contains a string matching any pattern in `SECRET_PATTERN_SOURCES`. Text is sanitized by `strip_private_data()` before every write.
999
+
1000
+ ### Property 5: Timeline Ordering
1001
+
1002
+ `folder_timeline()` always returns results in non-increasing timestamp order.
1003
+
1004
+ `∀ i < j in result: result[i].timestamp ≥ result[j].timestamp`
1005
+
1006
+ ### Property 6: Forget Completeness
1007
+
1008
+ After `forget({folderPath: fp, agentId: aid})` with no specific `observationIds` completes: `kv.list(KV.folder_obs(fp, aid))` is empty, `kv.get(KV.folders, f"{fp}:{aid}")` is None, and the BM25 index contains no entries originating from that pair.
1009
+
1010
+ ### Property 7: Memory Versioning
1011
+
1012
+ At most one memory with a given conceptual content (Jaccard similarity > 0.7) has `isLatest = True` at any point in time. All superseded versions have `isLatest = False` and `parentId` set.
1013
+
1014
+ ### Property 8: Path Normalization Idempotency
1015
+
1016
+ `normalize_folder_path` is idempotent: applying it twice produces the same result as applying it once.
1017
+
1018
+ `normalize_folder_path(normalize_folder_path(p)) == normalize_folder_path(p)` for all non-empty strings `p`.
1019
+
1020
+ ### Property 9: Search Index Sync
1021
+
1022
+ Every observation added via `folder_observe()` is present in `_bm25_index` under its `obs_id`. Every observation deleted via `forget()` is absent from `_bm25_index` after the operation completes.
1023
+
1024
+ ### Property 10: Graph Node Uniqueness
1025
+
1026
+ `folder_graph_build()` produces exactly one node per distinct `folder_path` in `KV.folders`, regardless of how many `agent_id` values are associated with that folder path.
.kiro/specs/folder-based-memory/requirements.md ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Requirements: Folder-Based Memory
2
+
3
+ ## Overview
4
+
5
+ This document specifies the requirements for restructuring agentmemory-python from a session-based architecture to a folder-based memory model. The primary unit of storage shifts from `(session_id)` to `(folder_path, agent_id)` — each agent accumulates observations scoped to the folder it is working in, with no concept of "sessions". Long-term global memories remain unchanged. The viewer, MCP tools, and REST API are all reshaped around this model.
6
+
7
+ The redesign eliminates sessions, lessons, slots, actions, crystals, and artefacts entirely. What remains is: folder observations, global memories, BM25 + optional vector search, a folder-based graph, a folder activity feed (timeline), privacy stripping, and the MCP tool-calling interface.
8
+
9
+ ---
10
+
11
+ ## Group 1 — Data Model
12
+
13
+ - **REQ-001:** The system SHALL store observations scoped to `(folder_path, agent_id)` pairs, replacing the existing session-based `(session_id)` scope.
14
+ - **REQ-002:** The system SHALL normalize all folder paths using `os.path.normpath`, converting OS separators to forward slashes and stripping leading/trailing slashes before any storage or scope construction.
15
+ - **REQ-003:** Each observation SHALL contain the following fields: `id`, `folderPath`, `agentId`, `timestamp`, `text`, `type`, `title`, `concepts`, `files`, and `importance`.
16
+ - **REQ-004:** The system SHALL maintain a global folders index stored in the `mem:folders` KV scope, keyed by the string `"{folder_path}:{agent_id}"`, containing `folderPath`, `agentId`, `lastUpdated`, and `obsCount`.
17
+ - **REQ-005:** The system SHALL maintain per-pair metadata in a `mem:foldermeta:{folder_path}:{agent_id}` KV scope containing `folderPath`, `agentId`, `lastUpdated`, `obsCount`, and an optional `summary` field.
18
+ - **REQ-006:** Global memories stored in the `mem:memories` scope SHALL remain unchanged from the current implementation, including their data model, versioning behaviour, and Jaccard deduplication.
19
+ - **REQ-007:** The system SHALL cap observation `text` at 4000 characters before storage.
20
+
21
+ ---
22
+
23
+ ## Group 2 — Observation Ingestion (`agent_observe`)
24
+
25
+ - **REQ-008:** The `agent_observe` endpoint SHALL require the fields `folderPath`, `agentId`, `text`, and `timestamp`; requests missing any of these fields SHALL be rejected with an error response.
26
+ - **REQ-009:** The system SHALL call `strip_private_data()` on the observation text before any storage or indexing operation.
27
+ - **REQ-010:** The system SHALL generate a unique observation ID with the prefix `fobs_` for every new observation.
28
+ - **REQ-011:** The system SHALL upsert the `mem:folders` index entry for the `(folder_path, agent_id)` pair on every successful observation write.
29
+ - **REQ-012:** The system SHALL add each new observation to the BM25 index, and to the vector index if an embedding provider is configured.
30
+ - **REQ-013:** The system SHALL broadcast each new observation via the existing WebSocket mechanism to connected viewers.
31
+ - **REQ-014:** The system SHALL write an audit log entry in `mem:audit` for each observation ingestion.
32
+ - **REQ-015:** The system SHALL enforce a per-pair observation cap (`MAX_OBS_PER_FOLDER`, default 2000, configurable via environment variable); ingestion SHALL be rejected when the cap is reached.
33
+
34
+ ---
35
+
36
+ ## Group 3 — Search
37
+
38
+ - **REQ-016:** The system SHALL support hybrid BM25 + vector search across all folder observations, combining scores via Reciprocal Rank Fusion (RRF) using the existing `HybridSearch` implementation.
39
+ - **REQ-017:** Search SHALL accept optional `folderPath` and `agentId` filter parameters and restrict results to matching pairs when provided.
40
+ - **REQ-018:** Search results SHALL also include matching global memories from `mem:memories` alongside folder observations.
41
+ - **REQ-019:** Each search result SHALL include `folderPath`, `agentId`, `score`, and the full observation object.
42
+
43
+ ---
44
+
45
+ ## Group 4 — Timeline
46
+
47
+ - **REQ-020:** The system SHALL provide a folder activity feed that returns observations sorted by `timestamp` descending.
48
+ - **REQ-021:** The timeline SHALL support filtering by `folderPath`, `agentId`, `before` (ISO 8601 timestamp upper bound), and `after` (ISO 8601 timestamp lower bound).
49
+ - **REQ-022:** The timeline SHALL respect a `limit` parameter (default 100) and return at most that many observations.
50
+
51
+ ---
52
+
53
+ ## Group 5 — Graph
54
+
55
+ - **REQ-023:** The system SHALL build a graph where each unique `folder_path` (regardless of how many agents have observations in it) is represented as exactly one node.
56
+ - **REQ-024:** Each graph node SHALL contain the fields `id`, `label`, `folderPath`, `agentIds` (list of all agents with observations in that folder), `obsCount`, and `color` (derived via the existing `folderColor()` hash function).
57
+ - **REQ-025:** The system SHALL create "same-parent" edges between any two folder nodes that share the same parent directory.
58
+ - **REQ-026:** The system SHALL create "cross-reference" edges between folder A and folder B when the text or title of any observation in folder A contains folder B's path string.
59
+ - **REQ-027:** The system SHALL create "agent-shared" edges between any two folder nodes that have at least one common `agentId` with observations.
60
+ - **REQ-028:** The graph SHALL contain no duplicate edges; an edge is a duplicate if it shares the same `source`, `target`, and `type` as an existing edge.
61
+
62
+ ---
63
+
64
+ ## Group 6 — Forget / Delete
65
+
66
+ - **REQ-029:** The `forget` function SHALL accept a `memoryId` parameter to delete a single global memory from `mem:memories`.
67
+ - **REQ-030:** The `forget` function SHALL accept a `folderPath` + `agentId` pair to delete all observations stored for that pair.
68
+ - **REQ-031:** The `forget` function SHALL accept a `folderPath` + `agentId` pair together with a list of `observationIds` to delete only the specified observations.
69
+ - **REQ-032:** When a full `(folderPath, agentId)` pair is deleted, the system SHALL remove its entry from the `mem:folders` index, delete the `mem:foldermeta:{path}:{agent}` scope, and remove all corresponding entries from the BM25 index.
70
+ - **REQ-033:** The response from any `forget` operation SHALL include a `"deleted"` field containing the count of items actually removed from storage.
71
+
72
+ ---
73
+
74
+ ## Group 7 — Viewer (4 Tabs Only)
75
+
76
+ - **REQ-034:** The viewer dashboard SHALL contain exactly four tabs: Folders, Memories, Graph, and Timeline.
77
+ - **REQ-035:** The Folders tab SHALL list all `(folder, agent)` pairs, displaying `folderPath`, `agentId`, `obsCount`, and `lastUpdated` for each.
78
+ - **REQ-036:** Clicking a row in the Folders tab SHALL display a drill-down view showing all observations for that `(folderPath, agentId)` pair.
79
+ - **REQ-037:** The Memories tab SHALL be functionally unchanged from the current implementation.
80
+ - **REQ-038:** The Graph tab SHALL render a force-directed graph using the folder-based node and edge model defined in Group 5.
81
+ - **REQ-039:** The Timeline tab SHALL display observations from all pairs sorted by timestamp descending, with filter controls for folder path and agent ID.
82
+ - **REQ-040:** The following tabs SHALL be removed from the viewer: Sessions, Lessons, Slots, Actions, Replay, Profile, and Crystals.
83
+
84
+ ---
85
+
86
+ ## Group 8 — MCP Tools
87
+
88
+ - **REQ-041:** The `agent_observe` MCP tool SHALL require `folderPath`, `agentId`, and `text`; a `sessionId` parameter MAY be accepted for backward compatibility but SHALL be ignored.
89
+ - **REQ-042:** The `memory_recall` MCP tool SHALL search folder observations and global memories, accepting optional `folderPath` and `agentId` filter parameters.
90
+ - **REQ-043:** The `memory_save` and `agent_remember` MCP tools SHALL continue to save to global memories without change.
91
+ - **REQ-044:** The `memory_smart_search` MCP tool SHALL perform hybrid BM25 + vector search equivalent to `memory_recall`.
92
+ - **REQ-045:** The `memory_export` MCP tool SHALL export all folder pairs with their observations and all global memories in the v2 export format (version `"2.0"`).
93
+ - **REQ-046:** The `memory_forget` MCP tool SHALL accept either a `memoryId` (global memory deletion) or a `folderPath` + `agentId` pair (folder observations deletion).
94
+ - **REQ-047:** The `memory_diagnose` MCP tool SHALL return `folderCount`, `agentCount`, `pairCount`, `observationCount`, and `memoryCount` instead of session-based counts.
95
+ - **REQ-048:** A new `memory_folders` MCP tool SHALL be added that lists all `(folder, agent)` pairs with `folderPath`, `agentId`, `obsCount`, and `lastUpdated`.
96
+ - **REQ-049:** A new `memory_folder_observations` MCP tool SHALL be added that returns all observations for a specified `(folderPath, agentId)` pair.
97
+ - **REQ-050:** A new `memory_timeline` MCP tool SHALL be added that returns the folder activity feed, supporting the same filter parameters as the timeline REST endpoint.
98
+ - **REQ-051:** The following MCP tools SHALL be removed: `memory_sessions`, `memory_sessions_list`, `memory_observations` (session-based), `memory_profile`, `memory_lessons`, `memory_lesson_save`, `memory_lesson_recall`, `memory_lesson_search`, `memory_consolidate`, `memory_reflect`, `memory_crystallize`, `memory_slot_list`, `memory_slot_get`, `memory_slot_create`, `memory_slot_append`, `memory_slot_replace`, `memory_slot_delete`, `memory_action_create`, `memory_action_update`, `memory_frontier`, `memory_antigravity_sync`, and `memory_antigravity_sync_all`.
99
+
100
+ ---
101
+
102
+ ## Group 9 — REST Endpoints
103
+
104
+ - **REQ-052:** The system SHALL expose `GET /agentmemory/folders` to return a list of all `(folder, agent)` pairs from the `mem:folders` index.
105
+ - **REQ-053:** The system SHALL expose `GET /agentmemory/folder/observations` accepting `folderPath` and `agentId` query parameters and returning all observations for that pair.
106
+ - **REQ-054:** The system SHALL expose `POST /agentmemory/timeline` accepting an optional JSON body with `folderPath`, `agentId`, `limit`, `before`, and `after` fields, and returning the folder activity feed.
107
+ - **REQ-055:** The system SHALL expose `GET /agentmemory/graph` returning the graph payload with `nodes` and `edges` arrays.
108
+ - **REQ-056:** The system SHALL expose `POST /agentmemory/migrate` to migrate old session-based data to the folder format, supporting a `dry_run` boolean parameter.
109
+ - **REQ-057:** All new endpoints SHALL enforce `AGENTMEMORY_SECRET` Bearer token authentication using `hmac.compare_digest`, consistent with the existing `check_auth()` pattern; the `/livez` endpoint SHALL remain unauthenticated.
110
+
111
+ ---
112
+
113
+ ## Group 10 — Migration
114
+
115
+ - **REQ-058:** The `POST /agentmemory/migrate` endpoint SHALL read from the existing `mem:sessions` and `mem:obs:{session_id}` KV scopes to retrieve legacy session data.
116
+ - **REQ-059:** Migration SHALL map `session.cwd` or `session.project` to `folderPath`, and `session.agentId` to `agentId`; missing values SHALL fall back to the string `"unknown"`.
117
+ - **REQ-060:** Migration SHALL be non-destructive: old `mem:sessions` and `mem:obs:*` scopes SHALL never be automatically deleted during or after migration.
118
+ - **REQ-061:** Migration SHALL support a `dry_run=true` mode that computes and returns migration counts without writing any data.
119
+ - **REQ-062:** The migration response SHALL include the fields `migrated_sessions`, `migrated_observations`, and `errors`.
120
+
121
+ ---
122
+
123
+ ## Group 11 — Security & Privacy
124
+
125
+ - **REQ-063:** The system SHALL apply `os.path.normpath` to all folder paths before using them in any KV scope key construction.
126
+ - **REQ-064:** The system SHALL reject with HTTP 400 any request where the normalized folder path contains path traversal patterns (e.g. `..`).
127
+ - **REQ-065:** The system SHALL call `strip_private_data()` on all observation text before storage, indexing, or broadcast.
128
+ - **REQ-066:** The system SHALL length-cap `agent_id` and `folder_path` values at 512 characters before using them in any KV scope key construction.
129
+ - **REQ-067:** All new REST endpoints SHALL use the existing `check_auth()` / `hmac.compare_digest` authentication pattern.
130
+
131
+ ---
132
+
133
+ ## Group 12 — Correctness Properties
134
+
135
+ - **REQ-068:** The system SHALL guarantee pair isolation: a direct read of `KV.folder_obs(folder_path_A, agent_id_A)` SHALL never return observations belonging to a different `(folder_path, agent_id)` pair.
136
+ - **REQ-069:** The system SHALL guarantee observation count consistency: the `obsCount` value stored in `KV.folder_meta(folder_path, agent_id)` SHALL always equal the actual number of observation keys present in `KV.folder_obs(folder_path, agent_id)`.
137
+ - **REQ-070:** The system SHALL guarantee index coverage: every `(folder_path, agent_id)` pair that has at least one stored observation SHALL have a corresponding entry in the `mem:folders` index.
138
+ - **REQ-071:** The system SHALL guarantee timeline ordering: all results returned by `folder_timeline()` SHALL be in non-increasing `timestamp` order (i.e. `result[i].timestamp >= result[i+1].timestamp` for all valid `i`).
139
+ - **REQ-072:** The system SHALL guarantee forget completeness: after a full `(folderPath, agentId)` pair deletion, the BM25 index SHALL contain no document entries originating from that pair.
140
+ - **REQ-073:** The system SHALL guarantee memory version uniqueness: at most one memory with a Jaccard similarity greater than 0.7 to any other memory SHALL have `isLatest=True` at any point in time.
141
+ - **REQ-074:** The system SHALL guarantee path normalization idempotency: applying `normalize_folder_path()` twice to any input SHALL produce the same result as applying it once — i.e. `normalize_folder_path(normalize_folder_path(p)) == normalize_folder_path(p)`.
.kiro/specs/folder-based-memory/tasks.md ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Implementation Plan: Folder-Based Memory
2
+
3
+ ## Overview
4
+
5
+ Restructure agentmemory-python from session-based to folder-based memory. The primary unit
6
+ of storage shifts from `(session_id)` to `(folder_path, agent_id)`. Core business logic
7
+ (`functions.py`) is rewritten first; REST endpoints and MCP tools (`app.py`) are updated
8
+ next; finally the viewer (`index.html`) is rebuilt to four tabs: Folders, Memories, Graph,
9
+ Timeline. A migration endpoint and a pytest + hypothesis test suite round out the work.
10
+
11
+ ---
12
+
13
+ ## Tasks
14
+
15
+ - [x] 1. Rewrite KV scope registry and core helpers in `src/functions.py`
16
+ - [x] 1.1 Replace the `KV` class with the folder-based scope definitions
17
+ - Remove all session/lesson/slot/action/crystal/sketch/facet/sentinel/signal/checkpoint/mesh/routine scopes
18
+ - Add `KV.folders = "mem:folders"`, `KV.folder_obs(folder_path, agent_id)`, `KV.folder_meta(folder_path, agent_id)`
19
+ - Keep `KV.memories`, `KV.bm25Index`, `KV.audit`; keep `KV.relations` (repurposed for graph edges)
20
+ - _Requirements: REQ-001, REQ-004, REQ-005_
21
+
22
+ - [x] 1.2 Add `normalize_folder_path()` utility function
23
+ - Apply `os.path.normpath`, convert OS separators to `/`, strip leading/trailing slashes
24
+ - Reject (raise `ValueError`) if the normalized path contains `..` segments
25
+ - Cap `folder_path` and `agent_id` at 512 characters before any scope construction
26
+ - _Requirements: REQ-002, REQ-063, REQ-064, REQ-066_
27
+
28
+ - [ ]* 1.3 Write property test for `normalize_folder_path` — idempotency
29
+ - **Property 8: Path Normalization Idempotency**
30
+ - **Validates: REQ-074 / Requirements REQ-002**
31
+ - Use `hypothesis.strategies.text()` with a mix of Windows and POSIX path strings
32
+ - Assert `normalize_folder_path(normalize_folder_path(p)) == normalize_folder_path(p)` for all valid inputs
33
+
34
+ - [x] 2. Implement `folder_observe()` — observation ingestion
35
+ - [x] 2.1 Write `folder_observe(kv, payload) -> dict` in `src/functions.py`
36
+ - Validate required fields: `folderPath`, `agentId`, `text`, `timestamp`; raise `ValueError` if missing
37
+ - Normalize `folder_path` via `normalize_folder_path()`; strip and validate `agent_id`
38
+ - Call `strip_private_data(text)` before any storage; cap text at 4000 chars
39
+ - Generate `obs_id = generate_id("fobs")`; build `FolderObservation` dict with all fields
40
+ - Write to `KV.folder_obs(folder_path, agent_id)` under `obs_id`
41
+ - Upsert `KV.folder_meta` (`obsCount += 1`, update `lastUpdated`); upsert `KV.folders` index entry
42
+ - Add to `_bm25_index`; call `vector_index_add_guarded` if provider set; schedule persistence save
43
+ - Write audit log entry via `kv.commit_version`; broadcast via `broadcast_stream`
44
+ - Enforce `MAX_OBS_PER_FOLDER` cap (default 2000, env-configurable); raise `ValueError` if reached
45
+ - _Requirements: REQ-003, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011, REQ-012, REQ-013, REQ-014, REQ-015_
46
+
47
+ - [ ]* 2.2 Write property test for `folder_observe` — pair isolation
48
+ - **Property 1: Pair Isolation**
49
+ - **Validates: REQ-068**
50
+ - Generate two distinct `(folderPath, agentId)` pairs via `hypothesis`; write one observation to each
51
+ - Assert `kv.list(KV.folder_obs(a)) ∩ kv.list(KV.folder_obs(b)) = ∅`
52
+
53
+ - [ ]* 2.3 Write property test for `folder_observe` — observation count consistency
54
+ - **Property 2: Observation Count Consistency**
55
+ - **Validates: REQ-069**
56
+ - Generate N random observations for a pair (N drawn from `hypothesis.strategies.integers(1, 20)`)
57
+ - Assert `kv.get(KV.folder_meta(fp, aid), "meta")["obsCount"] == len(kv.list(KV.folder_obs(fp, aid)))`
58
+
59
+ - [ ]* 2.4 Write property test for `folder_observe` — index coverage
60
+ - **Property 3: Index Coverage**
61
+ - **Validates: REQ-070**
62
+ - After writing at least one observation for a pair, assert `kv.get(KV.folders, f"{fp}:{aid}")` is not None
63
+
64
+ - [ ]* 2.5 Write property test for `folder_observe` — privacy invariant
65
+ - **Property 4: Privacy Invariant**
66
+ - **Validates: REQ-009 / REQ-065**
67
+ - Inject strings matching `SECRET_PATTERN_SOURCES` patterns into `text`; call `folder_observe`
68
+ - Assert no stored observation's `text` field matches any secret pattern
69
+
70
+ - [x] 3. Implement `folder_search()`, `folder_timeline()`, and `folder_graph_build()`
71
+ - [x] 3.1 Write `folder_search(kv, query, limit, folder_path?, agent_id?) -> list` in `src/functions.py`
72
+ - Delegate to existing `_hybrid_search.search(query, limit * 2)` for BM25 + vector scoring
73
+ - Hydrate each `obs_id` from `KV.folder_obs` scopes; include matching global memories from `KV.memories`
74
+ - Apply `folder_path` / `agent_id` post-filters; include `folderPath`, `agentId`, `score` in each result
75
+ - _Requirements: REQ-016, REQ-017, REQ-018, REQ-019_
76
+
77
+ - [x] 3.2 Write `folder_timeline(kv, limit, folder_path?, agent_id?, before?, after?) -> list`
78
+ - List all entries from `KV.folders`; apply folder/agent filters; load observations from each pair
79
+ - Apply `before` / `after` ISO timestamp filters; sort all results by `timestamp` descending; return `[:limit]`
80
+ - _Requirements: REQ-020, REQ-021, REQ-022_
81
+
82
+ - [ ]* 3.3 Write property test for `folder_timeline` — ordering guarantee
83
+ - **Property 5: Timeline Ordering**
84
+ - **Validates: REQ-071**
85
+ - Generate an arbitrary set of observations with random timestamps; call `folder_timeline(kv, limit=1000)`
86
+ - Assert `all(result[i]["timestamp"] >= result[i+1]["timestamp"] for i in range(len(result)-1))`
87
+
88
+ - [x] 3.4 Write `folder_graph_build(kv) -> dict`
89
+ - Build one node per unique `folder_path` in `KV.folders`; aggregate `agentIds` and `obsCount`; assign color via `folderColor(folder_path)` hash
90
+ - Produce three edge types: `"same-parent"` (shared parent dir), `"cross-ref"` (folder B's path mentioned in folder A's obs text/title), `"agent-shared"` (two folders share an `agentId`)
91
+ - Deduplicate edges on `(source, target, type)`; return `{"nodes": [...], "edges": [...]}`
92
+ - _Requirements: REQ-023, REQ-024, REQ-025, REQ-026, REQ-027, REQ-028_
93
+
94
+ - [ ]* 3.5 Write property test for `folder_graph_build` — node uniqueness
95
+ - **Property 10: Graph Node Uniqueness**
96
+ - **Validates: REQ-073 / Requirements REQ-023**
97
+ - Generate observations in multiple `(folder, agent)` combinations sharing some folder paths
98
+ - Assert `len(nodes) == len({entry["folderPath"] for entry in kv.list(KV.folders)})`
99
+
100
+ - [x] 4. Update `forget()` and `health_check()` / `export_data()` in `src/functions.py`
101
+ - [x] 4.1 Rewrite `forget(kv, data) -> dict` to handle folder-based deletion
102
+ - If `memoryId` present: delete from `KV.memories` (existing logic, unchanged)
103
+ - If `folderPath + agentId` present (no `observationIds`): delete all entries in `KV.folder_obs(fp, aid)`, remove BM25 index entries for each `obs_id`, delete `KV.folder_meta` entry, remove `KV.folders` index entry; set `result["deleted"]`
104
+ - If `folderPath + agentId + observationIds` present: delete only specified observations, decrement `obsCount` in metadata
105
+ - _Requirements: REQ-029, REQ-030, REQ-031, REQ-032, REQ-033_
106
+
107
+ - [ ]* 4.2 Write property test for `forget` — completeness
108
+ - **Property 6: Forget Completeness**
109
+ - **Validates: REQ-072**
110
+ - Write N observations for a pair; call `forget({folderPath, agentId})`
111
+ - Assert `kv.list(KV.folder_obs(fp, aid)) == []`, `kv.get(KV.folders, key) is None`, and no BM25 entry matches any `obs_id` from that pair
112
+
113
+ - [x] 4.3 Update `health_check(kv) -> dict` to return folder-based counts
114
+ - Return `folderCount` (distinct folder paths), `agentCount`, `pairCount`, `observationCount`, `memoryCount`, `bm25IndexSize`, `vectorIndexSize`, `dbPath`
115
+ - Remove session-based fields
116
+ - _Requirements: REQ-047_
117
+
118
+ - [x] 4.4 Update `export_data(kv, opts) -> dict` to v2 folder format
119
+ - Output `{"folders": [{folderPath, agentId, meta, observations: [...]}], "memories": [...], "exportedAt": ..., "version": "2.0"}`
120
+ - _Requirements: REQ-045_
121
+
122
+ - [x] 4.5 Update `rebuild_index(kv)` to read from folder observation scopes
123
+ - Iterate `KV.folders` index entries; load each pair's observations via `KV.folder_obs`; re-add to BM25 and vector indexes
124
+ - _Requirements: REQ-016_
125
+
126
+ - [x] 5. Checkpoint — verify core business logic
127
+ - Ensure all tests pass, ask the user if questions arise.
128
+
129
+ - [x] 6. Update REST endpoints in `src/app.py`
130
+ - [x] 6.1 Rewrite `POST /agentmemory/agent/observe` to call `folder_observe()`
131
+ - Require `folderPath`, `agentId`, `text`; accept `sessionId` for backward compat (ignore it)
132
+ - Validate with HTTP 400 if required fields are missing; pass through `folderPath`, `agentId`, `text`, `timestamp`, `type`, `title`, `concepts`, `files`, `importance`
133
+ - _Requirements: REQ-008, REQ-041, REQ-057_
134
+
135
+ - [x] 6.2 Update `POST /agentmemory/search` to call `folder_search()`
136
+ - Accept optional `folderPath` and `agentId` in request body; pass to `folder_search()`
137
+ - _Requirements: REQ-016, REQ-017, REQ-057_
138
+
139
+ - [ ] 6.3 Add `GET /agentmemory/folders` endpoint
140
+ - Return `kv.list(KV.folders)` sorted by `lastUpdated` descending
141
+ - _Requirements: REQ-052, REQ-057_
142
+
143
+ - [x] 6.4 Add `GET /agentmemory/folder/observations` endpoint
144
+ - Accept `folderPath` and `agentId` query params (both required); return `{"observations": [...]}`
145
+ - Reject with HTTP 400 if either param is missing
146
+ - _Requirements: REQ-053, REQ-057_
147
+
148
+ - [x] 6.5 Add `POST /agentmemory/timeline` endpoint
149
+ - Accept optional JSON body `{folderPath?, agentId?, limit?, before?, after?}`; delegate to `folder_timeline()`
150
+ - _Requirements: REQ-054, REQ-057_
151
+
152
+ - [x] 6.6 Add `GET /agentmemory/graph` endpoint
153
+ - Delegate to `folder_graph_build(kv)`; return `{"nodes": [...], "edges": [...]}`
154
+ - _Requirements: REQ-055, REQ-057_
155
+
156
+ - [x] 6.7 Add `POST /agentmemory/migrate` endpoint
157
+ - Accept optional `dry_run` boolean in body; delegate to `migrate_sessions_to_folders(kv, dry_run)`
158
+ - Return `{migrated_sessions, migrated_observations, errors}`
159
+ - _Requirements: REQ-056, REQ-057, REQ-058, REQ-059, REQ-060, REQ-061, REQ-062_
160
+
161
+ - [x] 6.8 Implement `migrate_sessions_to_folders(kv, dry_run=False)` in `src/functions.py`
162
+ - Read `kv.list("mem:sessions")`; for each session, map `cwd` or `project` → `folderPath`, `agentId` with `"unknown"` fallback
163
+ - Skip `:raw` observations; build `FolderObservation` from session obs fields; write if not dry-run
164
+ - Upsert folder metadata and index entries; never delete old `mem:sessions` or `mem:obs:*` scopes
165
+ - Collect per-session errors; return `{migrated_sessions, migrated_observations, errors}`
166
+ - _Requirements: REQ-058, REQ-059, REQ-060, REQ-061, REQ-062_
167
+
168
+ - [x] 6.9 Remove or stub out session-based endpoints that have no folder equivalent
169
+ - Remove/stub: `POST /session/start`, `POST /session/end`, `POST /session/commit`, `GET /session/by-commit`, `GET /sessions`, `GET /observations` (session-scoped), `GET /replay/sessions`
170
+ - Remove/stub: lessons endpoints (`/lessons`, `/lessons/search`, `/lessons/strengthen`), slots endpoints (`/slot`, `/slots`, `/slot/append`, `/slot/replace`), antigravity endpoints
171
+ - Remove background worker calls to `lesson_decay_sweep` and `consolidate` from `init_app()`
172
+ - _Requirements: REQ-040, REQ-051_
173
+
174
+ - [x] 7. Update MCP tool schema and dispatch in `src/app.py`
175
+ - [x] 7.1 Update `agent_observe` MCP tool schema and handler
176
+ - Change required fields to `folderPath`, `agentId`, `text`; mark `sessionId` as optional/deprecated
177
+ - Handler calls `folder_observe(kv, args)`
178
+ - _Requirements: REQ-041_
179
+
180
+ - [x] 7.2 Update `memory_recall` and `memory_smart_search` MCP tool schemas and handlers
181
+ - Add optional `folderPath` and `agentId` parameters; handlers call `folder_search()`
182
+ - _Requirements: REQ-042, REQ-044_
183
+
184
+ - [x] 7.3 Update `memory_export`, `memory_forget`, `memory_diagnose` MCP handlers
185
+ - `memory_export` calls updated `export_data()` → v2 format
186
+ - `memory_forget` accepts `memoryId` OR `folderPath + agentId`; calls updated `forget()`
187
+ - `memory_diagnose` calls updated `health_check()` → folder counts
188
+ - _Requirements: REQ-045, REQ-046, REQ-047_
189
+
190
+ - [x] 7.4 Add `memory_folders`, `memory_folder_observations`, `memory_timeline` MCP tools
191
+ - `memory_folders`: no required args; returns `kv.list(KV.folders)`
192
+ - `memory_folder_observations`: requires `folderPath`, `agentId`; returns observations for that pair
193
+ - `memory_timeline`: optional `folderPath`, `agentId`, `limit`, `before`, `after`; calls `folder_timeline()`
194
+ - _Requirements: REQ-048, REQ-049, REQ-050_
195
+
196
+ - [x] 7.5 Remove dropped MCP tools from schema and dispatch
197
+ - Remove from `GET /mcp/tools` list and `POST /mcp/tools` dispatch: `memory_sessions`, `memory_sessions_list`, `memory_observations` (session), `memory_profile`, `memory_lessons`, `memory_lesson_save`, `memory_lesson_recall`, `memory_lesson_search`, `memory_consolidate`, `memory_reflect`, `memory_crystallize`, `memory_slot_list`, `memory_slot_get`, `memory_slot_create`, `memory_slot_append`, `memory_slot_replace`, `memory_slot_delete`, `memory_action_create`, `memory_action_update`, `memory_frontier`, `memory_antigravity_sync`, `memory_antigravity_sync_all`
198
+ - _Requirements: REQ-051_
199
+
200
+ - [x] 8. Checkpoint — verify REST and MCP layer
201
+ - Ensure all tests pass, ask the user if questions arise.
202
+
203
+ - [x] 9. Rebuild viewer dashboard to four tabs in `src/viewer/index.html`
204
+ - [x] 9.1 Remove old tabs and restructure tab bar to exactly four tabs
205
+ - Replace current tab bar entries with: Folders, Memories, Graph, Timeline
206
+ - Remove tab panes and JS sections for: Sessions, Lessons, Slots, Actions, Replay, Profile, Crystals
207
+ - Update `<div class="tab-bar">` and the `showTab()` / active-tab JS logic accordingly
208
+ - _Requirements: REQ-034, REQ-040_
209
+
210
+ - [x] 9.2 Implement Folders tab — list view and drill-down detail
211
+ - On load, fetch `GET /agentmemory/folders`; render table rows with `folderPath`, `agentId`, `obsCount`, `lastUpdated`
212
+ - On row click, fetch `GET /agentmemory/folder/observations?folderPath=&agentId=`; render observations list with `timestamp`, type badge, `title`, `text` excerpt
213
+ - Show `summary` field if present; add "Delete folder memory" button that calls `POST /agentmemory/forget`
214
+ - _Requirements: REQ-035, REQ-036_
215
+
216
+ - [x] 9.3 Preserve Memories tab functionality (no logic change)
217
+ - Verify existing Memories tab code still works against `GET /agentmemory/memories` and `POST /agentmemory/remember`
218
+ - Update any session-specific references (e.g. session filter dropdowns) to remove or replace with folder filters
219
+ - _Requirements: REQ-037_
220
+
221
+ - [x] 9.4 Implement Graph tab with folder-based nodes and edges
222
+ - Fetch `GET /agentmemory/graph`; render force-directed graph using existing canvas + physics code
223
+ - Nodes keyed by `folderPath`; color via existing `folderColor()` function; node label = basename of `folderPath`
224
+ - Render three edge types with distinct stroke styles: `same-parent` (dashed), `cross-ref` (solid), `agent-shared` (dotted)
225
+ - Update graph sidebar filter checkboxes to match the three new edge types
226
+ - _Requirements: REQ-038_
227
+
228
+ - [x] 9.5 Implement Timeline tab with folder activity feed
229
+ - Fetch `POST /agentmemory/timeline` with `{limit: 100}` on tab activate; render observation cards sorted newest-first
230
+ - Add filter bar: text input for `folderPath` substring match, dropdown for `agentId` (populated from `GET /agentmemory/folders`)
231
+ - On filter change, re-fetch timeline with updated body params; support `before`/`after` via existing date-range controls pattern
232
+ - _Requirements: REQ-039_
233
+
234
+ - [x] 9.6 Update WebSocket handler to process `folder_observation` broadcast events
235
+ - Change the `type == "compressed_observation"` / `type == "raw_observation"` branch to also handle `type == "folder_observation"`
236
+ - On receive, append to the Timeline tab live feed if it is active; update Folders tab obsCount badge if that folder is visible
237
+ - _Requirements: REQ-013_
238
+
239
+ - [x] 10. Update supporting files
240
+ - [x] 10.1 Update `requirements.txt` to add `pytest` and `hypothesis`
241
+ - Add `pytest==8.3.5` and `hypothesis==6.131.15` under a `# dev` comment
242
+ - _Requirements: design testing strategy_
243
+
244
+ - [x] 10.2 Update `start.sh` to remove session-based startup logic
245
+ - Remove any session auto-complete or lesson-decay invocations from startup
246
+ - _Requirements: REQ-040, REQ-051_
247
+
248
+ - [x] 10.3 Update `sync.py` if it references session-scoped KV keys
249
+ - Replace any hardcoded `mem:sessions` or `mem:obs:*` references with `mem:folders` and `mem:folder:*` patterns for backup filtering
250
+ - _Requirements: REQ-004_
251
+
252
+ - [x] 11. Write pytest + hypothesis test suite
253
+ - [x] 11.1 Create `tests/test_normalize.py` — unit tests for `normalize_folder_path`
254
+ - Test Windows paths (`C:\Users\foo`), POSIX paths, paths with `..`, UNC paths, trailing slashes, empty string (expect `ValueError`)
255
+ - _Requirements: REQ-002, REQ-063_
256
+
257
+ - [x] 11.2 Create `tests/test_folder_observe.py` — unit tests for `folder_observe`
258
+ - Test missing required fields raise `ValueError`; test obs ID prefix `fobs_`; test `obsCount` increment; test `KV.folders` index upsert; test `MAX_OBS_PER_FOLDER` cap
259
+ - _Requirements: REQ-008, REQ-010, REQ-011, REQ-015_
260
+
261
+ - [x] 11.3 Create `tests/test_forget.py` — unit tests for `forget`
262
+ - Test full pair deletion clears all three scopes; test partial deletion by `observationIds`; test `deleted` count in response
263
+ - _Requirements: REQ-029, REQ-030, REQ-031, REQ-032, REQ-033_
264
+
265
+ - [x] 11.4 Create `tests/test_timeline.py` — unit tests for `folder_timeline`
266
+ - Test `before`/`after` filters; test `limit` enforcement; test empty result when no data
267
+ - _Requirements: REQ-020, REQ-021, REQ-022_
268
+
269
+ - [x] 11.5 Create `tests/test_graph.py` — unit tests for `folder_graph_build`
270
+ - Test empty KV returns `{nodes: [], edges: []}`; test one node per unique folder; test same-parent edge; test no duplicate edges
271
+ - _Requirements: REQ-023, REQ-025, REQ-028_
272
+
273
+ - [x] 11.6 Create `tests/test_migration.py` — unit tests for `migrate_sessions_to_folders`
274
+ - Test `dry_run=True` writes nothing; test `cwd`/`project` fallback to `"unknown"`; test `:raw` obs are skipped; test `errors` list populated on malformed sessions
275
+ - _Requirements: REQ-058, REQ-059, REQ-060, REQ-061, REQ-062_
276
+
277
+ - [ ]* 11.7 Create `tests/test_properties.py` — all Hypothesis property tests
278
+ - **Property 1: Pair Isolation** (REQ-068)
279
+ - **Property 2: Observation Count Consistency** (REQ-069)
280
+ - **Property 3: Index Coverage** (REQ-070)
281
+ - **Property 4: Privacy Invariant** (REQ-065)
282
+ - **Property 5: Timeline Ordering** (REQ-071)
283
+ - **Property 6: Forget Completeness** (REQ-072)
284
+ - **Property 7: Memory Version Uniqueness** (REQ-073)
285
+ - **Property 8: Path Normalization Idempotency** (REQ-074)
286
+ - **Property 10: Graph Node Uniqueness** (REQ-023)
287
+ - Use a temp-file SQLite DB (`tmp_path` fixture) for each test; isolate with `@given` + `@settings(max_examples=50)`
288
+
289
+ - [x] 12. Final checkpoint — full test run
290
+ - Ensure all tests pass (`pytest tests/`), ask the user if questions arise.
291
+
292
+ ---
293
+
294
+ ## Notes
295
+
296
+ - Tasks marked with `*` are optional and can be skipped for a faster MVP
297
+ - Each task references specific requirements for traceability
298
+ - The migration endpoint (task 6.8) is non-destructive: old `mem:sessions` / `mem:obs:*` data is never deleted
299
+ - Property tests in `tests/test_properties.py` (task 11.7) consolidate all Hypothesis tests; the earlier inline property test tasks (1.3, 2.2–2.5, 3.3, 3.5, 4.2) are equivalent sub-sets and can be skipped if 11.7 is implemented
300
+ - `src/db.py` schema is unchanged — only KV scope strings change, not the SQLite table structure
301
+ - The `folderColor()` function referenced by the Graph tab and `folder_graph_build()` is the existing hash-to-HSL function already in `index.html`; it should be extracted or duplicated into `functions.py` for server-side use
302
+
303
+ ## Task Dependency Graph
304
+
305
+ ```json
306
+ {
307
+ "waves": [
308
+ { "id": 0, "tasks": ["1.1", "1.2"] },
309
+ { "id": 1, "tasks": ["1.3", "2.1"] },
310
+ { "id": 2, "tasks": ["2.2", "2.3", "2.4", "2.5", "3.1", "3.2", "4.1"] },
311
+ { "id": 3, "tasks": ["3.3", "3.4", "4.2", "4.3", "4.4", "4.5"] },
312
+ { "id": 4, "tasks": ["3.5", "6.1", "6.2", "6.3", "6.4", "6.5", "6.6", "6.8"] },
313
+ { "id": 5, "tasks": ["6.7", "6.9", "7.1", "7.2", "7.3"] },
314
+ { "id": 6, "tasks": ["7.4", "7.5", "9.1", "10.1", "10.2", "10.3"] },
315
+ { "id": 7, "tasks": ["9.2", "9.3", "9.4", "9.5"] },
316
+ { "id": 8, "tasks": ["9.6", "11.1", "11.2", "11.3", "11.4", "11.5", "11.6"] },
317
+ { "id": 9, "tasks": ["11.7"] }
318
+ ]
319
+ }
320
+ ```
requirements.txt CHANGED
@@ -4,3 +4,7 @@ requests>=2.31.0
4
  websockets>=12.0
5
  python-dateutil>=2.8.2
6
  huggingface_hub>=0.20.0
 
 
 
 
 
4
  websockets>=12.0
5
  python-dateutil>=2.8.2
6
  huggingface_hub>=0.20.0
7
+
8
+ # dev
9
+ pytest==8.3.5
10
+ hypothesis==6.131.15
src/app.py CHANGED
@@ -125,27 +125,8 @@ def init_app():
125
  print(f"[scheduler] auto_forget loop error: {e}")
126
  time.sleep(3600)
127
 
128
- def run_consolidation_loop():
129
- time.sleep(15)
130
- while True:
131
- try:
132
- print("[scheduler] Running lesson_decay_sweep...")
133
- decay_res = functions.lesson_decay_sweep(kv)
134
- print(f"[scheduler] lesson_decay_sweep completed: {decay_res}")
135
-
136
- if functions.is_consolidation_enabled():
137
- print("[scheduler] Running consolidation...")
138
- cons_res = functions.consolidate(kv)
139
- print(f"[scheduler] consolidation completed: {cons_res}")
140
- except Exception as e:
141
- print(f"[scheduler] consolidation/decay loop error: {e}")
142
- time.sleep(86400)
143
-
144
  t_forget = threading.Thread(target=run_auto_forget_loop, daemon=True)
145
  t_forget.start()
146
-
147
- t_consolidate = threading.Thread(target=run_consolidation_loop, daemon=True)
148
- t_consolidate.start()
149
 
150
  # =====================================================================
151
  # Auth Middleware
@@ -339,44 +320,36 @@ def api_agent_observe():
339
  auth_err = check_auth()
340
  if auth_err:
341
  return auth_err
342
-
343
  try:
344
  body = request.get_json(force=True) or {}
 
345
  agent_id = body.get("agentId")
346
- session_id = body.get("sessionId")
347
- project = body.get("project")
348
- cwd = body.get("cwd") or ""
349
  text = body.get("text") or body.get("content") or ""
350
- obs_type = body.get("type") or "other"
351
- title = body.get("title") or f"agent_{obs_type}"
352
- image_data = body.get("imageData")
353
-
354
- if not session_id or not project:
355
- return jsonify({"error": "sessionId and project are required"}), 400
356
-
357
- timestamp = datetime_now_iso()
358
-
359
- data_payload = {
360
- "tool_name": title,
361
- "tool_input": text,
362
- "tool_output": text,
363
- }
364
- if image_data:
365
- data_payload["imageBase64"] = image_data
366
-
367
  payload = {
368
- "sessionId": session_id,
369
- "project": project,
370
- "cwd": cwd,
371
- "hookType": "post_tool_use",
372
  "timestamp": timestamp,
373
- "data": data_payload
 
 
 
 
374
  }
375
- if agent_id:
376
- payload["agentId"] = agent_id
377
-
378
- res = functions.observe(kv, payload)
379
  return jsonify(res), 201
 
 
380
  except Exception as e:
381
  return jsonify({"error": str(e)}), 400
382
 
@@ -399,17 +372,17 @@ def api_search():
399
  auth_err = check_auth()
400
  if auth_err:
401
  return auth_err
402
-
403
  try:
404
  body = request.get_json(force=True) or {}
405
  query = body.get("query")
406
  if not query or not query.strip():
407
  return jsonify({"error": "query is required"}), 400
408
  limit = body.get("limit") or 10
409
-
410
- # smart search / hybrid search query
411
- res = functions._hybrid_search.search(query, limit)
412
- res = enrich_search_results(kv, res)
413
  return jsonify(res), 200
414
  except Exception as e:
415
  return jsonify({"error": str(e)}), 400
@@ -432,165 +405,27 @@ def api_export():
432
 
433
  @app.route("/agentmemory/replay/sessions", methods=["GET"])
434
  def api_replay_sessions():
435
- auth_err = check_auth()
436
- if auth_err:
437
- return auth_err
438
-
439
- sessions = functions.list_sessions(kv)
440
- return jsonify({"success": True, "sessions": sessions}), 200
441
 
442
  @app.route("/agentmemory/session/start", methods=["POST"])
443
  def api_session_start():
444
- auth_err = check_auth()
445
- if auth_err:
446
- return auth_err
447
-
448
- try:
449
- body = request.get_json(force=True) or {}
450
- session_id = body.get("sessionId")
451
- project = body.get("project")
452
- cwd = body.get("cwd")
453
- if not session_id or not project or not cwd:
454
- return jsonify({"error": "sessionId, project, and cwd are required"}), 400
455
-
456
- title = body.get("title")
457
- agent_id = body.get("agentId") or functions.get_agent_id()
458
-
459
- session = {
460
- "id": session_id,
461
- "project": project,
462
- "cwd": cwd,
463
- "startedAt": datetime_now_iso(),
464
- "status": "active",
465
- "observationCount": 0
466
- }
467
- if title:
468
- session["summary"] = title[:200]
469
- session["firstPrompt"] = title[:200]
470
- if agent_id:
471
- session["agentId"] = agent_id
472
-
473
- functions.create_session(kv, session)
474
-
475
- # Compile initial context
476
- ctx = functions.context(kv, {"sessionId": session_id, "project": project})
477
-
478
- # Commit to Dolt
479
- functions.commit_if_enabled(kv, f"Start session {session_id[:8]}", agent_id)
480
-
481
- return jsonify({"session": session, "context": ctx.get("context", "")}), 200
482
- except Exception as e:
483
- return jsonify({"error": str(e)}), 400
484
 
485
  @app.route("/agentmemory/antigravity/sync", methods=["POST"])
486
  def api_antigravity_sync():
487
- auth_err = check_auth()
488
- if auth_err:
489
- return auth_err
490
- try:
491
- body = request.get_json(force=True) or {}
492
- mode = body.get("mode") or "current_session"
493
- current_convo = body.get("currentConversationId")
494
- current_folder = body.get("currentFolder")
495
- res = perform_antigravity_sync(mode, current_convo, current_folder)
496
- return jsonify(res), 200
497
- except Exception as e:
498
- return jsonify({"error": str(e)}), 500
499
 
500
  @app.route("/agentmemory/session/end", methods=["POST"])
501
  def api_session_end():
502
- auth_err = check_auth()
503
- if auth_err:
504
- return auth_err
505
-
506
- try:
507
- body = request.get_json(force=True) or {}
508
- session_id = body.get("sessionId")
509
- if not session_id:
510
- return jsonify({"error": "sessionId is required"}), 400
511
-
512
- functions.end_session(kv, session_id)
513
-
514
- # Commit to Dolt
515
- sess = functions.get_session(kv, session_id) or {}
516
- agent_id = sess.get("agentId")
517
- functions.commit_if_enabled(kv, f"End session {session_id[:8]}", agent_id)
518
-
519
- return jsonify({"success": True}), 200
520
- except Exception as e:
521
- return jsonify({"error": str(e)}), 400
522
 
523
  @app.route("/agentmemory/session/commit", methods=["POST"])
524
  def api_session_commit():
525
- auth_err = check_auth()
526
- if auth_err:
527
- return auth_err
528
-
529
- try:
530
- body = request.get_json(force=True) or {}
531
- sha = body.get("sha")
532
- if not sha:
533
- return jsonify({"error": "sha is required"}), 400
534
-
535
- session_id = body.get("sessionId")
536
- branch = body.get("branch")
537
- repo = body.get("repo")
538
- message = body.get("message")
539
- author = body.get("author")
540
- authored_at = body.get("authoredAt")
541
- files = body.get("files")
542
-
543
- existing = kv.get(KV.commits, sha) or {}
544
- session_ids = set(existing.get("sessionIds", []))
545
- if session_id:
546
- session_ids.add(session_id)
547
-
548
- link = {
549
- "sha": sha,
550
- "shortSha": sha[:7],
551
- "branch": branch or existing.get("branch"),
552
- "repo": repo or existing.get("repo"),
553
- "message": message or existing.get("message"),
554
- "author": author or existing.get("author"),
555
- "authoredAt": authored_at or existing.get("authoredAt"),
556
- "files": files or existing.get("files"),
557
- "sessionIds": list(session_ids),
558
- "linkedAt": existing.get("linkedAt") or datetime_now_iso()
559
- }
560
- kv.set(KV.commits, sha, link)
561
-
562
- if session_id:
563
- sess = functions.get_session(kv, session_id)
564
- if sess:
565
- commit_shas = set(sess.get("commitShas", []))
566
- commit_shas.add(sha)
567
- sess["commitShas"] = list(commit_shas)
568
- functions.create_session(kv, sess)
569
-
570
- return jsonify({"commit": link}), 200
571
- except Exception as e:
572
- return jsonify({"error": str(e)}), 400
573
 
574
  @app.route("/agentmemory/session/by-commit", methods=["GET"])
575
  def api_session_by_commit():
576
- auth_err = check_auth()
577
- if auth_err:
578
- return auth_err
579
-
580
- sha = request.args.get("sha")
581
- if not sha:
582
- return jsonify({"error": "sha query param required"}), 400
583
-
584
- link = kv.get(KV.commits, sha)
585
- if not link:
586
- return jsonify({"error": "no sessions linked to this commit"}), 404
587
-
588
- sessions = []
589
- for sid in link.get("sessionIds", []):
590
- sess = functions.get_session(kv, sid)
591
- if sess:
592
- sessions.append(sess)
593
- return jsonify({"commit": link, "sessions": sessions}), 200
594
 
595
  @app.route("/agentmemory/commits", methods=["GET"])
596
  def api_commits():
@@ -614,20 +449,7 @@ def api_commits():
614
 
615
  @app.route("/agentmemory/sessions", methods=["GET"])
616
  def api_sessions():
617
- auth_err = check_auth()
618
- if auth_err:
619
- return auth_err
620
-
621
- sessions = functions.list_sessions(kv)
622
- agent_id = request.args.get("agentId")
623
- if agent_id and agent_id != "*":
624
- sessions = [s for s in sessions if s.get("agentId") == agent_id]
625
- elif functions.is_agent_scope_isolated():
626
- env_aid = functions.get_agent_id()
627
- if env_aid:
628
- sessions = [s for s in sessions if s.get("agentId") == env_aid]
629
-
630
- return jsonify({"sessions": sessions}), 200
631
 
632
  @app.route("/agentmemory/observations", methods=["GET"])
633
  def api_observations():
@@ -717,175 +539,128 @@ def api_forget():
717
  except Exception as e:
718
  return jsonify({"error": str(e)}), 400
719
 
720
- # =====================================================================
721
- # Lessons Learned Endpoints
722
- # =====================================================================
 
 
 
 
 
 
 
 
723
 
724
- @app.route("/agentmemory/lessons", methods=["GET"])
725
- def api_lessons_list():
 
726
  auth_err = check_auth()
727
  if auth_err:
728
  return auth_err
729
-
730
- project = request.args.get("project")
731
- source = request.args.get("source")
732
- min_conf = float(request.args.get("minConfidence", "0"))
733
- limit = int(request.args.get("limit", "50"))
734
-
735
- res = functions.lesson_list(kv, {
736
- "project": project,
737
- "source": source,
738
- "minConfidence": min_conf,
739
- "limit": limit
740
- })
741
- return jsonify(res), 200
742
 
743
- @app.route("/agentmemory/lessons", methods=["POST"])
744
- def api_lessons_save():
 
745
  auth_err = check_auth()
746
  if auth_err:
747
  return auth_err
748
-
749
  try:
750
  body = request.get_json(force=True) or {}
751
- res = functions.lesson_save(kv, body)
752
- return jsonify(res), 201 if res.get("action") == "created" else 200
 
 
 
 
 
753
  except Exception as e:
754
  return jsonify({"error": str(e)}), 400
755
 
756
- @app.route("/agentmemory/lessons/search", methods=["POST"])
757
- def api_lessons_search():
 
758
  auth_err = check_auth()
759
  if auth_err:
760
  return auth_err
761
-
762
- try:
763
- body = request.get_json(force=True) or {}
764
- res = functions.lesson_recall(kv, body)
765
- return jsonify(res), 200
766
- except Exception as e:
767
- return jsonify({"error": str(e)}), 400
768
 
769
- @app.route("/agentmemory/lessons/strengthen", methods=["POST"])
770
- def api_lessons_strengthen():
 
771
  auth_err = check_auth()
772
  if auth_err:
773
  return auth_err
774
-
775
  try:
776
  body = request.get_json(force=True) or {}
777
- lesson_id = body.get("lessonId")
778
- if not lesson_id:
779
- return jsonify({"error": "lessonId is required"}), 400
780
- res = functions.lesson_strengthen(kv, lesson_id)
781
- return jsonify(res), 200
782
  except Exception as e:
783
  return jsonify({"error": str(e)}), 400
784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
785
  # =====================================================================
786
- # Memory Slots Endpoints
787
  # =====================================================================
788
 
789
  @app.route("/agentmemory/slots", methods=["GET"])
790
  def api_slots_list():
791
- auth_err = check_auth()
792
- if auth_err:
793
- return auth_err
794
-
795
- return jsonify(functions.slot_list(kv)), 200
796
 
797
  @app.route("/agentmemory/slot", methods=["GET"])
798
  def api_slots_get():
799
- auth_err = check_auth()
800
- if auth_err:
801
- return auth_err
802
-
803
- label = request.args.get("label")
804
- if not label:
805
- return jsonify({"error": "label required"}), 400
806
- res = functions.slot_get(kv, label)
807
- status = 200 if res.get("success") else 404
808
- return jsonify(res), status
809
 
810
  @app.route("/agentmemory/slot", methods=["POST"])
811
  def api_slots_create():
812
- auth_err = check_auth()
813
- if auth_err:
814
- return auth_err
815
-
816
- try:
817
- body = request.get_json(force=True) or {}
818
- res = functions.slot_create(kv, body)
819
- status = 201 if res.get("success") else 400
820
- return jsonify(res), status
821
- except Exception as e:
822
- return jsonify({"error": str(e)}), 400
823
 
824
  @app.route("/agentmemory/slot/append", methods=["POST"])
825
  def api_slots_append():
826
- auth_err = check_auth()
827
- if auth_err:
828
- return auth_err
829
-
830
- try:
831
- body = request.get_json(force=True) or {}
832
- label = body.get("label")
833
- text = body.get("text")
834
- if not label or not text:
835
- return jsonify({"error": "label and text required"}), 400
836
- res = functions.slot_append(kv, label, text)
837
- status = 200 if res.get("success") else 400
838
- return jsonify(res), status
839
- except Exception as e:
840
- return jsonify({"error": str(e)}), 400
841
 
842
  @app.route("/agentmemory/slot/replace", methods=["POST"])
843
  def api_slots_replace():
844
- auth_err = check_auth()
845
- if auth_err:
846
- return auth_err
847
-
848
- try:
849
- body = request.get_json(force=True) or {}
850
- label = body.get("label")
851
- content = body.get("content")
852
- if not label or content is None:
853
- return jsonify({"error": "label and content required"}), 400
854
- res = functions.slot_replace(kv, label, content)
855
- status = 200 if res.get("success") else 400
856
- return jsonify(res), status
857
- except Exception as e:
858
- return jsonify({"error": str(e)}), 400
859
 
860
  @app.route("/agentmemory/slot", methods=["DELETE"])
861
  def api_slots_delete():
862
- auth_err = check_auth()
863
- if auth_err:
864
- return auth_err
865
-
866
- label = request.args.get("label")
867
- if not label:
868
- return jsonify({"error": "label query param required"}), 400
869
- res = functions.slot_delete(kv, label)
870
- status = 200 if res.get("success") else 404
871
- return jsonify(res), status
872
 
873
  @app.route("/agentmemory/slot/reflect", methods=["POST"])
874
  def api_slots_reflect():
875
- auth_err = check_auth()
876
- if auth_err:
877
- return auth_err
878
-
879
- try:
880
- body = request.get_json(force=True) or {}
881
- session_id = body.get("sessionId")
882
- if not session_id:
883
- return jsonify({"error": "sessionId required"}), 400
884
- max_obs = body.get("maxObservations") or 50
885
- res = functions.slot_reflect(kv, session_id, max_obs)
886
- return jsonify(res), 200
887
- except Exception as e:
888
- return jsonify({"error": str(e)}), 400
889
 
890
  # =====================================================================
891
  # Audit, Relations, Evolve, Timeline, Profile
@@ -938,17 +713,9 @@ def api_evolve():
938
  return jsonify({"error": str(e)}), 400
939
 
940
  @app.route("/agentmemory/timeline", methods=["POST"])
941
- def api_timeline():
942
- auth_err = check_auth()
943
- if auth_err:
944
- return auth_err
945
-
946
- try:
947
- body = request.get_json(force=True) or {}
948
- res = functions.timeline(kv, body)
949
- return jsonify(res), 200
950
- except Exception as e:
951
- return jsonify({"error": str(e)}), 400
952
 
953
  @app.route("/agentmemory/profile", methods=["GET"])
954
  def api_profile():
@@ -991,9 +758,83 @@ def api_graph_stats():
991
  auth_err = check_auth()
992
  if auth_err:
993
  return auth_err
994
- nodes = kv.list(KV.graphNodes)
995
- edges = kv.list(KV.graphEdges)
996
- return jsonify({"nodes": len(nodes), "edges": len(edges), "success": True}), 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
997
 
998
  @app.route("/agentmemory/semantic", methods=["GET"])
999
  def api_semantic_list():
@@ -1629,9 +1470,10 @@ def perform_antigravity_sync(mode="current_session", current_conversation_id=Non
1629
  return {"success": False, "syncedSessions": [], "observationsAdded": 0, "error": f"Invalid mode: {mode}"}
1630
 
1631
  if not targets:
1632
- return {"success": True, "syncedSessions": [], "observationsAdded": 0}
1633
 
1634
  synced_sessions = []
 
1635
  observations_added = 0
1636
 
1637
  for convo in targets:
@@ -1737,10 +1579,11 @@ def perform_antigravity_sync(mode="current_session", current_conversation_id=Non
1737
  observations_added += 1
1738
  convo_synced = True
1739
 
 
1740
  if convo_synced:
1741
  synced_sessions.append(convo_id)
1742
 
1743
- return {"success": True, "syncedSessions": synced_sessions, "observationsAdded": observations_added}
1744
 
1745
  @app.route("/agentmemory/mcp/tools", methods=["GET"])
1746
  def mcp_tools_list():
@@ -1751,12 +1594,28 @@ def mcp_tools_list():
1751
  tools = [
1752
  {
1753
  "name": "memory_recall",
1754
- "description": "Search past session observations for relevant context. Use when you need to recall what happened in previous sessions.",
1755
  "inputSchema": {
1756
  "type": "object",
1757
  "properties": {
1758
  "query": {"type": "string", "description": "Search query keywords"},
1759
- "limit": {"type": "number", "description": "Max results to return (default 10)"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1760
  },
1761
  "required": ["query"]
1762
  }
@@ -1786,146 +1645,24 @@ def mcp_tools_list():
1786
  "required": ["content"]
1787
  }
1788
  },
1789
- {
1790
- "name": "memory_sessions",
1791
- "description": "List recent sessions with their status and observation counts.",
1792
- "inputSchema": {"type": "object", "properties": {}}
1793
- },
1794
- {
1795
- "name": "memory_sessions_list",
1796
- "description": "Retrieve list of all memory sessions.",
1797
- "inputSchema": {"type": "object", "properties": {}}
1798
- },
1799
- {
1800
- "name": "memory_smart_search",
1801
- "description": "Hybrid semantic+keyword search with progressive disclosure.",
1802
- "inputSchema": {
1803
- "type": "object",
1804
- "properties": {
1805
- "query": {"type": "string", "description": "Search query"},
1806
- "limit": {"type": "number", "description": "Max results (default 10)"}
1807
- },
1808
- "required": ["query"]
1809
- }
1810
- },
1811
- {
1812
- "name": "memory_timeline",
1813
- "description": "Chronological observations around an anchor point.",
1814
- "inputSchema": {
1815
- "type": "object",
1816
- "properties": {
1817
- "anchor": {"type": "string", "description": "Anchor point date or keyword"},
1818
- "project": {"type": "string", "description": "Filter by project path"},
1819
- "sessionId": {"type": "string", "description": "Filter by session ID"}
1820
- },
1821
- "required": ["anchor"]
1822
- }
1823
- },
1824
- {
1825
- "name": "memory_observations",
1826
- "description": "Retrieve all observations for a given session ID.",
1827
- "inputSchema": {
1828
- "type": "object",
1829
- "properties": {
1830
- "sessionId": {"type": "string", "description": "Session ID to fetch observations for"}
1831
- },
1832
- "required": ["sessionId"]
1833
- }
1834
- },
1835
- {
1836
- "name": "memory_profile",
1837
- "description": "User/project profile with top concepts and file patterns.",
1838
- "inputSchema": {
1839
- "type": "object",
1840
- "properties": {
1841
- "project": {"type": "string", "description": "Project path"}
1842
- },
1843
- "required": ["project"]
1844
- }
1845
- },
1846
- {
1847
- "name": "memory_lessons",
1848
- "description": "List all saved lessons, optionally filtered by project.",
1849
- "inputSchema": {
1850
- "type": "object",
1851
- "properties": {
1852
- "project": {"type": "string", "description": "Filter by project (optional)"},
1853
- "minConfidence": {"type": "number", "description": "Filter by minimum confidence (optional, default 0.0)"},
1854
- "limit": {"type": "number", "description": "Max results to return (optional, default 50)"}
1855
- }
1856
- }
1857
- },
1858
- {
1859
- "name": "memory_lesson_save",
1860
- "description": "Save a lesson learned from this session.",
1861
- "inputSchema": {
1862
- "type": "object",
1863
- "properties": {
1864
- "content": {"type": "string", "description": "The lesson learned"},
1865
- "context": {"type": "string", "description": "When/where this lesson applies"},
1866
- "project": {"type": "string", "description": "Project this lesson is about"}
1867
- },
1868
- "required": ["content"]
1869
- }
1870
- },
1871
- {
1872
- "name": "memory_lesson_recall",
1873
- "description": "Search lessons by query. Returns lessons sorted by confidence and recency.",
1874
- "inputSchema": {
1875
- "type": "object",
1876
- "properties": {
1877
- "query": {"type": "string", "description": "Search query"},
1878
- "project": {"type": "string", "description": "Filter by project"}
1879
- },
1880
- "required": ["query"]
1881
- }
1882
- },
1883
- {
1884
- "name": "memory_lesson_search",
1885
- "description": "Search lessons learned by query.",
1886
- "inputSchema": {
1887
- "type": "object",
1888
- "properties": {
1889
- "query": {"type": "string", "description": "Search query keywords"},
1890
- "project": {"type": "string", "description": "Filter by project path (optional)"}
1891
- },
1892
- "required": ["query"]
1893
- }
1894
- },
1895
- {
1896
- "name": "memory_consolidate",
1897
- "description": "Trigger the consolidation pipeline to summarize sessions and extract semantic/procedural memory.",
1898
- "inputSchema": {"type": "object", "properties": {}}
1899
- },
1900
- {
1901
- "name": "memory_reflect",
1902
- "description": "Trigger reflection for a session, updating pending items, project context, and session patterns.",
1903
- "inputSchema": {
1904
- "type": "object",
1905
- "properties": {
1906
- "sessionId": {"type": "string", "description": "Session ID to reflect upon"},
1907
- "maxObservations": {"type": "number", "description": "Max observations to scan (optional, default 50)"}
1908
- },
1909
- "required": ["sessionId"]
1910
- }
1911
- },
1912
  {
1913
  "name": "memory_diagnose",
1914
- "description": "Run diagnostic health checks across all memory subsystems.",
1915
  "inputSchema": {"type": "object", "properties": {}}
1916
  },
1917
  {
1918
  "name": "memory_forget",
1919
- "description": "Delete a memory, a session, or specific observations within a session.",
1920
  "inputSchema": {
1921
  "type": "object",
1922
  "properties": {
1923
  "memoryId": {"type": "string", "description": "Memory ID to delete"},
1924
- "sessionId": {"type": "string", "description": "Session ID to delete"},
 
1925
  "observationIds": {
1926
  "oneOf": [
1927
  {"type": "string", "description": "Comma-separated observation IDs to delete"},
1928
- {"type": "array", "items": {"type": "string"}, "description": "List of observation IDs to delete"}
1929
  ]
1930
  }
1931
  }
@@ -1933,208 +1670,84 @@ def mcp_tools_list():
1933
  },
1934
  {
1935
  "name": "memory_export",
1936
- "description": "Export all memory data including sessions, memories, lessons, observations, and slots as JSON.",
1937
- "inputSchema": {
1938
- "type": "object",
1939
- "properties": {
1940
- "maxSessions": {"type": "number", "description": "Max sessions to export (optional)"},
1941
- "offset": {"type": "number", "description": "Pagination offset for sessions (optional)"}
1942
- }
1943
- }
1944
  },
1945
  {
1946
  "name": "agent_observe",
1947
- "description": "Log a direct observation, thought, command execution, or action from the agent's active execution.",
1948
  "inputSchema": {
1949
  "type": "object",
1950
  "properties": {
1951
- "agentId": {"type": "string", "description": "ID/Name of the agent logging this (e.g. 'antigravity', optional)"},
1952
- "sessionId": {"type": "string", "description": "Active session ID"},
1953
- "project": {"type": "string", "description": "Canonical project path/identifier"},
1954
- "text": {"type": "string", "description": "The observation log, thought, or content"},
1955
- "content": {"type": "string", "description": "The observation log, thought, or content (alternative to text)"},
1956
- "type": {"type": "string", "description": "Observation type: thought, command, tool, error, result, conversation, other"},
1957
- "title": {"type": "string", "description": "A short summary title for the observation"},
1958
- "cwd": {"type": "string", "description": "Current working directory"}
 
 
1959
  },
1960
- "required": ["sessionId", "project"]
1961
  }
1962
  },
1963
  {
1964
  "name": "agent_remember",
1965
- "description": "Explicitly save a key insight, fact, user preference, or architecture decision to long-term memory.",
1966
  "inputSchema": {
1967
  "type": "object",
1968
  "properties": {
1969
- "agentId": {"type": "string", "description": "ID/Name of the agent (e.g. 'antigravity', optional)"},
1970
  "content": {"type": "string", "description": "The memory content/insight"},
1971
  "project": {"type": "string", "description": "Canonical project path/identifier"},
1972
  "type": {"type": "string", "description": "Memory type: fact, preference, bug, workflow, architecture"},
1973
  "concepts": {
1974
  "oneOf": [
1975
  {"type": "string", "description": "Comma-separated key concepts"},
1976
- {"type": "array", "items": {"type": "string"}, "description": "List of key concepts"}
1977
  ]
1978
  },
1979
  "files": {
1980
  "oneOf": [
1981
  {"type": "string", "description": "Comma-separated relevant file paths"},
1982
- {"type": "array", "items": {"type": "string"}, "description": "List of relevant file paths"}
1983
  ]
1984
  }
1985
  },
1986
- "required": ["content", "project"]
1987
- }
1988
- },
1989
- {
1990
- "name": "memory_antigravity_sync",
1991
- "description": "Sync Antigravity chat transcripts to agentmemory. Supports syncing the current session, all sessions, or sessions associated with the current folder.",
1992
- "inputSchema": {
1993
- "type": "object",
1994
- "properties": {
1995
- "mode": {"type": "string", "description": "Sync mode: current_session (default), current_folder, or all"},
1996
- "currentConversationId": {"type": "string", "description": "Optional conversation ID of the current active session"},
1997
- "currentFolder": {"type": "string", "description": "Optional current folder path to filter by"}
1998
- },
1999
- "required": ["mode"]
2000
- }
2001
- },
2002
- {
2003
- "name": "memory_antigravity_sync_all",
2004
- "description": "Sync the current Antigravity session, automatically crystallize (summarize) it, and reflect to populate pinned memory slots in a single action.",
2005
- "inputSchema": {
2006
- "type": "object",
2007
- "properties": {
2008
- "mode": {"type": "string", "description": "Sync mode: current_session (default), current_folder, or all"},
2009
- "currentConversationId": {"type": "string", "description": "Optional conversation ID of the current active session"},
2010
- "currentFolder": {"type": "string", "description": "Optional current folder path to filter by"}
2011
- },
2012
- "required": ["mode"]
2013
  }
2014
  },
2015
  {
2016
- "name": "memory_slot_list",
2017
- "description": "List all pinned memory slots.",
2018
  "inputSchema": {"type": "object", "properties": {}}
2019
  },
2020
  {
2021
- "name": "memory_slot_get",
2022
- "description": "Retrieve the content of a specific pinned memory slot.",
2023
- "inputSchema": {
2024
- "type": "object",
2025
- "properties": {
2026
- "label": {"type": "string", "description": "The label of the pinned slot to fetch"}
2027
- },
2028
- "required": ["label"]
2029
- }
2030
- },
2031
- {
2032
- "name": "memory_slot_create",
2033
- "description": "Create a new pinned memory slot or overwrite an existing one.",
2034
- "inputSchema": {
2035
- "type": "object",
2036
- "properties": {
2037
- "label": {"type": "string", "description": "The label of the pinned slot"},
2038
- "content": {"type": "string", "description": "Initial content for the slot (optional)"},
2039
- "scope": {"type": "string", "description": "Scope: global or session (optional, default 'global')"},
2040
- "sizeLimit": {"type": "number", "description": "Character limit (optional)"},
2041
- "pinned": {"type": "boolean", "description": "Whether pinned to context (optional, default true)"}
2042
- },
2043
- "required": ["label"]
2044
- }
2045
- },
2046
- {
2047
- "name": "memory_slot_append",
2048
- "description": "Append text content to a pinned memory slot.",
2049
- "inputSchema": {
2050
- "type": "object",
2051
- "properties": {
2052
- "label": {"type": "string", "description": "The label of the pinned slot"},
2053
- "text": {"type": "string", "description": "Text to append"}
2054
- },
2055
- "required": ["label", "text"]
2056
- }
2057
- },
2058
- {
2059
- "name": "memory_slot_replace",
2060
- "description": "Replace the content of a pinned memory slot.",
2061
- "inputSchema": {
2062
- "type": "object",
2063
- "properties": {
2064
- "label": {"type": "string", "description": "The label of the pinned slot"},
2065
- "content": {"type": "string", "description": "New content"}
2066
- },
2067
- "required": ["label", "content"]
2068
- }
2069
- },
2070
- {
2071
- "name": "memory_slot_delete",
2072
- "description": "Delete a pinned memory slot.",
2073
- "inputSchema": {
2074
- "type": "object",
2075
- "properties": {
2076
- "label": {"type": "string", "description": "The label of the pinned slot to delete"}
2077
- },
2078
- "required": ["label"]
2079
- }
2080
- },
2081
- {
2082
- "name": "memory_action_create",
2083
- "description": "Create a new work item / action.",
2084
  "inputSchema": {
2085
  "type": "object",
2086
  "properties": {
2087
- "title": {"type": "string", "description": "Title of the action"},
2088
- "description": {"type": "string", "description": "Detailed description of the action (optional)"},
2089
- "priority": {"type": "number", "description": "Priority score, higher is more urgent (optional, default 0)"},
2090
- "status": {"type": "string", "description": "Status: pending, active, completed (optional, default 'pending')"},
2091
- "tags": {
2092
- "oneOf": [
2093
- {"type": "string", "description": "Comma-separated tags (optional)"},
2094
- {"type": "array", "items": {"type": "string"}, "description": "List of tags (optional)"}
2095
- ]
2096
- },
2097
- "sessionId": {"type": "string", "description": "Link to a specific session ID (optional)"}
2098
  },
2099
- "required": ["title"]
2100
  }
2101
  },
2102
  {
2103
- "name": "memory_action_update",
2104
- "description": "Update fields of an existing action.",
2105
- "inputSchema": {
2106
- "type": "object",
2107
- "properties": {
2108
- "actionId": {"type": "string", "description": "ID of the action to update"},
2109
- "title": {"type": "string", "description": "Updated title (optional)"},
2110
- "description": {"type": "string", "description": "Updated description (optional)"},
2111
- "priority": {"type": "number", "description": "Updated priority (optional)"},
2112
- "status": {"type": "string", "description": "Updated status: pending, active, completed, discarded (optional)"},
2113
- "tags": {
2114
- "oneOf": [
2115
- {"type": "string", "description": "Comma-separated tags (optional)"},
2116
- {"type": "array", "items": {"type": "string"}, "description": "List of tags (optional)"}
2117
- ]
2118
- },
2119
- "sessionId": {"type": "string", "description": "Updated session ID (optional)"}
2120
- },
2121
- "required": ["actionId"]
2122
- }
2123
- },
2124
- {
2125
- "name": "memory_frontier",
2126
- "description": "Get pending and active actions sorted by priority.",
2127
- "inputSchema": {"type": "object", "properties": {}}
2128
- },
2129
- {
2130
- "name": "memory_crystallize",
2131
- "description": "Crystallize/summarize all observations in a session.",
2132
  "inputSchema": {
2133
  "type": "object",
2134
  "properties": {
2135
- "sessionId": {"type": "string", "description": "Session ID to crystallize"}
2136
- },
2137
- "required": ["sessionId"]
 
 
 
2138
  }
2139
  }
2140
  ]
@@ -2160,15 +1773,15 @@ def mcp_tools_call():
2160
  if name == "memory_recall":
2161
  q = args.get("query")
2162
  limit = int(args.get("limit") or 10)
2163
- res = functions._hybrid_search.search(q, limit)
2164
- res = enrich_search_results(kv, res)
 
2165
  text_out = json.dumps(res, indent=2)
2166
 
2167
  elif name == "memory_save":
2168
  content = args.get("content")
2169
  concepts = parse_mcp_list_arg(args.get("concepts"))
2170
  files = parse_mcp_list_arg(args.get("files"))
2171
- session_id = args.get("sessionId")
2172
  project = args.get("project")
2173
  res = functions.remember(kv, {
2174
  "content": content,
@@ -2177,88 +1790,14 @@ def mcp_tools_call():
2177
  "files": files,
2178
  "project": project
2179
  })
2180
- # If sessionId provided, also write observation so memory is linked to session
2181
- if session_id and project and content:
2182
- obs_payload = {
2183
- "sessionId": session_id,
2184
- "project": project,
2185
- "cwd": "",
2186
- "hookType": "post_tool_use",
2187
- "timestamp": datetime_now_iso(),
2188
- "agentId": functions.get_agent_id() or "agent",
2189
- "data": {
2190
- "tool_name": "memory_save",
2191
- "tool_input": content[:500],
2192
- "tool_output": res.get("id", ""),
2193
- }
2194
- }
2195
- functions.observe(kv, obs_payload)
2196
  text_out = json.dumps(res)
2197
 
2198
- elif name in ("memory_sessions", "memory_sessions_list"):
2199
- sessions = functions.list_sessions(kv)
2200
- text_out = json.dumps({"sessions": sessions}, indent=2)
2201
-
2202
  elif name == "memory_smart_search":
2203
  q = args.get("query")
2204
  limit = int(args.get("limit") or 10)
2205
- res = functions._hybrid_search.search(q, limit)
2206
- res = enrich_search_results(kv, res)
2207
- text_out = json.dumps(res, indent=2)
2208
-
2209
- elif name == "memory_timeline":
2210
- res = functions.timeline(kv, {
2211
- "anchor": args.get("anchor"),
2212
- "project": args.get("project"),
2213
- "sessionId": args.get("sessionId")
2214
- })
2215
- text_out = json.dumps(res, indent=2)
2216
-
2217
- elif name == "memory_observations":
2218
- session_id = args.get("sessionId")
2219
- if not session_id:
2220
- return jsonify({"error": "sessionId is required"}), 400
2221
- obs = kv.list(KV.observations(session_id))
2222
- obs.sort(key=lambda o: o.get("timestamp", ""))
2223
- text_out = json.dumps({"observations": obs}, indent=2)
2224
-
2225
- elif name == "memory_profile":
2226
- res = functions.build_project_profile(kv, args.get("project"))
2227
- text_out = json.dumps(res, indent=2)
2228
-
2229
- elif name == "memory_lessons":
2230
- res = functions.lesson_list(kv, {
2231
- "project": args.get("project"),
2232
- "minConfidence": args.get("minConfidence"),
2233
- "limit": args.get("limit")
2234
- })
2235
- text_out = json.dumps(res, indent=2)
2236
-
2237
- elif name == "memory_lesson_save":
2238
- res = functions.lesson_save(kv, {
2239
- "content": args.get("content"),
2240
- "context": args.get("context"),
2241
- "project": args.get("project")
2242
- })
2243
- text_out = json.dumps(res)
2244
-
2245
- elif name in ("memory_lesson_recall", "memory_lesson_search"):
2246
- res = functions.lesson_recall(kv, {
2247
- "query": args.get("query"),
2248
- "project": args.get("project")
2249
- })
2250
- text_out = json.dumps(res, indent=2)
2251
-
2252
- elif name == "memory_consolidate":
2253
- res = functions.consolidate(kv)
2254
- text_out = json.dumps(res, indent=2)
2255
-
2256
- elif name == "memory_reflect":
2257
- session_id = args.get("sessionId")
2258
- max_obs = int(args.get("maxObservations") or 50)
2259
- if not session_id:
2260
- return jsonify({"error": "sessionId is required"}), 400
2261
- res = functions.slot_reflect(kv, session_id, max_obs)
2262
  text_out = json.dumps(res, indent=2)
2263
 
2264
  elif name == "memory_diagnose":
@@ -2269,221 +1808,69 @@ def mcp_tools_call():
2269
  obs_ids = parse_mcp_list_arg(args.get("observationIds"))
2270
  res = functions.forget(kv, {
2271
  "memoryId": args.get("memoryId"),
2272
- "sessionId": args.get("sessionId"),
 
2273
  "observationIds": obs_ids
2274
  })
2275
  text_out = json.dumps(res, indent=2)
2276
 
2277
  elif name == "memory_export":
2278
- max_sess = args.get("maxSessions")
2279
- offset = args.get("offset")
2280
- payload = {}
2281
- if max_sess is not None: payload["maxSessions"] = max_sess
2282
- if offset is not None: payload["offset"] = offset
2283
- res = functions.export_data(kv, payload)
2284
  text_out = json.dumps(res, indent=2)
2285
 
2286
  elif name == "agent_observe":
2287
- agent_id = args.get("agentId") or functions.get_agent_id() or "agent"
2288
- session_id = args.get("sessionId")
2289
- project = args.get("project")
2290
- text = args.get("text") or args.get("content")
2291
- obs_type = args.get("type") or "other"
2292
- title = args.get("title") or f"agent_{obs_type}"
2293
- cwd = args.get("cwd") or ""
2294
-
2295
- if not session_id or not project or not text:
2296
- return jsonify({"error": "sessionId, project, and text (or content) are required"}), 400
2297
-
2298
  payload = {
2299
- "sessionId": session_id,
2300
- "project": project,
2301
- "cwd": cwd,
2302
- "hookType": "post_tool_use",
2303
- "timestamp": datetime_now_iso(),
2304
- "agentId": agent_id,
2305
- "data": {
2306
- "tool_name": title,
2307
- "tool_input": text,
2308
- "tool_output": text,
2309
- }
2310
  }
2311
- res = functions.observe(kv, payload)
2312
  text_out = json.dumps(res)
2313
 
2314
  elif name == "agent_remember":
2315
  agent_id = args.get("agentId") or functions.get_agent_id() or "agent"
2316
  content = args.get("content")
2317
  project = args.get("project")
2318
- session_id = args.get("sessionId")
2319
  mem_type = args.get("type") or "fact"
2320
  concepts = parse_mcp_list_arg(args.get("concepts"))
2321
  files = parse_mcp_list_arg(args.get("files"))
2322
-
2323
- if not content or not project:
2324
- return jsonify({"error": "content and project are required"}), 400
2325
-
2326
  payload = {
2327
- "content": content,
2328
- "type": mem_type,
2329
- "concepts": concepts,
2330
- "files": files,
2331
- "project": project,
2332
- "agentId": agent_id
2333
  }
2334
  res = functions.remember(kv, payload)
2335
- # If sessionId provided, write observation to link memory to session
2336
- if session_id and content:
2337
- obs_payload = {
2338
- "sessionId": session_id,
2339
- "project": project,
2340
- "cwd": "",
2341
- "hookType": "post_tool_use",
2342
- "timestamp": datetime_now_iso(),
2343
- "agentId": agent_id,
2344
- "data": {
2345
- "tool_name": "agent_remember",
2346
- "tool_input": content[:500],
2347
- "tool_output": res.get("id", ""),
2348
- }
2349
- }
2350
- functions.observe(kv, obs_payload)
2351
  text_out = json.dumps(res)
2352
-
2353
- elif name == "memory_antigravity_sync":
2354
- mode = args.get("mode") or "current_session"
2355
- current_convo = args.get("currentConversationId")
2356
- current_folder = args.get("currentFolder")
2357
- res = perform_antigravity_sync(mode, current_convo, current_folder)
2358
- text_out = json.dumps(res)
2359
-
2360
- elif name == "memory_antigravity_sync_all":
2361
- mode = args.get("mode") or "current_session"
2362
- current_convo = args.get("currentConversationId")
2363
- current_folder = args.get("currentFolder")
2364
- sync_res = perform_antigravity_sync(mode, current_convo, current_folder)
2365
-
2366
- synced_sessions = sync_res.get("syncedSessions") or []
2367
- crystallizations = {}
2368
- reflections = {}
2369
-
2370
- for cid in synced_sessions:
2371
- session_id = f"antigravity_{cid[:18].replace('-', '_')}"
2372
-
2373
- try:
2374
- cres = functions.summarize(kv, {"sessionId": session_id})
2375
- crystallizations[session_id] = cres
2376
- except Exception as ex:
2377
- crystallizations[session_id] = {"success": False, "error": str(ex)}
2378
-
2379
- try:
2380
- rres = functions.slot_reflect(kv, session_id, 50)
2381
- reflections[session_id] = rres
2382
- except Exception as ex:
2383
- reflections[session_id] = {"success": False, "error": str(ex)}
2384
-
2385
- text_out = json.dumps({
2386
- "success": sync_res.get("success", True),
2387
- "syncedSessions": synced_sessions,
2388
- "observationsAdded": sync_res.get("observationsAdded", 0),
2389
- "crystallizations": crystallizations,
2390
- "reflections": reflections
2391
- }, indent=2)
2392
-
2393
- elif name == "memory_slot_list":
2394
- res = functions.slot_list(kv)
2395
- text_out = json.dumps(res, indent=2)
2396
-
2397
- elif name == "memory_slot_get":
2398
- label = args.get("label")
2399
- if not label:
2400
- return jsonify({"error": "label is required"}), 400
2401
- res = functions.slot_get(kv, label)
2402
- text_out = json.dumps(res, indent=2)
2403
-
2404
- elif name == "memory_slot_create":
2405
- label = args.get("label")
2406
- if not label:
2407
- return jsonify({"error": "label is required"}), 400
2408
- res = functions.slot_create(kv, {
2409
- "label": label,
2410
- "content": args.get("content"),
2411
- "scope": args.get("scope") or "global",
2412
- "sizeLimit": args.get("sizeLimit"),
2413
- "pinned": args.get("pinned", True)
2414
- })
2415
- text_out = json.dumps(res, indent=2)
2416
-
2417
- elif name == "memory_slot_append":
2418
- label = args.get("label")
2419
- text = args.get("text")
2420
- if not label or not text:
2421
- return jsonify({"error": "label and text are required"}), 400
2422
- res = functions.slot_append(kv, label, text)
2423
- text_out = json.dumps(res, indent=2)
2424
-
2425
- elif name == "memory_slot_replace":
2426
- label = args.get("label")
2427
- content = args.get("content")
2428
- if not label or content is None:
2429
- return jsonify({"error": "label and content are required"}), 400
2430
- res = functions.slot_replace(kv, label, content)
2431
- text_out = json.dumps(res, indent=2)
2432
-
2433
- elif name == "memory_slot_delete":
2434
- label = args.get("label")
2435
- if not label:
2436
- return jsonify({"error": "label is required"}), 400
2437
- res = functions.slot_delete(kv, label)
2438
- text_out = json.dumps(res, indent=2)
2439
-
2440
- elif name == "memory_action_create":
2441
- action_id = functions.generate_id("act")
2442
- from datetime import datetime, timezone
2443
- now = datetime.now(timezone.utc).isoformat()
2444
- tags = parse_mcp_list_arg(args.get("tags"))
2445
- action = {
2446
- "id": action_id,
2447
- "title": args.get("title") or "",
2448
- "description": args.get("description"),
2449
- "priority": args.get("priority", 0),
2450
- "status": args.get("status", "pending"),
2451
- "tags": tags,
2452
- "sessionId": args.get("sessionId"),
2453
- "createdAt": now,
2454
- "updatedAt": now,
2455
- }
2456
- kv.set(KV.actions, action_id, action)
2457
- text_out = json.dumps({"action": action, "success": True}, indent=2)
2458
-
2459
- elif name == "memory_action_update":
2460
- action_id = args.get("actionId")
2461
- if not action_id:
2462
- return jsonify({"error": "actionId is required"}), 400
2463
- existing = kv.get(KV.actions, action_id)
2464
- if not existing:
2465
- return jsonify({"error": "action not found"}), 404
2466
- from datetime import datetime, timezone
2467
- allowed_fields = {"title", "description", "priority", "status", "tags", "sessionId"}
2468
- updates = {k: v for k, v in args.items() if k in allowed_fields}
2469
- if "tags" in args:
2470
- updates["tags"] = parse_mcp_list_arg(args.get("tags"))
2471
- existing.update(updates)
2472
- existing["updatedAt"] = datetime.now(timezone.utc).isoformat()
2473
- kv.set(KV.actions, action_id, existing)
2474
- text_out = json.dumps({"action": existing, "success": True}, indent=2)
2475
-
2476
- elif name == "memory_frontier":
2477
- items = kv.list(KV.actions)
2478
- frontier = [a for a in items if a.get("status") in ("pending", "active")]
2479
- frontier.sort(key=lambda a: (-(a.get("priority") or 0), a.get("createdAt", "")))
2480
- text_out = json.dumps({"frontier": frontier[:50], "total": len(frontier)}, indent=2)
2481
-
2482
- elif name == "memory_crystallize":
2483
- session_id = args.get("sessionId")
2484
- if not session_id:
2485
- return jsonify({"error": "sessionId is required"}), 400
2486
- res = functions.summarize(kv, {"sessionId": session_id})
2487
  text_out = json.dumps(res, indent=2)
2488
 
2489
  else:
 
125
  print(f"[scheduler] auto_forget loop error: {e}")
126
  time.sleep(3600)
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  t_forget = threading.Thread(target=run_auto_forget_loop, daemon=True)
129
  t_forget.start()
 
 
 
130
 
131
  # =====================================================================
132
  # Auth Middleware
 
320
  auth_err = check_auth()
321
  if auth_err:
322
  return auth_err
323
+
324
  try:
325
  body = request.get_json(force=True) or {}
326
+ folder_path = body.get("folderPath")
327
  agent_id = body.get("agentId")
 
 
 
328
  text = body.get("text") or body.get("content") or ""
329
+
330
+ if not folder_path or not agent_id or not text:
331
+ return jsonify({"error": "folderPath, agentId, and text are required"}), 400
332
+
333
+ # sessionId accepted but ignored (folder-based model)
334
+ # timestamp defaults to now UTC
335
+ timestamp = body.get("timestamp") or datetime_now_iso()
336
+
 
 
 
 
 
 
 
 
 
337
  payload = {
338
+ "folderPath": folder_path,
339
+ "agentId": agent_id,
340
+ "text": text,
 
341
  "timestamp": timestamp,
342
+ "type": body.get("type"),
343
+ "title": body.get("title"),
344
+ "concepts": body.get("concepts"),
345
+ "files": body.get("files"),
346
+ "importance": body.get("importance"),
347
  }
348
+
349
+ res = functions.folder_observe(kv, payload)
 
 
350
  return jsonify(res), 201
351
+ except ValueError as e:
352
+ return jsonify({"error": str(e)}), 400
353
  except Exception as e:
354
  return jsonify({"error": str(e)}), 400
355
 
 
372
  auth_err = check_auth()
373
  if auth_err:
374
  return auth_err
375
+
376
  try:
377
  body = request.get_json(force=True) or {}
378
  query = body.get("query")
379
  if not query or not query.strip():
380
  return jsonify({"error": "query is required"}), 400
381
  limit = body.get("limit") or 10
382
+ folder_path = body.get("folderPath")
383
+ agent_id = body.get("agentId")
384
+
385
+ res = functions.folder_search(kv, query, limit, folder_path=folder_path, agent_id=agent_id)
386
  return jsonify(res), 200
387
  except Exception as e:
388
  return jsonify({"error": str(e)}), 400
 
405
 
406
  @app.route("/agentmemory/replay/sessions", methods=["GET"])
407
  def api_replay_sessions():
408
+ return jsonify({"error": "Gone — session replay removed in folder-based memory model"}), 410
 
 
 
 
 
409
 
410
  @app.route("/agentmemory/session/start", methods=["POST"])
411
  def api_session_start():
412
+ return jsonify({"error": "This endpoint has been removed", "status": "gone"}), 410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
  @app.route("/agentmemory/antigravity/sync", methods=["POST"])
415
  def api_antigravity_sync():
416
+ return jsonify({"error": "This endpoint has been removed", "status": "gone"}), 410
 
 
 
 
 
 
 
 
 
 
 
417
 
418
  @app.route("/agentmemory/session/end", methods=["POST"])
419
  def api_session_end():
420
+ return jsonify({"error": "This endpoint has been removed", "status": "gone"}), 410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
 
422
  @app.route("/agentmemory/session/commit", methods=["POST"])
423
  def api_session_commit():
424
+ return jsonify({"error": "This endpoint has been removed", "status": "gone"}), 410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
 
426
  @app.route("/agentmemory/session/by-commit", methods=["GET"])
427
  def api_session_by_commit():
428
+ return jsonify({"error": "This endpoint has been removed", "status": "gone"}), 410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
 
430
  @app.route("/agentmemory/commits", methods=["GET"])
431
  def api_commits():
 
449
 
450
  @app.route("/agentmemory/sessions", methods=["GET"])
451
  def api_sessions():
452
+ return jsonify({"error": "This endpoint has been removed", "status": "gone"}), 410
 
 
 
 
 
 
 
 
 
 
 
 
 
453
 
454
  @app.route("/agentmemory/observations", methods=["GET"])
455
  def api_observations():
 
539
  except Exception as e:
540
  return jsonify({"error": str(e)}), 400
541
 
542
+ @app.route("/agentmemory/folders", methods=["GET"])
543
+ def api_folders():
544
+ auth_err = check_auth()
545
+ if auth_err:
546
+ return auth_err
547
+ folders = sorted(
548
+ kv.list(functions.KV.folders),
549
+ key=lambda x: x.get("lastUpdated", ""),
550
+ reverse=True
551
+ )
552
+ return jsonify({"folders": folders}), 200
553
 
554
+
555
+ @app.route("/agentmemory/folder/observations", methods=["GET"])
556
+ def api_folder_observations():
557
  auth_err = check_auth()
558
  if auth_err:
559
  return auth_err
560
+ fp = request.args.get("folderPath")
561
+ aid = request.args.get("agentId")
562
+ if not fp or not aid:
563
+ return jsonify({"error": "folderPath and agentId are required"}), 400
564
+ observations = sorted(
565
+ kv.list(functions.KV.folder_obs(fp, aid)),
566
+ key=lambda x: x.get("timestamp", ""),
567
+ reverse=True
568
+ )
569
+ return jsonify({"observations": observations, "folderPath": fp, "agentId": aid}), 200
 
 
 
570
 
571
+
572
+ @app.route("/agentmemory/timeline", methods=["POST"])
573
+ def api_timeline():
574
  auth_err = check_auth()
575
  if auth_err:
576
  return auth_err
 
577
  try:
578
  body = request.get_json(force=True) or {}
579
+ folder_path = body.get("folderPath")
580
+ agent_id = body.get("agentId")
581
+ limit = body.get("limit") or 100
582
+ before = body.get("before")
583
+ after = body.get("after")
584
+ result = functions.folder_timeline(kv, limit, folder_path, agent_id, before, after)
585
+ return jsonify({"observations": result}), 200
586
  except Exception as e:
587
  return jsonify({"error": str(e)}), 400
588
 
589
+
590
+ @app.route("/agentmemory/graph", methods=["GET"])
591
+ def api_graph():
592
  auth_err = check_auth()
593
  if auth_err:
594
  return auth_err
595
+ result = functions.folder_graph_build(kv)
596
+ return jsonify(result), 200
 
 
 
 
 
597
 
598
+
599
+ @app.route("/agentmemory/migrate", methods=["POST"])
600
+ def api_migrate():
601
  auth_err = check_auth()
602
  if auth_err:
603
  return auth_err
 
604
  try:
605
  body = request.get_json(force=True) or {}
606
+ dry_run = bool(body.get("dry_run", False))
607
+ result = functions.migrate_sessions_to_folders(kv, dry_run)
608
+ return jsonify(result), 200
 
 
609
  except Exception as e:
610
  return jsonify({"error": str(e)}), 400
611
 
612
+
613
+ # =====================================================================
614
+ # Lessons Learned Endpoints (stubbed — 410 Gone)
615
+ # =====================================================================
616
+
617
+ @app.route("/agentmemory/lessons", methods=["GET"])
618
+ def api_lessons_list():
619
+ return jsonify({"error": "Gone — lessons have been removed in the folder-based memory model"}), 410
620
+
621
+ @app.route("/agentmemory/lessons", methods=["POST"])
622
+ def api_lessons_save():
623
+ return jsonify({"error": "Gone — lessons have been removed in the folder-based memory model"}), 410
624
+
625
+ @app.route("/agentmemory/lessons/search", methods=["POST"])
626
+ def api_lessons_search():
627
+ return jsonify({"error": "Gone — lessons have been removed in the folder-based memory model"}), 410
628
+
629
+ @app.route("/agentmemory/lessons/strengthen", methods=["POST"])
630
+ def api_lessons_strengthen():
631
+ return jsonify({"error": "Gone — lessons have been removed in the folder-based memory model"}), 410
632
+
633
  # =====================================================================
634
+ # Memory Slots Endpoints (stubbed — 410 Gone)
635
  # =====================================================================
636
 
637
  @app.route("/agentmemory/slots", methods=["GET"])
638
  def api_slots_list():
639
+ return jsonify({"error": "Gone — slots have been removed in the folder-based memory model"}), 410
 
 
 
 
640
 
641
  @app.route("/agentmemory/slot", methods=["GET"])
642
  def api_slots_get():
643
+ return jsonify({"error": "Gone — slots have been removed in the folder-based memory model"}), 410
 
 
 
 
 
 
 
 
 
644
 
645
  @app.route("/agentmemory/slot", methods=["POST"])
646
  def api_slots_create():
647
+ return jsonify({"error": "Gone — slots have been removed in the folder-based memory model"}), 410
 
 
 
 
 
 
 
 
 
 
648
 
649
  @app.route("/agentmemory/slot/append", methods=["POST"])
650
  def api_slots_append():
651
+ return jsonify({"error": "Gone — slots have been removed in the folder-based memory model"}), 410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
 
653
  @app.route("/agentmemory/slot/replace", methods=["POST"])
654
  def api_slots_replace():
655
+ return jsonify({"error": "Gone — slots have been removed in the folder-based memory model"}), 410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
 
657
  @app.route("/agentmemory/slot", methods=["DELETE"])
658
  def api_slots_delete():
659
+ return jsonify({"error": "Gone — slots have been removed in the folder-based memory model"}), 410
 
 
 
 
 
 
 
 
 
660
 
661
  @app.route("/agentmemory/slot/reflect", methods=["POST"])
662
  def api_slots_reflect():
663
+ return jsonify({"error": "Gone — slots have been removed in the folder-based memory model"}), 410
 
 
 
 
 
 
 
 
 
 
 
 
 
664
 
665
  # =====================================================================
666
  # Audit, Relations, Evolve, Timeline, Profile
 
713
  return jsonify({"error": str(e)}), 400
714
 
715
  @app.route("/agentmemory/timeline", methods=["POST"])
716
+ def api_timeline_legacy():
717
+ # Duplicate route removed — now handled by the folder-based api_timeline above.
718
+ return jsonify({"error": "This endpoint has been removed", "status": "gone"}), 410
 
 
 
 
 
 
 
 
719
 
720
  @app.route("/agentmemory/profile", methods=["GET"])
721
  def api_profile():
 
758
  auth_err = check_auth()
759
  if auth_err:
760
  return auth_err
761
+
762
+ # Dynamically compute graph folder nodes and connection edges
763
+ sessions = functions.list_sessions(kv)
764
+ memories = kv.list(KV.memories)
765
+
766
+ folders = set()
767
+ concepts_by_folder = {}
768
+
769
+ for s in sessions:
770
+ project = s.get("project", "").strip()
771
+ if project:
772
+ folders.add(project)
773
+
774
+ for m in memories:
775
+ project = m.get("project", "").strip()
776
+ if project:
777
+ folders.add(project)
778
+ concepts = m.get("concepts", [])
779
+ if concepts:
780
+ if project not in concepts_by_folder:
781
+ concepts_by_folder[project] = set()
782
+ for c in concepts:
783
+ if isinstance(c, str):
784
+ concepts_by_folder[project].add(c.lower())
785
+
786
+ node_count = len(folders)
787
+ edge_count = 0
788
+ folder_list = list(folders)
789
+
790
+ for i in range(len(folder_list)):
791
+ for j in range(i + 1, len(folder_list)):
792
+ f1 = folder_list[i]
793
+ f2 = folder_list[j]
794
+
795
+ c1 = concepts_by_folder.get(f1, set())
796
+ c2 = concepts_by_folder.get(f2, set())
797
+ shared = c1.intersection(c2)
798
+
799
+ p1 = [p for p in f1.replace("\\", "/").split("/") if p]
800
+ p2 = [p for p in f2.replace("\\", "/").split("/") if p]
801
+ common_subdirs = 0
802
+ for k in range(min(len(p1), len(p2))):
803
+ if p1[k].lower() == p2[k].lower():
804
+ p_low = p1[k].lower()
805
+ if p_low not in ("c:", "d:", "downloads", "projects", "other projects"):
806
+ common_subdirs += 1
807
+ else:
808
+ break
809
+
810
+ if len(shared) > 0 or common_subdirs > 0:
811
+ edge_count += 1
812
+
813
+ return jsonify({"nodes": node_count, "edges": edge_count, "success": True}), 200
814
+
815
+ @app.route("/agentmemory/graph/query", methods=["POST"])
816
+ def api_graph_query():
817
+ auth_err = check_auth()
818
+ if auth_err:
819
+ return auth_err
820
+ try:
821
+ body = request.get_json(force=True) or {}
822
+ start_node_id = body.get("startNodeId")
823
+ return jsonify({"nodes": [], "edges": [], "success": True}), 200
824
+ except Exception as e:
825
+ return jsonify({"error": str(e)}), 400
826
+
827
+ @app.route("/agentmemory/graph/build", methods=["POST"])
828
+ def api_graph_build():
829
+ auth_err = check_auth()
830
+ if auth_err:
831
+ return auth_err
832
+ try:
833
+ if functions.is_consolidation_enabled():
834
+ functions.consolidate(kv)
835
+ return jsonify({"success": True}), 200
836
+ except Exception as e:
837
+ return jsonify({"error": str(e)}), 400
838
 
839
  @app.route("/agentmemory/semantic", methods=["GET"])
840
  def api_semantic_list():
 
1470
  return {"success": False, "syncedSessions": [], "observationsAdded": 0, "error": f"Invalid mode: {mode}"}
1471
 
1472
  if not targets:
1473
+ return {"success": True, "syncedSessions": [], "processedSessions": [], "observationsAdded": 0}
1474
 
1475
  synced_sessions = []
1476
+ processed_sessions = []
1477
  observations_added = 0
1478
 
1479
  for convo in targets:
 
1579
  observations_added += 1
1580
  convo_synced = True
1581
 
1582
+ processed_sessions.append(convo_id)
1583
  if convo_synced:
1584
  synced_sessions.append(convo_id)
1585
 
1586
+ return {"success": True, "syncedSessions": synced_sessions, "processedSessions": processed_sessions, "observationsAdded": observations_added}
1587
 
1588
  @app.route("/agentmemory/mcp/tools", methods=["GET"])
1589
  def mcp_tools_list():
 
1594
  tools = [
1595
  {
1596
  "name": "memory_recall",
1597
+ "description": "Search past folder observations and global memories. Use when you need to recall what happened in a folder.",
1598
  "inputSchema": {
1599
  "type": "object",
1600
  "properties": {
1601
  "query": {"type": "string", "description": "Search query keywords"},
1602
+ "limit": {"type": "number", "description": "Max results to return (default 10)"},
1603
+ "folderPath": {"type": "string", "description": "Filter to a specific folder path (optional)"},
1604
+ "agentId": {"type": "string", "description": "Filter to a specific agent ID (optional)"}
1605
+ },
1606
+ "required": ["query"]
1607
+ }
1608
+ },
1609
+ {
1610
+ "name": "memory_smart_search",
1611
+ "description": "Hybrid semantic+keyword search across folder observations and global memories.",
1612
+ "inputSchema": {
1613
+ "type": "object",
1614
+ "properties": {
1615
+ "query": {"type": "string", "description": "Search query"},
1616
+ "limit": {"type": "number", "description": "Max results (default 10)"},
1617
+ "folderPath": {"type": "string", "description": "Filter to a specific folder path (optional)"},
1618
+ "agentId": {"type": "string", "description": "Filter to a specific agent ID (optional)"}
1619
  },
1620
  "required": ["query"]
1621
  }
 
1645
  "required": ["content"]
1646
  }
1647
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1648
  {
1649
  "name": "memory_diagnose",
1650
+ "description": "Health check returns folder, agent, observation and memory counts.",
1651
  "inputSchema": {"type": "object", "properties": {}}
1652
  },
1653
  {
1654
  "name": "memory_forget",
1655
+ "description": "Delete a global memory or all observations for a (folderPath, agentId) pair.",
1656
  "inputSchema": {
1657
  "type": "object",
1658
  "properties": {
1659
  "memoryId": {"type": "string", "description": "Memory ID to delete"},
1660
+ "folderPath": {"type": "string", "description": "Folder path to delete observations from"},
1661
+ "agentId": {"type": "string", "description": "Agent ID to delete observations for"},
1662
  "observationIds": {
1663
  "oneOf": [
1664
  {"type": "string", "description": "Comma-separated observation IDs to delete"},
1665
+ {"type": "array", "items": {"type": "string"}}
1666
  ]
1667
  }
1668
  }
 
1670
  },
1671
  {
1672
  "name": "memory_export",
1673
+ "description": "Export all folder observations and global memories as JSON (v2 format).",
1674
+ "inputSchema": {"type": "object", "properties": {}}
 
 
 
 
 
 
1675
  },
1676
  {
1677
  "name": "agent_observe",
1678
+ "description": "Log an observation scoped to a folder path and agent ID.",
1679
  "inputSchema": {
1680
  "type": "object",
1681
  "properties": {
1682
+ "folderPath": {"type": "string", "description": "Absolute path of the working directory"},
1683
+ "agentId": {"type": "string", "description": "Identity of the agent (e.g. 'kiro', 'claude')"},
1684
+ "text": {"type": "string", "description": "Observation content"},
1685
+ "timestamp": {"type": "string", "description": "ISO 8601 UTC timestamp (defaults to now)"},
1686
+ "type": {"type": "string", "description": "Observation type"},
1687
+ "title": {"type": "string", "description": "Short title"},
1688
+ "concepts": {"type": "array", "items": {"type": "string"}},
1689
+ "files": {"type": "array", "items": {"type": "string"}},
1690
+ "importance": {"type": "integer", "description": "1-10"},
1691
+ "sessionId": {"type": "string", "description": "Deprecated — ignored"}
1692
  },
1693
+ "required": ["folderPath", "agentId", "text"]
1694
  }
1695
  },
1696
  {
1697
  "name": "agent_remember",
1698
+ "description": "Explicitly save a key insight, fact, or architecture decision to long-term memory.",
1699
  "inputSchema": {
1700
  "type": "object",
1701
  "properties": {
1702
+ "agentId": {"type": "string", "description": "ID/Name of the agent (optional)"},
1703
  "content": {"type": "string", "description": "The memory content/insight"},
1704
  "project": {"type": "string", "description": "Canonical project path/identifier"},
1705
  "type": {"type": "string", "description": "Memory type: fact, preference, bug, workflow, architecture"},
1706
  "concepts": {
1707
  "oneOf": [
1708
  {"type": "string", "description": "Comma-separated key concepts"},
1709
+ {"type": "array", "items": {"type": "string"}}
1710
  ]
1711
  },
1712
  "files": {
1713
  "oneOf": [
1714
  {"type": "string", "description": "Comma-separated relevant file paths"},
1715
+ {"type": "array", "items": {"type": "string"}}
1716
  ]
1717
  }
1718
  },
1719
+ "required": ["content"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1720
  }
1721
  },
1722
  {
1723
+ "name": "memory_folders",
1724
+ "description": "List all (folder, agent) pairs that have memory observations.",
1725
  "inputSchema": {"type": "object", "properties": {}}
1726
  },
1727
  {
1728
+ "name": "memory_folder_observations",
1729
+ "description": "Get all observations for a specific (folderPath, agentId) pair.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1730
  "inputSchema": {
1731
  "type": "object",
1732
  "properties": {
1733
+ "folderPath": {"type": "string", "description": "Folder path"},
1734
+ "agentId": {"type": "string", "description": "Agent ID"}
 
 
 
 
 
 
 
 
 
1735
  },
1736
+ "required": ["folderPath", "agentId"]
1737
  }
1738
  },
1739
  {
1740
+ "name": "memory_timeline",
1741
+ "description": "Get folder activity feed observations sorted by time, filterable by folder/agent.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1742
  "inputSchema": {
1743
  "type": "object",
1744
  "properties": {
1745
+ "folderPath": {"type": "string", "description": "Filter by folder path (optional)"},
1746
+ "agentId": {"type": "string", "description": "Filter by agent ID (optional)"},
1747
+ "limit": {"type": "integer", "description": "Max results (default 100)"},
1748
+ "before": {"type": "string", "description": "ISO timestamp upper bound (optional)"},
1749
+ "after": {"type": "string", "description": "ISO timestamp lower bound (optional)"}
1750
+ }
1751
  }
1752
  }
1753
  ]
 
1773
  if name == "memory_recall":
1774
  q = args.get("query")
1775
  limit = int(args.get("limit") or 10)
1776
+ folder_path = args.get("folderPath")
1777
+ agent_id = args.get("agentId")
1778
+ res = functions.folder_search(kv, q, limit, folder_path=folder_path, agent_id=agent_id)
1779
  text_out = json.dumps(res, indent=2)
1780
 
1781
  elif name == "memory_save":
1782
  content = args.get("content")
1783
  concepts = parse_mcp_list_arg(args.get("concepts"))
1784
  files = parse_mcp_list_arg(args.get("files"))
 
1785
  project = args.get("project")
1786
  res = functions.remember(kv, {
1787
  "content": content,
 
1790
  "files": files,
1791
  "project": project
1792
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1793
  text_out = json.dumps(res)
1794
 
 
 
 
 
1795
  elif name == "memory_smart_search":
1796
  q = args.get("query")
1797
  limit = int(args.get("limit") or 10)
1798
+ folder_path = args.get("folderPath")
1799
+ agent_id = args.get("agentId")
1800
+ res = functions.folder_search(kv, q, limit, folder_path=folder_path, agent_id=agent_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1801
  text_out = json.dumps(res, indent=2)
1802
 
1803
  elif name == "memory_diagnose":
 
1808
  obs_ids = parse_mcp_list_arg(args.get("observationIds"))
1809
  res = functions.forget(kv, {
1810
  "memoryId": args.get("memoryId"),
1811
+ "folderPath": args.get("folderPath"),
1812
+ "agentId": args.get("agentId"),
1813
  "observationIds": obs_ids
1814
  })
1815
  text_out = json.dumps(res, indent=2)
1816
 
1817
  elif name == "memory_export":
1818
+ res = functions.export_data(kv, {})
 
 
 
 
 
1819
  text_out = json.dumps(res, indent=2)
1820
 
1821
  elif name == "agent_observe":
1822
+ folder_path = args.get("folderPath")
1823
+ agent_id = args.get("agentId")
1824
+ text = args.get("text") or args.get("content") or ""
1825
+ if not folder_path or not agent_id or not text:
1826
+ return jsonify({"error": "folderPath, agentId, and text are required"}), 400
1827
+ timestamp = args.get("timestamp") or datetime_now_iso()
 
 
 
 
 
1828
  payload = {
1829
+ "folderPath": folder_path, "agentId": agent_id, "text": text,
1830
+ "timestamp": timestamp, "type": args.get("type"), "title": args.get("title"),
1831
+ "concepts": args.get("concepts"), "files": args.get("files"),
1832
+ "importance": args.get("importance"),
 
 
 
 
 
 
 
1833
  }
1834
+ res = functions.folder_observe(kv, payload)
1835
  text_out = json.dumps(res)
1836
 
1837
  elif name == "agent_remember":
1838
  agent_id = args.get("agentId") or functions.get_agent_id() or "agent"
1839
  content = args.get("content")
1840
  project = args.get("project")
 
1841
  mem_type = args.get("type") or "fact"
1842
  concepts = parse_mcp_list_arg(args.get("concepts"))
1843
  files = parse_mcp_list_arg(args.get("files"))
1844
+ if not content:
1845
+ return jsonify({"error": "content is required"}), 400
 
 
1846
  payload = {
1847
+ "content": content, "type": mem_type, "concepts": concepts,
1848
+ "files": files, "project": project, "agentId": agent_id
 
 
 
 
1849
  }
1850
  res = functions.remember(kv, payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1851
  text_out = json.dumps(res)
1852
+
1853
+ elif name == "memory_folders":
1854
+ pairs = sorted(kv.list(functions.KV.folders), key=lambda x: x.get("lastUpdated", ""), reverse=True)
1855
+ text_out = json.dumps(pairs, indent=2)
1856
+
1857
+ elif name == "memory_folder_observations":
1858
+ fp = args.get("folderPath", "")
1859
+ aid = args.get("agentId", "")
1860
+ if not fp or not aid:
1861
+ return jsonify({"error": "folderPath and agentId are required"}), 400
1862
+ obs = sorted(kv.list(functions.KV.folder_obs(fp, aid)), key=lambda x: x.get("timestamp", ""), reverse=True)
1863
+ text_out = json.dumps(obs, indent=2)
1864
+
1865
+ elif name == "memory_timeline":
1866
+ res = functions.folder_timeline(
1867
+ kv,
1868
+ limit=int(args.get("limit", 100)),
1869
+ folder_path=args.get("folderPath"),
1870
+ agent_id=args.get("agentId"),
1871
+ before=args.get("before"),
1872
+ after=args.get("after"),
1873
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1874
  text_out = json.dumps(res, indent=2)
1875
 
1876
  else:
src/functions.py CHANGED
@@ -26,73 +26,66 @@ _hybrid_search = HybridSearch(_bm25_index, _vector_index, None, None)
26
  _index_persistence = None
27
  _stream_broadcaster = None # Callable: (payload) -> None
28
 
29
- # Default scopes matching schema.ts
30
  class KV:
31
- sessions = "mem:sessions"
32
- memories = "mem:memories"
33
- summaries = "mem:summaries"
34
- config = "mem:config"
35
- metrics = "mem:metrics"
36
- health = "mem:health"
37
- bm25Index = "mem:index:bm25"
38
- relations = "mem:relations"
39
- profiles = "mem:profiles"
40
- claudeBridge = "mem:claude-bridge"
41
- graphNodes = "mem:graph:nodes"
42
- graphEdges = "mem:graph:edges"
43
- graphSnapshot = "mem:graph:snapshot"
44
- graphNameIndex = "mem:graph:name-index"
45
- graphEdgeKey = "mem:graph:edge-key"
46
- graphNodeDegree = "mem:graph:node-degree"
47
- semantic = "mem:semantic"
48
- procedural = "mem:procedural"
49
- audit = "mem:audit"
50
- actions = "mem:actions"
51
- actionEdges = "mem:action-edges"
52
- leases = "mem:leases"
53
- routines = "mem:routines"
54
- routineRuns = "mem:routine-runs"
55
- signals = "mem:signals"
56
- checkpoints = "mem:checkpoints"
57
- mesh = "mem:mesh"
58
- sketches = "mem:sketches"
59
- facets = "mem:facets"
60
- sentinels = "mem:sentinels"
61
- crystals = "mem:crystals"
62
- lessons = "mem:lessons"
63
- insights = "mem:insights"
64
- graphEdgeHistory = "mem:graph:edge-history"
65
- retentionScores = "mem:retention"
66
- accessLog = "mem:access"
67
- imageRefs = "mem:image-refs"
68
- slots = "mem:slots"
69
- globalSlots = "mem:slots:global"
70
- commits = "mem:commits"
71
- recentSearches = "mem:recent-searches"
72
 
73
- @staticmethod
74
- def observations(session_id: str) -> str:
75
- return f"mem:obs:{session_id}"
76
 
77
  @staticmethod
78
- def team_shared(team_id: str) -> str:
79
- return f"mem:team:{team_id}:shared"
 
 
 
 
 
80
 
81
  @staticmethod
82
- def team_users(team_id: str, user_id: str) -> str:
83
- return f"mem:team:{team_id}:users:{user_id}"
 
 
 
 
 
84
 
85
- @staticmethod
86
- def team_profile(team_id: str) -> str:
87
- return f"mem:team:{team_id}:profile"
88
 
89
- @staticmethod
90
- def enriched_chunks(session_id: str) -> str:
91
- return f"mem:enriched:{session_id}"
92
 
93
- @staticmethod
94
- def latent_embeddings(obs_id: str) -> str:
95
- return f"mem:latent:{obs_id}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
  # =====================================================================
98
  # Core Helpers & Utilities
@@ -114,12 +107,96 @@ def fingerprint_id(prefix: str, content: str) -> str:
114
  h = hashlib.sha256(content.strip().lower().encode('utf-8')).hexdigest()
115
  return f"{prefix}_{h[:16]}"
116
 
117
- def auto_complete_old_active_sessions(kv: StateKV, current_session_id: str) -> int:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  sessions = kv.list(KV.sessions)
119
  count = 0
120
  now = datetime.datetime.utcnow().isoformat() + "Z"
121
  for s in sessions:
122
  if s.get("id") != current_session_id and s.get("status") == "active":
 
 
 
 
123
  s["status"] = "completed"
124
  if "endedAt" not in s:
125
  s["endedAt"] = now
@@ -406,9 +483,10 @@ class IndexPersistence:
406
  try:
407
  conn = self.kv._get_conn()
408
  try:
409
- with conn.cursor() as cursor:
 
410
  cursor.execute(
411
- "SELECT DISTINCT scope FROM kv_store WHERE scope LIKE %s",
412
  (scope_prefix + "%",)
413
  )
414
  rows = cursor.fetchall()
@@ -422,11 +500,14 @@ class IndexPersistence:
422
  if to_delete:
423
  for i in range(0, len(to_delete), 50):
424
  chunk_delete = to_delete[i:i + 50]
425
- format_strings = ','.join(['%s'] * len(chunk_delete))
426
  cursor.execute(
427
  f"DELETE FROM kv_store WHERE scope IN ({format_strings})",
428
  tuple(chunk_delete)
429
  )
 
 
 
430
  finally:
431
  conn.close()
432
  except Exception as ex:
@@ -696,7 +777,8 @@ def observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
696
  max_obs = int(os.getenv("MAX_OBS_PER_SESSION", "500"))
697
  if max_obs > 0:
698
  existing = kv.list(KV.observations(session_id))
699
- if len(existing) >= max_obs:
 
700
  raise ValueError(f"Session observation limit reached ({max_obs})")
701
 
702
  existing_session = kv.get(KV.sessions, session_id)
@@ -716,7 +798,8 @@ def observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
716
  print(f"[image store] failed: {ex}")
717
 
718
  # Set raw observation
719
- kv.set(KV.observations(session_id), obs_id, raw)
 
720
 
721
  # Stream raw observation
722
  broadcast_stream({
@@ -740,8 +823,8 @@ def observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
740
  updates.append({"type": "set", "path": "firstPrompt", "value": trimmed[:200]})
741
  kv.update(KV.sessions, session_id, updates)
742
  else:
743
- auto_complete_old_active_sessions(kv, session_id)
744
  project = payload.get("project") or "unknown"
 
745
  cwd = payload.get("cwd") or os.getcwd()
746
  trimmed_prompt = None
747
  if isinstance(raw.get("userPrompt"), str):
@@ -763,10 +846,12 @@ def observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
763
  kv.set(KV.sessions, session_id, new_sess)
764
 
765
  # Perform synthetic compression (we default to synthetic)
766
- synthetic = build_synthetic_compression(raw)
 
 
767
  for k in ["hookType", "raw", "toolName", "toolInput", "toolOutput", "userPrompt"]:
768
- if k in raw:
769
- synthetic[k] = raw[k]
770
  kv.set(KV.observations(session_id), obs_id, synthetic)
771
  _bm25_index.add(synthetic)
772
 
@@ -793,6 +878,333 @@ def observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
793
  return {"observationId": obs_id}
794
 
795
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
796
  # =====================================================================
797
  # Memory System (Remember, Forget, Evolve)
798
  # =====================================================================
@@ -896,14 +1308,33 @@ def remember(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
896
 
897
 
898
  def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
899
  memory_id = data.get("memoryId")
900
  session_id = data.get("sessionId")
 
 
901
  obs_ids = data.get("observationIds") or []
902
  deleted = 0
903
  deleted_mem_ids = []
904
  deleted_obs_ids = []
905
  deleted_session = False
906
 
 
 
 
907
  if memory_id:
908
  mem = kv.get(KV.memories, memory_id)
909
  kv.delete(KV.memories, memory_id)
@@ -918,23 +1349,97 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
918
  deleted_mem_ids.append(memory_id)
919
  deleted += 1
920
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921
  if session_id and obs_ids:
922
  for oid in obs_ids:
923
- obs = kv.get(KV.observations(session_id), oid)
924
- kv.delete(KV.observations(session_id), oid)
925
- if obs:
926
- img = obs.get("imageData") or obs.get("imageRef")
927
- if img:
928
- refs = kv.get(KV.imageRefs, img) or 0
929
- if refs > 0:
930
- kv.set(KV.imageRefs, img, refs - 1)
931
- _bm25_index.remove(oid)
 
 
 
 
 
 
 
 
932
  if _vector_index:
933
- _vector_index.remove(oid)
 
934
  deleted_obs_ids.append(oid)
935
  deleted += 1
936
 
937
- if session_id and not obs_ids and not memory_id:
938
  obs_list = kv.list(KV.observations(session_id))
939
  for obs in obs_list:
940
  kv.delete(KV.observations(session_id), obs["id"])
@@ -962,7 +1467,10 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
962
  "mem::forget",
963
  deleted_mem_ids + deleted_obs_ids,
964
  {
 
965
  "sessionId": session_id,
 
 
966
  "deleted": deleted,
967
  "memoriesDeleted": len(deleted_mem_ids),
968
  "observationsDeleted": len(deleted_obs_ids),
@@ -970,10 +1478,9 @@ def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
970
  "reason": "user-initiated forget"
971
  }
972
  )
973
-
974
- # Commit to Dolt
975
  agent_id = data.get("agentId") or get_agent_id()
976
- commit_if_enabled(kv, f"Forget: memory_id={memory_id} session_id={session_id}", agent_id)
977
 
978
  return {"success": True, "deleted": deleted}
979
 
@@ -999,7 +1506,7 @@ def context(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
999
  blocks = []
1000
 
1001
  # 1. Pinned Slots
1002
- pinned_slots = list_pinned_slots(kv)
1003
  slot_content = render_pinned_context(pinned_slots)
1004
  if slot_content:
1005
  blocks.append({
@@ -1212,8 +1719,8 @@ def seed_defaults(kv: StateKV) -> None:
1212
  slot["updatedAt"] = now
1213
  kv.set(target, tmpl["label"], slot)
1214
 
1215
- def list_pinned_slots(kv: StateKV) -> List[Dict[str, Any]]:
1216
- p_slots = kv.list(KV.slots)
1217
  g_slots = kv.list(KV.globalSlots)
1218
  merged = {}
1219
  for s in g_slots:
@@ -1234,8 +1741,8 @@ def render_pinned_context(slots: List[Dict[str, Any]]) -> str:
1234
  lines.append("")
1235
  return "\n".join(lines)
1236
 
1237
- def slot_list(kv: StateKV) -> Dict[str, Any]:
1238
- p_slots = kv.list(KV.slots)
1239
  g_slots = kv.list(KV.globalSlots)
1240
  merged = {}
1241
  for s in g_slots:
@@ -1245,10 +1752,11 @@ def slot_list(kv: StateKV) -> Dict[str, Any]:
1245
  slots = sorted(list(merged.values()), key=lambda s: s["label"])
1246
  return {"success": True, "slots": slots}
1247
 
1248
- def slot_get(kv: StateKV, label: str) -> Dict[str, Any]:
1249
- project = kv.get(KV.slots, label)
1250
- if project:
1251
- return {"success": True, "slot": project, "scope": "project"}
 
1252
  global_s = kv.get(KV.globalSlots, label)
1253
  if global_s:
1254
  return {"success": True, "slot": global_s, "scope": "global"}
@@ -1273,8 +1781,9 @@ def slot_create(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1273
 
1274
  description = data.get("description") or ""
1275
  pinned = data.get("pinned", True)
 
1276
 
1277
- target_kv = KV.globalSlots if scope == "global" else KV.slots
1278
  existing = kv.get(target_kv, label)
1279
  if existing:
1280
  return {"success": False, "error": f"slot already exists in {scope} scope"}
@@ -1300,14 +1809,14 @@ def slot_create(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1300
 
1301
  return {"success": True, "slot": slot}
1302
 
1303
- def slot_append(kv: StateKV, label: str, text: str, agent_id: Optional[str] = None) -> Dict[str, Any]:
1304
- res = slot_get(kv, label)
1305
  if not res.get("success"):
1306
  return {"success": False, "error": "slot not found"}
1307
 
1308
  slot = res["slot"]
1309
  scope = res["scope"]
1310
- target_kv = KV.globalSlots if scope == "global" else KV.slots
1311
 
1312
  if slot.get("readOnly"):
1313
  return {"success": False, "error": "slot is read-only"}
@@ -1336,14 +1845,14 @@ def slot_append(kv: StateKV, label: str, text: str, agent_id: Optional[str] = No
1336
 
1337
  return {"success": True, "slot": slot, "size": len(next_content)}
1338
 
1339
- def slot_replace(kv: StateKV, label: str, content: str, agent_id: Optional[str] = None) -> Dict[str, Any]:
1340
- res = slot_get(kv, label)
1341
  if not res.get("success"):
1342
  return {"success": False, "error": "slot not found"}
1343
 
1344
  slot = res["slot"]
1345
  scope = res["scope"]
1346
- target_kv = KV.globalSlots if scope == "global" else KV.slots
1347
 
1348
  if slot.get("readOnly"):
1349
  return {"success": False, "error": "slot is read-only"}
@@ -1369,14 +1878,14 @@ def slot_replace(kv: StateKV, label: str, content: str, agent_id: Optional[str]
1369
 
1370
  return {"success": True, "slot": slot, "size": len(content)}
1371
 
1372
- def slot_delete(kv: StateKV, label: str, agent_id: Optional[str] = None) -> Dict[str, Any]:
1373
- res = slot_get(kv, label)
1374
  if not res.get("success"):
1375
  return {"success": False, "error": "slot not found"}
1376
 
1377
  slot = res["slot"]
1378
  scope = res["scope"]
1379
- target_kv = KV.globalSlots if scope == "global" else KV.slots
1380
 
1381
  if slot.get("readOnly"):
1382
  return {"success": False, "error": "slot is read-only"}
@@ -1391,6 +1900,9 @@ def slot_delete(kv: StateKV, label: str, agent_id: Optional[str] = None) -> Dict
1391
 
1392
 
1393
  def slot_reflect(kv: StateKV, session_id: str, max_obs: int = 50) -> Dict[str, Any]:
 
 
 
1394
  observations = kv.list(KV.observations(session_id))
1395
  if not observations:
1396
  return {"success": True, "applied": 0, "reason": "no observations for session"}
@@ -1417,11 +1929,11 @@ def slot_reflect(kv: StateKV, session_id: str, max_obs: int = 50) -> Dict[str, A
1417
  now = datetime.datetime.utcnow().isoformat() + "Z"
1418
 
1419
  if pending_lines:
1420
- res = slot_get(kv, "pending_items")
1421
  if res.get("success"):
1422
  slot = res["slot"]
1423
  scope = res["scope"]
1424
- target_kv = scopeKv = KV.globalSlots if scope == "global" else KV.slots
1425
  already = set((slot.get("content") or "").split("\n"))
1426
  fresh = [l for l in pending_lines if l not in already]
1427
  if fresh:
@@ -1436,11 +1948,11 @@ def slot_reflect(kv: StateKV, session_id: str, max_obs: int = 50) -> Dict[str, A
1436
  applied += 1
1437
 
1438
  if pattern_counts:
1439
- res = slot_get(kv, "session_patterns")
1440
  if res.get("success"):
1441
  slot = res["slot"]
1442
  scope = res["scope"]
1443
- target_kv = KV.globalSlots if scope == "global" else KV.slots
1444
  summary = [f"last reflection: {now}"]
1445
  for k, v in pattern_counts.items():
1446
  summary.append(f"- {k}: {v} in last {len(recent)} observations")
@@ -1454,11 +1966,11 @@ def slot_reflect(kv: StateKV, session_id: str, max_obs: int = 50) -> Dict[str, A
1454
  applied += 1
1455
 
1456
  if files:
1457
- res = slot_get(kv, "project_context")
1458
  if res.get("success"):
1459
  slot = res["slot"]
1460
  scope = res["scope"]
1461
- target_kv = KV.globalSlots if scope == "global" else KV.slots
1462
  already = slot.get("content") or ""
1463
  fresh = [f for f in files if f not in already][:20]
1464
  if fresh:
@@ -1685,24 +2197,46 @@ def rebuild_index(kv: StateKV) -> int:
1685
  if _vector_index:
1686
  _vector_index.clear()
1687
 
1688
- # Backfill BM25 with observations
1689
- sessions = kv.list(KV.sessions)
1690
  total_indexed = 0
1691
 
1692
- for sess in sessions:
1693
- sid = sess.get("id")
1694
- if not sid:
 
 
 
1695
  continue
1696
- obs_list = kv.list(KV.observations(sid))
1697
  for obs in obs_list:
1698
- # Only index compressed (non-raw) observations
1699
- if obs.get("title") and obs.get("narrative"):
1700
- _bm25_index.add(obs)
1701
- comb_text = obs["title"] + " " + obs["narrative"]
1702
- vector_index_add_guarded(obs["id"], sid, comb_text, {"kind": "observation", "logId": obs["id"]})
1703
- total_indexed += 1
1704
-
1705
- # Backfill BM25 with memories
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1706
  memories = kv.list(KV.memories)
1707
  for mem in memories:
1708
  if mem.get("isLatest") is False:
@@ -1746,7 +2280,7 @@ def get_session(kv: StateKV, session_id: str) -> Optional[Dict[str, Any]]:
1746
  return s
1747
 
1748
  def create_session(kv: StateKV, session: Dict[str, Any]) -> Dict[str, Any]:
1749
- auto_complete_old_active_sessions(kv, session["id"])
1750
  kv.set(KV.sessions, session["id"], session)
1751
  return session
1752
 
@@ -1900,99 +2434,118 @@ def build_project_profile(kv: StateKV, project: str) -> Dict[str, Any]:
1900
  def export_data(kv: StateKV, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1901
  if data is None:
1902
  data = {}
1903
- raw_max = data.get("maxSessions")
1904
- max_sessions = None
1905
- if raw_max is not None:
1906
- try:
1907
- max_sessions = min(max(int(raw_max), 1), 1000)
1908
- except Exception:
1909
- pass
1910
-
1911
- raw_offset = data.get("offset")
1912
- offset = 0
1913
- if raw_offset is not None:
1914
- try:
1915
- offset = max(int(raw_offset), 0)
1916
- except Exception:
1917
- pass
1918
-
1919
- all_sessions = kv.list(KV.sessions)
1920
- all_sessions.sort(key=lambda s: s.get("startedAt", ""), reverse=True)
1921
-
1922
- if max_sessions is not None:
1923
- paginated_sessions = all_sessions[offset:offset + max_sessions]
1924
- else:
1925
- paginated_sessions = all_sessions
1926
-
 
1927
  memories = kv.list(KV.memories)
1928
- summaries = kv.list(KV.summaries)
1929
-
1930
- observations = {}
1931
- for s in paginated_sessions:
1932
- sid = s.get("id")
1933
- if sid:
1934
- obs = kv.list(KV.observations(sid))
1935
- if obs:
1936
- observations[sid] = obs
1937
-
1938
- profiles = []
1939
- unique_projects = list(set(s.get("project") for s in paginated_sessions if s.get("project")))
1940
- for proj in unique_projects:
1941
- p = kv.get(KV.profiles, proj)
1942
- if p:
1943
- profiles.append(p)
1944
-
1945
- graph_nodes = kv.list(KV.graphNodes)
1946
- graph_edges = kv.list(KV.graphEdges)
1947
- semantic_memories = kv.list(KV.semantic)
1948
- procedural_memories = kv.list(KV.procedural)
1949
- actions = kv.list(KV.actions)
1950
- action_edges = kv.list(KV.actionEdges)
1951
- sentinels = kv.list(KV.sentinels)
1952
- sketches = kv.list(KV.sketches)
1953
- crystals = kv.list(KV.crystals)
1954
- facets = kv.list(KV.facets)
1955
- lessons = kv.list(KV.lessons)
1956
- insights = kv.list(KV.insights)
1957
- routines = kv.list(KV.routines)
1958
- signals = kv.list(KV.signals)
1959
- checkpoints = kv.list(KV.checkpoints)
1960
- access_logs = kv.list(KV.accessLog)
1961
-
1962
- res = {
1963
- "version": "0.9.21",
1964
- "exportedAt": datetime.datetime.utcnow().isoformat() + "Z",
1965
- "sessions": paginated_sessions,
1966
- "observations": observations,
1967
  "memories": memories,
1968
- "summaries": summaries
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1969
  }
1970
- if profiles: res["profiles"] = profiles
1971
- if graph_nodes: res["graphNodes"] = graph_nodes
1972
- if graph_edges: res["graphEdges"] = graph_edges
1973
- if semantic_memories: res["semanticMemories"] = semantic_memories
1974
- if procedural_memories: res["proceduralMemories"] = procedural_memories
1975
- if actions: res["actions"] = actions
1976
- if action_edges: res["actionEdges"] = action_edges
1977
- if sentinels: res["sentinels"] = sentinels
1978
- if sketches: res["sketches"] = sketches
1979
- if crystals: res["crystals"] = crystals
1980
- if facets: res["facets"] = facets
1981
- if lessons: res["lessons"] = lessons
1982
- if insights: res["insights"] = insights
1983
- if routines: res["routines"] = routines
1984
- if signals: res["signals"] = signals
1985
- if checkpoints: res["checkpoints"] = checkpoints
1986
- if access_logs: res["accessLogs"] = access_logs
1987
-
1988
- if max_sessions is not None:
1989
- res["pagination"] = {
1990
- "offset": offset,
1991
- "limit": max_sessions,
1992
- "total": len(all_sessions),
1993
- "hasMore": offset + max_sessions < len(all_sessions)
1994
- }
1995
- return res
1996
 
1997
 
1998
  def set_project_profile(kv: StateKV, project: str, profile: Dict[str, Any]) -> Dict[str, Any]:
@@ -2131,17 +2684,26 @@ def auto_forget(kv: StateKV, dry_run: bool = False) -> Dict[str, Any]:
2131
  _vector_index.remove(mem_id)
2132
 
2133
  for sid, obs_id in evicted_observations:
2134
- obs = kv.get(KV.observations(sid), obs_id)
2135
- kv.delete(KV.observations(sid), obs_id)
2136
- if obs:
2137
- img = obs.get("imageData") or obs.get("imageRef")
2138
- if img:
2139
- refs = kv.get(KV.imageRefs, img) or 0
2140
- if refs > 0:
2141
- kv.set(KV.imageRefs, img, refs - 1)
2142
- _bm25_index.remove(obs_id)
 
 
 
 
 
 
 
 
2143
  if _vector_index:
2144
- _vector_index.remove(obs_id)
 
2145
 
2146
  if evicted_memories or evicted_observations:
2147
  if _index_persistence:
@@ -2174,12 +2736,61 @@ def health_check(kv: StateKV) -> Dict[str, Any]:
2174
  conn.close()
2175
  except Exception:
2176
  db_status = "disconnected"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2177
  return {
2178
- "status": "healthy" if db_status == "connected" else "degraded",
2179
- "service": "agentmemory",
2180
- "version": "0.9.8",
2181
- "database": "dolt",
2182
- "databaseStatus": db_status
 
 
 
 
2183
  }
2184
 
2185
  def strip_xml_wrappers(raw: str) -> str:
@@ -2312,10 +2923,11 @@ def summarize(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
2312
  "concepts": get_xml_children(cleaned, "concepts", "concept"),
2313
  })
2314
  except Exception as e:
 
2315
  print(f"[summarize] Chunk {idx+1} failed: {e}")
2316
 
2317
  if not partial_summaries:
2318
- return {"success": False, "error": "No chunks summarized successfully"}
2319
 
2320
  if len(partial_summaries) == 1:
2321
  final_summary = {
@@ -2509,6 +3121,19 @@ def consolidate(kv: StateKV, project: Optional[str] = None, min_observations: in
2509
  if project:
2510
  evolved["project"] = project
2511
  kv.set(KV.memories, evolved["id"], evolved)
 
 
 
 
 
 
 
 
 
 
 
 
 
2512
  consolidated_count += 1
2513
  else:
2514
  memory = {
@@ -2529,6 +3154,12 @@ def consolidate(kv: StateKV, project: Optional[str] = None, min_observations: in
2529
  if project:
2530
  memory["project"] = project
2531
  kv.set(KV.memories, memory["id"], memory)
 
 
 
 
 
 
2532
  consolidated_count += 1
2533
 
2534
  except Exception as e:
@@ -2688,10 +3319,185 @@ def consolidate(kv: StateKV, project: Optional[str] = None, min_observations: in
2688
  "patternsAnalyzed": len(patterns)
2689
  }
2690
  }
 
 
2691
  safe_audit(kv, "consolidate", "mem::consolidate-pipeline", [], res_summary)
2692
  commit_if_enabled(kv, f"Consolidation complete: consolidated={consolidated_count}, facts={new_facts_count}, procs={new_procs_count}", "system")
2693
  return res_summary
2694
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2695
  # Setup persistence helper wire-ups
2696
  def set_index_persistence(persistence: IndexPersistence) -> None:
2697
  global _index_persistence
 
26
  _index_persistence = None
27
  _stream_broadcaster = None # Callable: (payload) -> None
28
 
29
+ # KV scope registry — folder-based memory model
30
  class KV:
31
+ # ---- Folder memory scopes (new) ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
+ # Global index of all (folder_path, agent_id) pairs known to the system.
34
+ # Key = "{safe_folder_path}:{agent_id}", value = FolderIndexEntry dict.
35
+ folders = "mem:folders"
36
 
37
  @staticmethod
38
+ def folder_obs(folder_path: str, agent_id: str) -> str:
39
+ """Per-(folder, agent) observations scope.
40
+ Key = obs_id, value = FolderObservation dict.
41
+ """
42
+ safe_path = folder_path.replace("\\", "/").strip("/")
43
+ safe_agent = agent_id.strip()
44
+ return f"mem:folder:{safe_path}:{safe_agent}"
45
 
46
  @staticmethod
47
+ def folder_meta(folder_path: str, agent_id: str) -> str:
48
+ """Per-(folder, agent) metadata scope.
49
+ Key = "meta", value = FolderMeta dict (obsCount, lastUpdated, summary).
50
+ """
51
+ safe_path = folder_path.replace("\\", "/").strip("/")
52
+ safe_agent = agent_id.strip()
53
+ return f"mem:foldermeta:{safe_path}:{safe_agent}"
54
 
55
+ # ---- Global / shared scopes (kept) ----
 
 
56
 
57
+ # Long-term memories — unchanged from previous implementation.
58
+ memories = "mem:memories"
 
59
 
60
+ # BM25 index shards — unchanged.
61
+ bm25Index = "mem:index:bm25"
62
+
63
+ # Audit log — unchanged.
64
+ audit = "mem:audit"
65
+
66
+ # Graph edges — repurposed for folder graph edges.
67
+ relations = "mem:relations"
68
+
69
+ def get_current_project(kv: StateKV) -> Optional[str]:
70
+ try:
71
+ sessions = kv.list(KV.sessions)
72
+ if not sessions:
73
+ return None
74
+ active_sessions = [s for s in sessions if s.get("status") == "active"]
75
+ if active_sessions:
76
+ active_sessions.sort(key=lambda s: s.get("updatedAt", ""), reverse=True)
77
+ return active_sessions[0].get("project")
78
+ sessions.sort(key=lambda s: s.get("updatedAt", ""), reverse=True)
79
+ return sessions[0].get("project")
80
+ except Exception:
81
+ return None
82
+
83
+ def project_slots_scope(kv: StateKV, project: Optional[str] = None) -> str:
84
+ if not project:
85
+ project = get_current_project(kv)
86
+ if not project:
87
+ return KV.slots
88
+ return f"mem:slots:{project}"
89
 
90
  # =====================================================================
91
  # Core Helpers & Utilities
 
107
  h = hashlib.sha256(content.strip().lower().encode('utf-8')).hexdigest()
108
  return f"{prefix}_{h[:16]}"
109
 
110
+ # ---- Folder-path normalisation (REQ-002, REQ-063, REQ-064, REQ-066) ----
111
+
112
+ _MAX_PATH_LEN = 512
113
+
114
+
115
+ def normalize_folder_path(path: str) -> str:
116
+ """Normalize a folder path for safe use in KV scope keys.
117
+
118
+ Steps applied in order:
119
+ 1. Cap the raw input at 512 characters (REQ-066).
120
+ 2. Apply ``os.path.normpath`` to collapse redundant separators and
121
+ resolve any ``..`` components at the OS level.
122
+ 3. Convert all OS-native separators to forward slashes.
123
+ 4. Strip any remaining leading or trailing slashes.
124
+
125
+ Raises:
126
+ ValueError: if *path* is empty (before or after normalization), or
127
+ if the normalized result still contains a ``..`` segment,
128
+ which would indicate an attempt at path traversal
129
+ (REQ-064).
130
+
131
+ Returns:
132
+ A non-empty, forward-slash-separated string with no leading/trailing
133
+ slashes and no ``..`` segments — safe for use as a KV scope fragment.
134
+
135
+ Property (REQ-074): idempotent — applying this function twice yields
136
+ the same result as applying it once.
137
+ """
138
+ if not path:
139
+ raise ValueError("folder_path must not be empty")
140
+
141
+ # 1. Length cap before any processing.
142
+ path = path[:_MAX_PATH_LEN]
143
+
144
+ # 2. OS-level normalisation (resolves .., duplicate separators, etc.)
145
+ normalized = os.path.normpath(path)
146
+
147
+ # 3. Unify separators to forward slash.
148
+ normalized = normalized.replace("\\", "/")
149
+
150
+ # 4. Strip leading / trailing slashes.
151
+ normalized = normalized.strip("/")
152
+
153
+ # Guard: reject path traversal patterns that survive normalisation.
154
+ # Split on "/" and check each component; normpath should have already
155
+ # resolved most cases but we verify explicitly for safety.
156
+ parts = normalized.split("/")
157
+ if any(part == ".." for part in parts):
158
+ raise ValueError(
159
+ f"folder_path contains path traversal segment '..': {path!r}"
160
+ )
161
+
162
+ if not normalized:
163
+ raise ValueError("folder_path is empty after normalization")
164
+
165
+ return normalized
166
+
167
+
168
+ def validate_agent_id(agent_id: str) -> str:
169
+ """Validate and sanitize an agent_id before use in KV scope keys.
170
+
171
+ Strips surrounding whitespace and caps at 512 characters (REQ-066).
172
+
173
+ Raises:
174
+ ValueError: if *agent_id* is empty after stripping.
175
+
176
+ Returns:
177
+ Sanitized agent_id string.
178
+ """
179
+ if not agent_id:
180
+ raise ValueError("agent_id must not be empty")
181
+
182
+ sanitized = agent_id.strip()[:_MAX_PATH_LEN]
183
+
184
+ if not sanitized:
185
+ raise ValueError("agent_id is empty after stripping whitespace")
186
+
187
+ return sanitized
188
+
189
+
190
+ def auto_complete_old_active_sessions(kv: StateKV, current_session_id: str, project: Optional[str] = None, agent_id: Optional[str] = None) -> int:
191
  sessions = kv.list(KV.sessions)
192
  count = 0
193
  now = datetime.datetime.utcnow().isoformat() + "Z"
194
  for s in sessions:
195
  if s.get("id") != current_session_id and s.get("status") == "active":
196
+ if project and s.get("project") != project:
197
+ continue
198
+ if agent_id and s.get("agentId") != agent_id:
199
+ continue
200
  s["status"] = "completed"
201
  if "endedAt" not in s:
202
  s["endedAt"] = now
 
483
  try:
484
  conn = self.kv._get_conn()
485
  try:
486
+ cursor = conn.cursor()
487
+ try:
488
  cursor.execute(
489
+ "SELECT DISTINCT scope FROM kv_store WHERE scope LIKE ?",
490
  (scope_prefix + "%",)
491
  )
492
  rows = cursor.fetchall()
 
500
  if to_delete:
501
  for i in range(0, len(to_delete), 50):
502
  chunk_delete = to_delete[i:i + 50]
503
+ format_strings = ','.join(['?'] * len(chunk_delete))
504
  cursor.execute(
505
  f"DELETE FROM kv_store WHERE scope IN ({format_strings})",
506
  tuple(chunk_delete)
507
  )
508
+ conn.commit()
509
+ finally:
510
+ cursor.close()
511
  finally:
512
  conn.close()
513
  except Exception as ex:
 
777
  max_obs = int(os.getenv("MAX_OBS_PER_SESSION", "500"))
778
  if max_obs > 0:
779
  existing = kv.list(KV.observations(session_id))
780
+ actual_obs_count = sum(1 for o in existing if not str(o.get("id", "")).endswith(":raw"))
781
+ if actual_obs_count >= max_obs:
782
  raise ValueError(f"Session observation limit reached ({max_obs})")
783
 
784
  existing_session = kv.get(KV.sessions, session_id)
 
798
  print(f"[image store] failed: {ex}")
799
 
800
  # Set raw observation
801
+ raw["id"] = f"{obs_id}:raw"
802
+ kv.set(KV.observations(session_id), raw["id"], raw)
803
 
804
  # Stream raw observation
805
  broadcast_stream({
 
823
  updates.append({"type": "set", "path": "firstPrompt", "value": trimmed[:200]})
824
  kv.update(KV.sessions, session_id, updates)
825
  else:
 
826
  project = payload.get("project") or "unknown"
827
+ auto_complete_old_active_sessions(kv, session_id, project=project, agent_id=inherited_agent_id)
828
  cwd = payload.get("cwd") or os.getcwd()
829
  trimmed_prompt = None
830
  if isinstance(raw.get("userPrompt"), str):
 
846
  kv.set(KV.sessions, session_id, new_sess)
847
 
848
  # Perform synthetic compression (we default to synthetic)
849
+ raw_for_synthetic = dict(raw)
850
+ raw_for_synthetic["id"] = obs_id
851
+ synthetic = build_synthetic_compression(raw_for_synthetic)
852
  for k in ["hookType", "raw", "toolName", "toolInput", "toolOutput", "userPrompt"]:
853
+ if k in raw_for_synthetic:
854
+ synthetic[k] = raw_for_synthetic[k]
855
  kv.set(KV.observations(session_id), obs_id, synthetic)
856
  _bm25_index.add(synthetic)
857
 
 
878
  return {"observationId": obs_id}
879
 
880
 
881
+ # =====================================================================
882
+ # Folder-Based Observation Ingestion (folder_observe)
883
+ # =====================================================================
884
+
885
+ def folder_observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
886
+ """Ingest a new observation scoped to a (folder_path, agent_id) pair.
887
+
888
+ Required payload fields:
889
+ folderPath: str — absolute path of working directory
890
+ agentId: str — identity of the agent making the observation
891
+ text: str — human-readable observation content
892
+ timestamp: str — ISO 8601 UTC
893
+
894
+ Optional payload fields:
895
+ type: str — observation type (default inferred or "other")
896
+ title: str — short title (auto-generated from text[:80] if absent)
897
+ concepts: list[str] — concept tags (default [])
898
+ files: list[str] — referenced file paths (default [] or extracted)
899
+ importance: int — 1-10 (default 5, clamped)
900
+
901
+ Returns: {"observationId": str}
902
+ Raises: ValueError if required fields are missing or folder cap exceeded.
903
+ """
904
+ # 1. Validate required fields (REQ-008)
905
+ folder_path_raw = payload.get("folderPath")
906
+ agent_id_raw = payload.get("agentId")
907
+ text_raw = payload.get("text")
908
+ timestamp = payload.get("timestamp")
909
+
910
+ if not folder_path_raw:
911
+ raise ValueError("Invalid payload: folderPath is required")
912
+ if not agent_id_raw:
913
+ raise ValueError("Invalid payload: agentId is required")
914
+ if not text_raw:
915
+ raise ValueError("Invalid payload: text is required")
916
+ if not timestamp:
917
+ raise ValueError("Invalid payload: timestamp is required")
918
+
919
+ # 2. Normalize folder_path and validate agent_id (REQ-002, REQ-063, REQ-064, REQ-066)
920
+ folder_path = normalize_folder_path(folder_path_raw)
921
+ agent_id = validate_agent_id(agent_id_raw)
922
+
923
+ # 3. Strip private data and cap text (REQ-009, REQ-007)
924
+ safe_text = strip_private_data(text_raw)
925
+ safe_text = safe_text[:4000]
926
+
927
+ # 10. Enforce MAX_OBS_PER_FOLDER cap before writing (REQ-015)
928
+ max_obs = int(os.getenv("MAX_OBS_PER_FOLDER", "2000"))
929
+ if max_obs > 0:
930
+ existing_obs = kv.list(KV.folder_obs(folder_path, agent_id))
931
+ if len(existing_obs) >= max_obs:
932
+ raise ValueError(f"Folder observation limit reached ({max_obs})")
933
+
934
+ # 4. Generate obs_id (REQ-010)
935
+ obs_id = generate_id("fobs")
936
+
937
+ # 5. Determine optional fields
938
+ obs_type = payload.get("type")
939
+ if not obs_type:
940
+ # Use infer_type with no tool_name; pass "other" as hook_type
941
+ obs_type = infer_type(None, "other")
942
+
943
+ title = payload.get("title")
944
+ if not title:
945
+ title = safe_text[:80]
946
+
947
+ concepts = payload.get("concepts") or []
948
+ if not isinstance(concepts, list):
949
+ concepts = []
950
+
951
+ files = payload.get("files")
952
+ if not isinstance(files, list):
953
+ # Try to extract file refs from the payload itself
954
+ files = extract_files(payload)
955
+
956
+ raw_importance = payload.get("importance")
957
+ if raw_importance is None:
958
+ importance = 5
959
+ else:
960
+ try:
961
+ importance = max(1, min(10, int(raw_importance)))
962
+ except (TypeError, ValueError):
963
+ importance = 5
964
+
965
+ # 5. Build FolderObservation dict (REQ-003)
966
+ obs: Dict[str, Any] = {
967
+ "id": obs_id,
968
+ "folderPath": folder_path,
969
+ "agentId": agent_id,
970
+ "timestamp": timestamp,
971
+ "text": safe_text,
972
+ "type": obs_type,
973
+ "title": title,
974
+ "concepts": concepts,
975
+ "files": files,
976
+ "importance": importance,
977
+ }
978
+
979
+ # 6. Write observation to KV (REQ-001)
980
+ obs_scope = KV.folder_obs(folder_path, agent_id)
981
+ kv.set(obs_scope, obs_id, obs)
982
+
983
+ # 7. Upsert folder metadata (REQ-005)
984
+ meta_scope = KV.folder_meta(folder_path, agent_id)
985
+ meta = kv.get(meta_scope, "meta") or {
986
+ "folderPath": folder_path,
987
+ "agentId": agent_id,
988
+ "obsCount": 0,
989
+ "lastUpdated": timestamp,
990
+ "summary": None,
991
+ }
992
+ meta["obsCount"] = meta.get("obsCount", 0) + 1
993
+ meta["lastUpdated"] = timestamp
994
+ kv.set(meta_scope, "meta", meta)
995
+
996
+ # 8. Upsert global folders index entry (REQ-004, REQ-011)
997
+ index_key = f"{folder_path}:{agent_id}"
998
+ kv.set(KV.folders, index_key, {
999
+ "folderPath": folder_path,
1000
+ "agentId": agent_id,
1001
+ "lastUpdated": meta["lastUpdated"],
1002
+ "obsCount": meta["obsCount"],
1003
+ })
1004
+
1005
+ # 9. Add to BM25 index and vector index (REQ-012)
1006
+ try:
1007
+ _bm25_index.add(obs)
1008
+ except Exception as ex:
1009
+ print(f"[bm25] folder_observe add failed: {ex}")
1010
+
1011
+ comb_text = title + " " + safe_text
1012
+ vector_index_add_guarded(obs_id, folder_path, comb_text, {"kind": "folder_obs", "logId": obs_id})
1013
+
1014
+ if _index_persistence:
1015
+ _index_persistence.schedule_save()
1016
+
1017
+ # 11. Write audit log entry (REQ-014)
1018
+ kv.commit_version(f"folder_observe: {obs_id}", agent_id)
1019
+
1020
+ # 12. Broadcast via WebSocket stream (REQ-013)
1021
+ broadcast_stream({
1022
+ "type": "folder_observation",
1023
+ "folderPath": folder_path,
1024
+ "agentId": agent_id,
1025
+ "data": obs,
1026
+ })
1027
+
1028
+ return {"observationId": obs_id}
1029
+
1030
+
1031
+ # =====================================================================
1032
+ # Folder-Based Search (folder_search)
1033
+ # =====================================================================
1034
+
1035
+ def folder_search(
1036
+ kv: StateKV,
1037
+ query: str,
1038
+ limit: int = 20,
1039
+ folder_path: Optional[str] = None,
1040
+ agent_id: Optional[str] = None,
1041
+ ) -> List[Dict[str, Any]]:
1042
+ """Search across all folder observations (and global memories) using BM25 + vector hybrid search.
1043
+
1044
+ Steps:
1045
+ 1. Run hybrid search to obtain up to ``limit * 2`` candidate obs_ids with scores.
1046
+ 2. Hydrate each candidate by looking up the observation in the KV store:
1047
+ - Iterate ``KV.folders`` index to discover all (folder_path, agent_id) pairs.
1048
+ - For each pair, load observations from ``KV.folder_obs`` and build an obs_id → obs map.
1049
+ 3. Apply ``folder_path`` and ``agent_id`` post-filters to folder observations.
1050
+ 4. Also include matching global memories from ``KV.memories``.
1051
+ 5. Return results sorted by score descending, capped at ``limit``.
1052
+
1053
+ Each result dict contains at minimum: ``folderPath``, ``agentId``, ``score``,
1054
+ plus all fields from the underlying FolderObservation or Memory object.
1055
+
1056
+ Requirements: REQ-016, REQ-017, REQ-018, REQ-019
1057
+ """
1058
+ if not query or not query.strip():
1059
+ return []
1060
+
1061
+ candidates = _hybrid_search.search(query, limit * 2)
1062
+
1063
+ # --- Build a comprehensive obs_id → observation mapping from all folder pairs ---
1064
+ # First, collect all known (folder_path, agent_id) pairs from the folders index.
1065
+ index_entries = kv.list(KV.folders)
1066
+
1067
+ obs_map: Dict[str, Dict[str, Any]] = {} # obs_id → observation dict
1068
+
1069
+ for entry in index_entries:
1070
+ fp = entry.get("folderPath", "")
1071
+ aid = entry.get("agentId", "")
1072
+ if not fp or not aid:
1073
+ continue
1074
+
1075
+ # Apply early filter: skip pairs that don't match the requested filters
1076
+ if folder_path is not None and fp != folder_path:
1077
+ continue
1078
+ if agent_id is not None and aid != agent_id:
1079
+ continue
1080
+
1081
+ obs_scope = KV.folder_obs(fp, aid)
1082
+ pair_obs_list = kv.list(obs_scope)
1083
+ for obs in pair_obs_list:
1084
+ oid = obs.get("id")
1085
+ if oid:
1086
+ obs_map[oid] = obs
1087
+
1088
+ # --- Also build a memory map from KV.memories for cross-inclusion (REQ-018) ---
1089
+ # We include memories regardless of folder/agent filters since they are global.
1090
+ all_memories = kv.list(KV.memories)
1091
+ memory_map: Dict[str, Dict[str, Any]] = {}
1092
+ for mem in all_memories:
1093
+ mid = mem.get("id")
1094
+ if mid:
1095
+ memory_map[mid] = mem
1096
+
1097
+ # --- Hydrate candidates from the search results ---
1098
+ results: List[Dict[str, Any]] = []
1099
+ seen_ids: set = set()
1100
+
1101
+ for candidate in candidates:
1102
+ obs_id = candidate.get("obsId") or candidate.get("id", "")
1103
+ score = candidate.get("combinedScore") or candidate.get("score", 0.0)
1104
+
1105
+ if obs_id in seen_ids:
1106
+ continue
1107
+
1108
+ # Try folder observation first
1109
+ obs = obs_map.get(obs_id)
1110
+ if obs is not None:
1111
+ result = dict(obs)
1112
+ result["score"] = score
1113
+ # Ensure folderPath and agentId are present (REQ-019)
1114
+ result.setdefault("folderPath", obs.get("folderPath", ""))
1115
+ result.setdefault("agentId", obs.get("agentId", ""))
1116
+ results.append(result)
1117
+ seen_ids.add(obs_id)
1118
+ continue
1119
+
1120
+ # Try global memory (REQ-018)
1121
+ mem = memory_map.get(obs_id)
1122
+ if mem is not None:
1123
+ result = dict(mem)
1124
+ result["score"] = score
1125
+ # Global memories have no folder scope; use empty strings as sentinels
1126
+ result.setdefault("folderPath", "")
1127
+ result.setdefault("agentId", mem.get("agentId") or "")
1128
+ results.append(result)
1129
+ seen_ids.add(obs_id)
1130
+ continue
1131
+
1132
+ # obs_id not found in either map — skip (stale index entry)
1133
+
1134
+ # Sort by score descending and cap at limit (REQ-016)
1135
+ results.sort(key=lambda r: r.get("score", 0.0), reverse=True)
1136
+ return results[:limit]
1137
+
1138
+
1139
+ def folder_timeline(
1140
+ kv: StateKV,
1141
+ limit: int = 100,
1142
+ folder_path: Optional[str] = None,
1143
+ agent_id: Optional[str] = None,
1144
+ before: Optional[str] = None,
1145
+ after: Optional[str] = None,
1146
+ ) -> List[Dict[str, Any]]:
1147
+ """Return a folder activity feed — observations sorted by timestamp descending.
1148
+
1149
+ Algorithm:
1150
+ 1. List all (folder, agent) pairs from ``KV.folders``.
1151
+ 2. Apply ``folder_path`` exact-match filter if provided.
1152
+ 3. Apply ``agent_id`` exact-match filter if provided.
1153
+ 4. For each remaining pair, load all observations from
1154
+ ``KV.folder_obs(entry["folderPath"], entry["agentId"])``.
1155
+ 5. Apply ``before`` ISO timestamp upper-bound filter:
1156
+ exclude obs where ``obs["timestamp"] >= before``.
1157
+ 6. Apply ``after`` ISO timestamp lower-bound filter:
1158
+ exclude obs where ``obs["timestamp"] <= after``.
1159
+ 7. Sort all collected observations by ``timestamp`` descending.
1160
+ 8. Return the first ``limit`` entries.
1161
+
1162
+ Postconditions (REQ-071):
1163
+ - ``len(result) <= limit``
1164
+ - All results satisfy the provided filter conditions.
1165
+ - Results are in non-increasing timestamp order.
1166
+
1167
+ Requirements: REQ-020, REQ-021, REQ-022
1168
+ """
1169
+ # Step 1 — load the global folders index
1170
+ index_entries = kv.list(KV.folders)
1171
+
1172
+ # Step 2 — filter by folder_path exact match (REQ-021)
1173
+ if folder_path is not None:
1174
+ index_entries = [e for e in index_entries if e.get("folderPath") == folder_path]
1175
+
1176
+ # Step 3 — filter by agent_id exact match (REQ-021)
1177
+ if agent_id is not None:
1178
+ index_entries = [e for e in index_entries if e.get("agentId") == agent_id]
1179
+
1180
+ all_obs: List[Dict[str, Any]] = []
1181
+
1182
+ for entry in index_entries:
1183
+ fp = entry.get("folderPath", "")
1184
+ aid = entry.get("agentId", "")
1185
+ if not fp or not aid:
1186
+ continue
1187
+
1188
+ # Step 4 — load observations for this pair
1189
+ obs_scope = KV.folder_obs(fp, aid)
1190
+ obs_list = kv.list(obs_scope)
1191
+
1192
+ # Step 5 — apply before filter: exclude obs where timestamp >= before (REQ-021)
1193
+ if before is not None:
1194
+ obs_list = [o for o in obs_list if o.get("timestamp", "") < before]
1195
+
1196
+ # Step 6 — apply after filter: exclude obs where timestamp <= after (REQ-021)
1197
+ if after is not None:
1198
+ obs_list = [o for o in obs_list if o.get("timestamp", "") > after]
1199
+
1200
+ all_obs.extend(obs_list)
1201
+
1202
+ # Step 7 — sort by timestamp descending (REQ-071)
1203
+ all_obs.sort(key=lambda o: o.get("timestamp", ""), reverse=True)
1204
+
1205
+ # Step 8 — return at most limit entries (REQ-022)
1206
+ return all_obs[:limit]
1207
+
1208
  # =====================================================================
1209
  # Memory System (Remember, Forget, Evolve)
1210
  # =====================================================================
 
1308
 
1309
 
1310
  def forget(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
1311
+ """Delete a global memory, a folder (folder_path+agent_id), or specific observations.
1312
+
1313
+ Dispatch rules (REQ-029, REQ-030, REQ-031, REQ-032, REQ-033):
1314
+ 1. ``memoryId`` present → delete that global memory from KV.memories
1315
+ 2. ``folderPath + agentId`` present,
1316
+ no ``observationIds`` → delete ALL observations for that pair,
1317
+ remove BM25 entries, delete folder_meta,
1318
+ remove from KV.folders index
1319
+ 3. ``folderPath + agentId +
1320
+ observationIds`` present → delete only the listed observations,
1321
+ decrement obsCount in metadata
1322
+
1323
+ Legacy session-based paths are preserved for backward compatibility.
1324
+ """
1325
  memory_id = data.get("memoryId")
1326
  session_id = data.get("sessionId")
1327
+ folder_path_raw = data.get("folderPath")
1328
+ agent_id_raw = data.get("agentId")
1329
  obs_ids = data.get("observationIds") or []
1330
  deleted = 0
1331
  deleted_mem_ids = []
1332
  deleted_obs_ids = []
1333
  deleted_session = False
1334
 
1335
+ # ------------------------------------------------------------------
1336
+ # Path 1: delete a global memory (REQ-029)
1337
+ # ------------------------------------------------------------------
1338
  if memory_id:
1339
  mem = kv.get(KV.memories, memory_id)
1340
  kv.delete(KV.memories, memory_id)
 
1349
  deleted_mem_ids.append(memory_id)
1350
  deleted += 1
1351
 
1352
+ # ------------------------------------------------------------------
1353
+ # Path 2 & 3: folder-based deletion (REQ-030, REQ-031, REQ-032, REQ-033)
1354
+ # ------------------------------------------------------------------
1355
+ if folder_path_raw and agent_id_raw:
1356
+ try:
1357
+ fp = normalize_folder_path(folder_path_raw)
1358
+ aid = validate_agent_id(agent_id_raw)
1359
+ except ValueError as exc:
1360
+ return {"success": False, "error": str(exc), "deleted": 0}
1361
+
1362
+ obs_scope = KV.folder_obs(fp, aid)
1363
+ meta_scope = KV.folder_meta(fp, aid)
1364
+ index_key = f"{fp}:{aid}"
1365
+
1366
+ if obs_ids:
1367
+ # ----------------------------------------------------------
1368
+ # Path 3: partial deletion — only the listed obs IDs (REQ-031)
1369
+ # ----------------------------------------------------------
1370
+ partial_deleted = 0
1371
+ for oid in obs_ids:
1372
+ existed = kv.delete(obs_scope, oid)
1373
+ if existed:
1374
+ _bm25_index.remove(oid)
1375
+ if _vector_index:
1376
+ _vector_index.remove(oid)
1377
+ deleted_obs_ids.append(oid)
1378
+ partial_deleted += 1
1379
+ deleted += 1
1380
+
1381
+ # Decrement obsCount in metadata
1382
+ if partial_deleted > 0:
1383
+ meta = kv.get(meta_scope, "meta")
1384
+ if meta and isinstance(meta, dict):
1385
+ current_count = meta.get("obsCount", 0)
1386
+ meta["obsCount"] = max(0, current_count - partial_deleted)
1387
+ kv.set(meta_scope, "meta", meta)
1388
+ # Also sync the folders index entry
1389
+ index_entry = kv.get(KV.folders, index_key)
1390
+ if index_entry and isinstance(index_entry, dict):
1391
+ index_entry["obsCount"] = meta["obsCount"]
1392
+ kv.set(KV.folders, index_key, index_entry)
1393
+ else:
1394
+ # ----------------------------------------------------------
1395
+ # Path 2: full pair deletion (REQ-030, REQ-032)
1396
+ # ----------------------------------------------------------
1397
+ all_obs = kv.list(obs_scope)
1398
+ for obs in all_obs:
1399
+ obs_id = obs.get("id")
1400
+ if obs_id:
1401
+ kv.delete(obs_scope, obs_id)
1402
+ _bm25_index.remove(obs_id)
1403
+ if _vector_index:
1404
+ _vector_index.remove(obs_id)
1405
+ deleted_obs_ids.append(obs_id)
1406
+ deleted += 1
1407
+
1408
+ # Delete folder metadata entry
1409
+ kv.delete(meta_scope, "meta")
1410
+
1411
+ # Remove from global folders index
1412
+ kv.delete(KV.folders, index_key)
1413
+
1414
+ # ------------------------------------------------------------------
1415
+ # Legacy: session-based deletion (unchanged)
1416
+ # ------------------------------------------------------------------
1417
  if session_id and obs_ids:
1418
  for oid in obs_ids:
1419
+ base_oid = oid.replace(":raw", "")
1420
+ obs = kv.get(KV.observations(session_id), base_oid)
1421
+ raw_obs = kv.get(KV.observations(session_id), f"{base_oid}:raw")
1422
+
1423
+ kv.delete(KV.observations(session_id), base_oid)
1424
+ kv.delete(KV.observations(session_id), f"{base_oid}:raw")
1425
+
1426
+ for o in (obs, raw_obs):
1427
+ if o:
1428
+ img = o.get("imageData") or o.get("imageRef")
1429
+ if img:
1430
+ refs = kv.get(KV.imageRefs, img) or 0
1431
+ if refs > 0:
1432
+ kv.set(KV.imageRefs, img, refs - 1)
1433
+
1434
+ _bm25_index.remove(base_oid)
1435
+ _bm25_index.remove(f"{base_oid}:raw")
1436
  if _vector_index:
1437
+ _vector_index.remove(base_oid)
1438
+ _vector_index.remove(f"{base_oid}:raw")
1439
  deleted_obs_ids.append(oid)
1440
  deleted += 1
1441
 
1442
+ if session_id and not obs_ids and not memory_id and not folder_path_raw:
1443
  obs_list = kv.list(KV.observations(session_id))
1444
  for obs in obs_list:
1445
  kv.delete(KV.observations(session_id), obs["id"])
 
1467
  "mem::forget",
1468
  deleted_mem_ids + deleted_obs_ids,
1469
  {
1470
+ "memoryId": memory_id,
1471
  "sessionId": session_id,
1472
+ "folderPath": folder_path_raw,
1473
+ "agentId": agent_id_raw,
1474
  "deleted": deleted,
1475
  "memoriesDeleted": len(deleted_mem_ids),
1476
  "observationsDeleted": len(deleted_obs_ids),
 
1478
  "reason": "user-initiated forget"
1479
  }
1480
  )
1481
+
 
1482
  agent_id = data.get("agentId") or get_agent_id()
1483
+ commit_if_enabled(kv, f"Forget: memory_id={memory_id} folder_path={folder_path_raw}", agent_id)
1484
 
1485
  return {"success": True, "deleted": deleted}
1486
 
 
1506
  blocks = []
1507
 
1508
  # 1. Pinned Slots
1509
+ pinned_slots = list_pinned_slots(kv, project)
1510
  slot_content = render_pinned_context(pinned_slots)
1511
  if slot_content:
1512
  blocks.append({
 
1719
  slot["updatedAt"] = now
1720
  kv.set(target, tmpl["label"], slot)
1721
 
1722
+ def list_pinned_slots(kv: StateKV, project: Optional[str] = None) -> List[Dict[str, Any]]:
1723
+ p_slots = kv.list(project_slots_scope(kv, project))
1724
  g_slots = kv.list(KV.globalSlots)
1725
  merged = {}
1726
  for s in g_slots:
 
1741
  lines.append("")
1742
  return "\n".join(lines)
1743
 
1744
+ def slot_list(kv: StateKV, project: Optional[str] = None) -> Dict[str, Any]:
1745
+ p_slots = kv.list(project_slots_scope(kv, project))
1746
  g_slots = kv.list(KV.globalSlots)
1747
  merged = {}
1748
  for s in g_slots:
 
1752
  slots = sorted(list(merged.values()), key=lambda s: s["label"])
1753
  return {"success": True, "slots": slots}
1754
 
1755
+ def slot_get(kv: StateKV, label: str, project: Optional[str] = None) -> Dict[str, Any]:
1756
+ p_scope = project_slots_scope(kv, project)
1757
+ project_s = kv.get(p_scope, label)
1758
+ if project_s:
1759
+ return {"success": True, "slot": project_s, "scope": "project"}
1760
  global_s = kv.get(KV.globalSlots, label)
1761
  if global_s:
1762
  return {"success": True, "slot": global_s, "scope": "global"}
 
1781
 
1782
  description = data.get("description") or ""
1783
  pinned = data.get("pinned", True)
1784
+ project = data.get("project")
1785
 
1786
+ target_kv = KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
1787
  existing = kv.get(target_kv, label)
1788
  if existing:
1789
  return {"success": False, "error": f"slot already exists in {scope} scope"}
 
1809
 
1810
  return {"success": True, "slot": slot}
1811
 
1812
+ def slot_append(kv: StateKV, label: str, text: str, agent_id: Optional[str] = None, project: Optional[str] = None) -> Dict[str, Any]:
1813
+ res = slot_get(kv, label, project)
1814
  if not res.get("success"):
1815
  return {"success": False, "error": "slot not found"}
1816
 
1817
  slot = res["slot"]
1818
  scope = res["scope"]
1819
+ target_kv = KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
1820
 
1821
  if slot.get("readOnly"):
1822
  return {"success": False, "error": "slot is read-only"}
 
1845
 
1846
  return {"success": True, "slot": slot, "size": len(next_content)}
1847
 
1848
+ def slot_replace(kv: StateKV, label: str, content: str, agent_id: Optional[str] = None, project: Optional[str] = None) -> Dict[str, Any]:
1849
+ res = slot_get(kv, label, project)
1850
  if not res.get("success"):
1851
  return {"success": False, "error": "slot not found"}
1852
 
1853
  slot = res["slot"]
1854
  scope = res["scope"]
1855
+ target_kv = KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
1856
 
1857
  if slot.get("readOnly"):
1858
  return {"success": False, "error": "slot is read-only"}
 
1878
 
1879
  return {"success": True, "slot": slot, "size": len(content)}
1880
 
1881
+ def slot_delete(kv: StateKV, label: str, agent_id: Optional[str] = None, project: Optional[str] = None) -> Dict[str, Any]:
1882
+ res = slot_get(kv, label, project)
1883
  if not res.get("success"):
1884
  return {"success": False, "error": "slot not found"}
1885
 
1886
  slot = res["slot"]
1887
  scope = res["scope"]
1888
+ target_kv = KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
1889
 
1890
  if slot.get("readOnly"):
1891
  return {"success": False, "error": "slot is read-only"}
 
1900
 
1901
 
1902
  def slot_reflect(kv: StateKV, session_id: str, max_obs: int = 50) -> Dict[str, Any]:
1903
+ session = kv.get(KV.sessions, session_id)
1904
+ project = session.get("project") if session else None
1905
+
1906
  observations = kv.list(KV.observations(session_id))
1907
  if not observations:
1908
  return {"success": True, "applied": 0, "reason": "no observations for session"}
 
1929
  now = datetime.datetime.utcnow().isoformat() + "Z"
1930
 
1931
  if pending_lines:
1932
+ res = slot_get(kv, "pending_items", project)
1933
  if res.get("success"):
1934
  slot = res["slot"]
1935
  scope = res["scope"]
1936
+ target_kv = scopeKv = KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
1937
  already = set((slot.get("content") or "").split("\n"))
1938
  fresh = [l for l in pending_lines if l not in already]
1939
  if fresh:
 
1948
  applied += 1
1949
 
1950
  if pattern_counts:
1951
+ res = slot_get(kv, "session_patterns", project)
1952
  if res.get("success"):
1953
  slot = res["slot"]
1954
  scope = res["scope"]
1955
+ target_kv = KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
1956
  summary = [f"last reflection: {now}"]
1957
  for k, v in pattern_counts.items():
1958
  summary.append(f"- {k}: {v} in last {len(recent)} observations")
 
1966
  applied += 1
1967
 
1968
  if files:
1969
+ res = slot_get(kv, "project_context", project)
1970
  if res.get("success"):
1971
  slot = res["slot"]
1972
  scope = res["scope"]
1973
+ target_kv = KV.globalSlots if scope == "global" else project_slots_scope(kv, project)
1974
  already = slot.get("content") or ""
1975
  fresh = [f for f in files if f not in already][:20]
1976
  if fresh:
 
2197
  if _vector_index:
2198
  _vector_index.clear()
2199
 
 
 
2200
  total_indexed = 0
2201
 
2202
+ # ---- Path A: folder-based observations (new schema) ----
2203
+ folder_pairs = kv.list(KV.folders)
2204
+ for entry in folder_pairs:
2205
+ fp = entry.get("folderPath")
2206
+ aid = entry.get("agentId")
2207
+ if not fp or not aid:
2208
  continue
2209
+ obs_list = kv.list(KV.folder_obs(fp, aid))
2210
  for obs in obs_list:
2211
+ if not obs.get("id"):
2212
+ continue
2213
+ _bm25_index.add(obs)
2214
+ comb_text = (obs.get("title") or "") + " " + (obs.get("text") or "")
2215
+ vector_index_add_guarded(obs["id"], fp, comb_text.strip(), {"kind": "folder_observation", "logId": obs["id"]})
2216
+ total_indexed += 1
2217
+
2218
+ # ---- Path B: session-based observations (legacy schema — kept for old data) ----
2219
+ try:
2220
+ sessions = kv.list(KV.sessions)
2221
+ for sess in sessions:
2222
+ sid = sess.get("id")
2223
+ if not sid:
2224
+ continue
2225
+ obs_list = kv.list(KV.observations(sid))
2226
+ for obs in obs_list:
2227
+ # Only index compressed (non-raw) observations
2228
+ if obs.get("title") and obs.get("narrative"):
2229
+ # Skip if already indexed via folder path (same obs id)
2230
+ if _bm25_index.has(obs["id"]):
2231
+ continue
2232
+ _bm25_index.add(obs)
2233
+ comb_text = obs["title"] + " " + obs["narrative"]
2234
+ vector_index_add_guarded(obs["id"], sid, comb_text, {"kind": "observation", "logId": obs["id"]})
2235
+ total_indexed += 1
2236
+ except Exception as e:
2237
+ print(f"[rebuild_index] session-based backfill skipped: {e}")
2238
+
2239
+ # ---- Backfill BM25 with global memories (both schemas) ----
2240
  memories = kv.list(KV.memories)
2241
  for mem in memories:
2242
  if mem.get("isLatest") is False:
 
2280
  return s
2281
 
2282
  def create_session(kv: StateKV, session: Dict[str, Any]) -> Dict[str, Any]:
2283
+ auto_complete_old_active_sessions(kv, session["id"], project=session.get("project"), agent_id=session.get("agentId"))
2284
  kv.set(KV.sessions, session["id"], session)
2285
  return session
2286
 
 
2434
  def export_data(kv: StateKV, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
2435
  if data is None:
2436
  data = {}
2437
+
2438
+ exported_at = datetime.datetime.utcnow().isoformat() + "Z"
2439
+
2440
+ # ---- v2 folder-based export (primary path) ----
2441
+ folder_pairs = kv.list(KV.folders)
2442
+ folders_export = []
2443
+ for entry in folder_pairs:
2444
+ fp = entry.get("folderPath")
2445
+ aid = entry.get("agentId")
2446
+ if not fp or not aid:
2447
+ continue
2448
+ meta = kv.get(KV.folder_meta(fp, aid), "meta") or {
2449
+ "folderPath": fp,
2450
+ "agentId": aid,
2451
+ "lastUpdated": entry.get("lastUpdated", ""),
2452
+ "obsCount": entry.get("obsCount", 0),
2453
+ }
2454
+ observations = kv.list(KV.folder_obs(fp, aid))
2455
+ folders_export.append({
2456
+ "folderPath": fp,
2457
+ "agentId": aid,
2458
+ "meta": meta,
2459
+ "observations": observations,
2460
+ })
2461
+
2462
  memories = kv.list(KV.memories)
2463
+
2464
+ return {
2465
+ "folders": folders_export,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2466
  "memories": memories,
2467
+ "exportedAt": exported_at,
2468
+ "version": "2.0",
2469
+ }
2470
+
2471
+
2472
+ def migrate_sessions_to_folders(kv: StateKV, dry_run: bool = False) -> Dict[str, Any]:
2473
+ """Migrate legacy session-based observations to folder-based storage.
2474
+ Non-destructive: old mem:sessions / mem:obs:* scopes are never deleted.
2475
+ """
2476
+ sessions = kv.list(KV.sessions)
2477
+ migrated_sessions = 0
2478
+ migrated_observations = 0
2479
+ errors = []
2480
+
2481
+ for session in sessions:
2482
+ session_id = session.get('id')
2483
+ if not session_id:
2484
+ continue
2485
+ try:
2486
+ fp_raw = session.get('cwd') or session.get('project') or 'unknown'
2487
+ aid = (session.get('agentId') or 'unknown').strip()[:_MAX_PATH_LEN]
2488
+ try:
2489
+ fp = normalize_folder_path(fp_raw)
2490
+ except ValueError:
2491
+ fp = 'unknown'
2492
+
2493
+ obs_list = kv.list(KV.observations(session_id))
2494
+ session_obs_count = 0
2495
+ for obs in obs_list:
2496
+ obs_id = obs.get('id', '')
2497
+ if obs_id.endswith(':raw'):
2498
+ continue
2499
+ folder_obs = {
2500
+ 'id': obs_id,
2501
+ 'folderPath': fp,
2502
+ 'agentId': aid,
2503
+ 'timestamp': obs.get('timestamp', ''),
2504
+ 'text': obs.get('narrative') or obs.get('raw') or obs.get('title') or '',
2505
+ 'type': obs.get('type', 'other'),
2506
+ 'title': obs.get('title', ''),
2507
+ 'concepts': obs.get('concepts') or [],
2508
+ 'files': obs.get('files') or [],
2509
+ 'importance': obs.get('importance', 5),
2510
+ }
2511
+ if isinstance(folder_obs['text'], dict):
2512
+ import json as _json
2513
+ folder_obs['text'] = _json.dumps(folder_obs['text'])[:4000]
2514
+ folder_obs['text'] = str(folder_obs['text'])[:4000]
2515
+
2516
+ if not dry_run:
2517
+ kv.set(KV.folder_obs(fp, aid), obs_id, folder_obs)
2518
+ session_obs_count += 1
2519
+ migrated_observations += 1
2520
+
2521
+ if not dry_run and session_obs_count > 0:
2522
+ meta_scope = KV.folder_meta(fp, aid)
2523
+ meta = kv.get(meta_scope, 'meta') or {
2524
+ 'folderPath': fp, 'agentId': aid, 'obsCount': 0,
2525
+ 'lastUpdated': session.get('updatedAt', ''), 'summary': None,
2526
+ }
2527
+ meta['obsCount'] = meta.get('obsCount', 0) + session_obs_count
2528
+ meta['lastUpdated'] = session.get('updatedAt', '') or meta['lastUpdated']
2529
+ kv.set(meta_scope, 'meta', meta)
2530
+
2531
+ index_key = f'{fp}:{aid}'
2532
+ kv.set(KV.folders, index_key, {
2533
+ 'folderPath': fp,
2534
+ 'agentId': aid,
2535
+ 'lastUpdated': meta['lastUpdated'],
2536
+ 'obsCount': meta['obsCount'],
2537
+ })
2538
+
2539
+ migrated_sessions += 1
2540
+ except Exception as e:
2541
+ errors.append({'sessionId': session_id, 'error': str(e)})
2542
+
2543
+ return {
2544
+ 'migrated_sessions': migrated_sessions,
2545
+ 'migrated_observations': migrated_observations,
2546
+ 'errors': errors,
2547
+ 'dry_run': dry_run,
2548
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2549
 
2550
 
2551
  def set_project_profile(kv: StateKV, project: str, profile: Dict[str, Any]) -> Dict[str, Any]:
 
2684
  _vector_index.remove(mem_id)
2685
 
2686
  for sid, obs_id in evicted_observations:
2687
+ base_oid = obs_id.replace(":raw", "")
2688
+ obs = kv.get(KV.observations(sid), base_oid)
2689
+ raw_obs = kv.get(KV.observations(sid), f"{base_oid}:raw")
2690
+
2691
+ kv.delete(KV.observations(sid), base_oid)
2692
+ kv.delete(KV.observations(sid), f"{base_oid}:raw")
2693
+
2694
+ for o in (obs, raw_obs):
2695
+ if o:
2696
+ img = o.get("imageData") or o.get("imageRef")
2697
+ if img:
2698
+ refs = kv.get(KV.imageRefs, img) or 0
2699
+ if refs > 0:
2700
+ kv.set(KV.imageRefs, img, refs - 1)
2701
+
2702
+ _bm25_index.remove(base_oid)
2703
+ _bm25_index.remove(f"{base_oid}:raw")
2704
  if _vector_index:
2705
+ _vector_index.remove(base_oid)
2706
+ _vector_index.remove(f"{base_oid}:raw")
2707
 
2708
  if evicted_memories or evicted_observations:
2709
  if _index_persistence:
 
2736
  conn.close()
2737
  except Exception:
2738
  db_status = "disconnected"
2739
+
2740
+ # ---- Folder-based counts ----
2741
+ folder_count = 0
2742
+ agent_count = 0
2743
+ pair_count = 0
2744
+ observation_count = 0
2745
+ try:
2746
+ folder_pairs = kv.list(KV.folders)
2747
+ pair_count = len(folder_pairs)
2748
+ unique_folders: Set[str] = set()
2749
+ unique_agents: Set[str] = set()
2750
+ for entry in folder_pairs:
2751
+ fp = entry.get("folderPath")
2752
+ aid = entry.get("agentId")
2753
+ if fp:
2754
+ unique_folders.add(fp)
2755
+ if aid:
2756
+ unique_agents.add(aid)
2757
+ # Use the stored obsCount in the index entry for efficiency;
2758
+ # fall back to 0 if missing.
2759
+ observation_count += int(entry.get("obsCount") or 0)
2760
+ folder_count = len(unique_folders)
2761
+ agent_count = len(unique_agents)
2762
+ except Exception as e:
2763
+ print(f"[health_check] folder count failed: {e}")
2764
+
2765
+ memory_count = 0
2766
+ try:
2767
+ memory_count = len(kv.list(KV.memories))
2768
+ except Exception:
2769
+ pass
2770
+
2771
+ bm25_index_size = 0
2772
+ try:
2773
+ bm25_index_size = _bm25_index.size
2774
+ except Exception:
2775
+ pass
2776
+
2777
+ vector_index_size = 0
2778
+ try:
2779
+ if _vector_index:
2780
+ vector_index_size = _vector_index.size
2781
+ except Exception:
2782
+ pass
2783
+
2784
  return {
2785
+ "status": "ok" if db_status == "connected" else "degraded",
2786
+ "folderCount": folder_count,
2787
+ "agentCount": agent_count,
2788
+ "pairCount": pair_count,
2789
+ "observationCount": observation_count,
2790
+ "memoryCount": memory_count,
2791
+ "bm25IndexSize": bm25_index_size,
2792
+ "vectorIndexSize": vector_index_size,
2793
+ "dbPath": kv.db_path,
2794
  }
2795
 
2796
  def strip_xml_wrappers(raw: str) -> str:
 
2923
  "concepts": get_xml_children(cleaned, "concepts", "concept"),
2924
  })
2925
  except Exception as e:
2926
+ last_error = str(e)
2927
  print(f"[summarize] Chunk {idx+1} failed: {e}")
2928
 
2929
  if not partial_summaries:
2930
+ return {"success": False, "error": f"No chunks summarized successfully. Last error: {last_error}"}
2931
 
2932
  if len(partial_summaries) == 1:
2933
  final_summary = {
 
3121
  if project:
3122
  evolved["project"] = project
3123
  kv.set(KV.memories, evolved["id"], evolved)
3124
+ try:
3125
+ _bm25_index.add(memory_to_observation(evolved))
3126
+ if existing_match:
3127
+ _bm25_index.remove(existing_match["id"])
3128
+ except Exception:
3129
+ pass
3130
+ comb_text = evolved["title"] + " " + evolved["content"]
3131
+ vector_index_add_guarded(evolved["id"], "memory", comb_text, {"kind": "memory", "logId": evolved["id"]})
3132
+ if _vector_index and existing_match:
3133
+ try:
3134
+ _vector_index.remove(existing_match["id"])
3135
+ except Exception:
3136
+ pass
3137
  consolidated_count += 1
3138
  else:
3139
  memory = {
 
3154
  if project:
3155
  memory["project"] = project
3156
  kv.set(KV.memories, memory["id"], memory)
3157
+ try:
3158
+ _bm25_index.add(memory_to_observation(memory))
3159
+ except Exception:
3160
+ pass
3161
+ comb_text = memory["title"] + " " + memory["content"]
3162
+ vector_index_add_guarded(memory["id"], "memory", comb_text, {"kind": "memory", "logId": memory["id"]})
3163
  consolidated_count += 1
3164
 
3165
  except Exception as e:
 
3319
  "patternsAnalyzed": len(patterns)
3320
  }
3321
  }
3322
+ if _index_persistence and consolidated_count > 0:
3323
+ _index_persistence.schedule_save()
3324
  safe_audit(kv, "consolidate", "mem::consolidate-pipeline", [], res_summary)
3325
  commit_if_enabled(kv, f"Consolidation complete: consolidated={consolidated_count}, facts={new_facts_count}, procs={new_procs_count}", "system")
3326
  return res_summary
3327
 
3328
+ # =====================================================================
3329
+ # Folder Graph
3330
+ # =====================================================================
3331
+
3332
+ def folder_color(path: str) -> str:
3333
+ """Hash a folder path string to an HSL hex color.
3334
+
3335
+ Replicates the JS ``folderColor(id)`` function in src/viewer/index.html
3336
+ exactly, using the light-mode lightness range (38 + h%14).
3337
+
3338
+ Algorithm (matches JS):
3339
+ h = 0
3340
+ for each char: h = (h * 31 + ord(char)) & 0xfffffff
3341
+ hue = (h % 360 + 360) % 360
3342
+ sat = 55 + (h % 25) # percent, 55-79
3343
+ lig = 38 + (h % 14) # percent, 38-51 (light mode)
3344
+ convert HSL → RGB → hex
3345
+ """
3346
+ h = 0
3347
+ for ch in path:
3348
+ h = (h * 31 + ord(ch)) & 0xFFFFFFF
3349
+
3350
+ hue = (h % 360 + 360) % 360
3351
+ sat_pct = 55 + (h % 25)
3352
+ lig_pct = 38 + (h % 14)
3353
+
3354
+ # HSL → RGB conversion (same formula as in JS)
3355
+ s = sat_pct / 100.0
3356
+ l = lig_pct / 100.0
3357
+ a = s * min(l, 1 - l)
3358
+
3359
+ def f(n: int) -> str:
3360
+ k = (n + hue / 30) % 12
3361
+ c = l - a * max(min(k - 3, 9 - k, 1), -1)
3362
+ return format(round(255 * c), '02x')
3363
+
3364
+ return '#' + f(0) + f(8) + f(4)
3365
+
3366
+
3367
+ def folder_graph_build(kv: StateKV) -> Dict[str, Any]:
3368
+ """Build graph data for the viewer's Graph tab.
3369
+
3370
+ Reads all (folder_path, agent_id) pairs from ``KV.folders``,
3371
+ aggregates per-folder node metadata, loads observations to collect
3372
+ text for cross-reference edge detection, then emits three edge types:
3373
+
3374
+ - ``same-parent``: two folders share the same ``os.path.dirname``
3375
+ - ``cross-ref``: folder A's combined obs text contains folder B's path
3376
+ - ``agent-shared``: two folders share a common agentId
3377
+
3378
+ Returns:
3379
+ {"nodes": [...], "edges": [...]}
3380
+
3381
+ Each node::
3382
+
3383
+ {
3384
+ "id": folderPath,
3385
+ "label": basename(folderPath),
3386
+ "folderPath": folderPath,
3387
+ "agentIds": [...],
3388
+ "obsCount": int,
3389
+ "color": "#rrggbb",
3390
+ }
3391
+
3392
+ Each edge::
3393
+
3394
+ {
3395
+ "source": folderPath,
3396
+ "target": folderPath,
3397
+ "type": "same-parent" | "cross-ref" | "agent-shared",
3398
+ # agent-shared only:
3399
+ "agentId": str,
3400
+ }
3401
+
3402
+ Edges are deduplicated on (source, target, type).
3403
+ """
3404
+ index_entries = kv.list(KV.folders)
3405
+
3406
+ # --- Build folder_map and collect obs text per (folder, agent) pair ---
3407
+ # folder_map: folderPath -> {"folderPath", "agentIds": set, "obsCount", "color"}
3408
+ folder_map: Dict[str, Dict[str, Any]] = {}
3409
+ # pair_obs_texts: (folder_path, agent_id) -> combined text string
3410
+ pair_obs_texts: Dict[Tuple[str, str], str] = {}
3411
+
3412
+ for entry in index_entries:
3413
+ fp = entry.get("folderPath", "")
3414
+ aid = entry.get("agentId", "")
3415
+ if not fp:
3416
+ continue
3417
+
3418
+ if fp not in folder_map:
3419
+ folder_map[fp] = {
3420
+ "folderPath": fp,
3421
+ "agentIds": set(),
3422
+ "obsCount": 0,
3423
+ "color": folder_color(fp),
3424
+ }
3425
+
3426
+ folder_map[fp]["agentIds"].add(aid)
3427
+ folder_map[fp]["obsCount"] += entry.get("obsCount", 0)
3428
+
3429
+ # Load observations to build combined text for cross-ref detection
3430
+ obs_scope = KV.folder_obs(fp, aid)
3431
+ obs_list = kv.list(obs_scope)
3432
+ combined_parts = []
3433
+ for obs in obs_list:
3434
+ text = obs.get("text") or ""
3435
+ title = obs.get("title") or ""
3436
+ combined_parts.append(f"{text} {title}")
3437
+ pair_obs_texts[(fp, aid)] = " ".join(combined_parts)
3438
+
3439
+ # --- Build nodes ---
3440
+ nodes = []
3441
+ for fp, info in folder_map.items():
3442
+ nodes.append({
3443
+ "id": fp,
3444
+ "label": os.path.basename(fp) or fp,
3445
+ "folderPath": fp,
3446
+ "agentIds": sorted(info["agentIds"]),
3447
+ "obsCount": info["obsCount"],
3448
+ "color": info["color"],
3449
+ })
3450
+
3451
+ # --- Build edges ---
3452
+ edges: List[Dict[str, Any]] = []
3453
+ # Deduplicate on (source, target, type)
3454
+ seen_edges: Set[Tuple[str, str, str]] = set()
3455
+
3456
+ def add_edge(edge: Dict[str, Any]) -> None:
3457
+ key = (edge["source"], edge["target"], edge["type"])
3458
+ if key not in seen_edges:
3459
+ seen_edges.add(key)
3460
+ edges.append(edge)
3461
+
3462
+ folder_paths = list(folder_map.keys())
3463
+
3464
+ # Edge type 1 — same-parent
3465
+ for i in range(len(folder_paths)):
3466
+ for j in range(i + 1, len(folder_paths)):
3467
+ a = folder_paths[i]
3468
+ b = folder_paths[j]
3469
+ # Use posixpath-style dirname on forward-slash paths
3470
+ if a.rsplit("/", 1)[0] == b.rsplit("/", 1)[0] and "/" in a and "/" in b:
3471
+ add_edge({"source": a, "target": b, "type": "same-parent"})
3472
+ elif os.path.dirname(a) == os.path.dirname(b) and os.path.dirname(a) != "":
3473
+ add_edge({"source": a, "target": b, "type": "same-parent"})
3474
+
3475
+ # Edge type 2 — cross-reference (folder A's obs text mentions folder B's path)
3476
+ for (fp_a, _agent_a), text_a in pair_obs_texts.items():
3477
+ for fp_b in folder_paths:
3478
+ if fp_b != fp_a and fp_b in text_a:
3479
+ add_edge({"source": fp_a, "target": fp_b, "type": "cross-ref"})
3480
+
3481
+ # Edge type 3 — agent-shared (two folders share the same agentId)
3482
+ # Build: agentId -> [folder_paths that have this agent]
3483
+ agent_to_folders: Dict[str, List[str]] = {}
3484
+ for fp, info in folder_map.items():
3485
+ for aid in info["agentIds"]:
3486
+ agent_to_folders.setdefault(aid, []).append(fp)
3487
+
3488
+ for aid, fps in agent_to_folders.items():
3489
+ for i in range(len(fps)):
3490
+ for j in range(i + 1, len(fps)):
3491
+ add_edge({
3492
+ "source": fps[i],
3493
+ "target": fps[j],
3494
+ "type": "agent-shared",
3495
+ "agentId": aid,
3496
+ })
3497
+
3498
+ return {"nodes": nodes, "edges": edges}
3499
+
3500
+
3501
  # Setup persistence helper wire-ups
3502
  def set_index_persistence(persistence: IndexPersistence) -> None:
3503
  global _index_persistence
src/viewer/index.html CHANGED
@@ -1119,7 +1119,7 @@
1119
  </head>
1120
  <body>
1121
  <div class="app-header">
1122
- <a class="brand" href="#dashboard" data-tab-link="dashboard" aria-label="Open dashboard">
1123
  <h1>agentmemory</h1>
1124
  <span class="version">v__AGENTMEMORY_VERSION__</span>
1125
  </a>
@@ -1131,33 +1131,19 @@
1131
  </div>
1132
 
1133
  <div class="tab-bar" id="tab-bar">
1134
- <button class="active" data-tab="dashboard" aria-current="page">Dashboard</button>
1135
- <button data-tab="graph">Graph</button>
1136
  <button data-tab="memories">Memories</button>
 
1137
  <button data-tab="timeline">Timeline</button>
1138
- <button data-tab="sessions">Sessions</button>
1139
- <button data-tab="lessons">Lessons</button>
1140
- <button data-tab="actions">Actions</button>
1141
- <button data-tab="crystals">Crystals</button>
1142
- <button data-tab="audit">Audit</button>
1143
- <button data-tab="activity">Activity</button>
1144
- <button data-tab="profile">Profile</button>
1145
- <button data-tab="replay">Replay</button>
1146
- <button data-tab="personal">Personal</button>
1147
  </div>
1148
 
1149
  <div id="flag-banners" class="flag-banners"></div>
1150
  <div id="viewer-auth" class="viewer-auth"></div>
1151
 
1152
- <div id="view-dashboard" class="view active"></div>
1153
  <div id="view-graph" class="view"></div>
1154
  <div id="view-memories" class="view"></div>
1155
- <div id="view-lessons" class="view"></div>
1156
- <div id="view-actions" class="view"></div>
1157
- <div id="view-crystals" class="view"></div>
1158
  <div id="view-timeline" class="view"></div>
1159
- <div id="view-sessions" class="view"></div>
1160
- <div id="view-audit" class="view"></div>
1161
  <div id="view-activity" class="view"></div>
1162
  <div id="view-profile" class="view"></div>
1163
  <div id="view-replay" class="view"></div>
@@ -1268,7 +1254,7 @@
1268
  task: '&#9745;', other: '&#128196;'
1269
  };
1270
  var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' };
1271
- var TAB_IDS = ['dashboard', 'graph', 'memories', 'timeline', 'sessions', 'lessons', 'actions', 'crystals', 'audit', 'activity', 'profile', 'replay', 'personal'];
1272
  var VIEWER_TOKEN_STORAGE_KEY = 'agentmemory-viewer-token';
1273
 
1274
  var state = {
@@ -1452,14 +1438,14 @@
1452
 
1453
  function normalizeTab(tab) {
1454
  var normalized = String(tab || '').replace(/^#/, '').toLowerCase();
1455
- return TAB_IDS.indexOf(normalized) >= 0 ? normalized : 'dashboard';
1456
  }
1457
 
1458
  function tabFromRoute() {
1459
  try {
1460
  return normalizeTab(decodeURIComponent(window.location.hash.slice(1)));
1461
  } catch (_) {
1462
- return 'dashboard';
1463
  }
1464
  }
1465
 
@@ -1476,9 +1462,6 @@
1476
  function switchTab(tab, opts) {
1477
  opts = opts || {};
1478
  tab = normalizeTab(tab);
1479
- if (state.activeTab === 'replay' && tab !== 'replay' && typeof stopReplayTimer === 'function') {
1480
- stopReplayTimer();
1481
- }
1482
  if (!opts.skipRoute) {
1483
  updateTabRoute(tab, !!opts.replaceRoute);
1484
  }
@@ -1501,64 +1484,149 @@
1501
 
1502
  async function loadTab(tab) {
1503
  switch(tab) {
1504
- case 'dashboard': if (!state.dashboard.loaded) await loadDashboard(); break;
1505
- case 'graph': if (!state.graph.loaded) await loadGraph(); break;
1506
- case 'memories': if (!state.memories.loaded) await loadMemories(); break;
1507
- case 'timeline': if (!state.timeline.loaded) await loadTimeline(); break;
1508
- case 'sessions': if (!state.sessions.loaded) await loadSessions(); break;
1509
- case 'lessons': if (!state.lessons.loaded) await loadLessons(); break;
1510
- case 'actions': if (!state.actions.loaded) await loadActions(); break;
1511
- case 'crystals': if (!state.crystals.loaded) await loadCrystals(); break;
1512
- case 'audit': if (!state.audit.loaded) await loadAudit(); break;
1513
- case 'activity': if (!state.activity.loaded) await loadActivity(); break;
1514
- case 'profile': if (!state.profile.loaded) await loadProfile(); break;
1515
- case 'replay': if (!state.replay.loaded) await loadReplay(); break;
1516
- case 'personal': if (!state.personal.loaded) await loadPersonal(); break;
1517
  }
1518
  }
1519
 
1520
  async function loadDashboard() {
1521
- var el = document.getElementById('view-dashboard');
1522
- el.innerHTML = '<div class="loading">Loading dashboard...</div>';
 
 
 
 
 
 
 
 
 
1523
  try {
1524
- var results = await Promise.all([
1525
- apiGet('health'),
1526
- apiGet('sessions'),
1527
- apiGet('memories?latest=true&limit=500'),
1528
- apiGet('graph/stats'),
1529
- apiGet('audit?limit=5'),
1530
- apiGet('semantic'),
1531
- apiGet('procedural'),
1532
- apiGet('relations'),
1533
- apiGet('lessons'),
1534
- apiGet('crystals')
1535
- ]);
1536
- state.dashboard.health = results[0];
1537
- state.dashboard.sessions = (results[1] && results[1].sessions) || [];
1538
- state.dashboard.memories = (results[2] && results[2].memories) || [];
1539
- state.dashboard.graphStats = results[3];
1540
- state.dashboard.recentAudit = (results[4] && results[4].entries) || [];
1541
- state.dashboard.semantic = (results[5] && results[5].items) || (results[5] && results[5].facts) || (results[5] && results[5].semantic) || [];
1542
- state.dashboard.procedural = (results[6] && results[6].items) || (results[6] && results[6].procedures) || (results[6] && results[6].procedural) || [];
1543
- state.dashboard.lessons = (results[8] && results[8].lessons) || [];
1544
- state.dashboard.crystals = (results[9] && results[9].crystals) || [];
1545
- state.dashboard.relations = (results[7] && results[7].relations) || [];
1546
- state.dashboard.loaded = true;
1547
- renderDashboard();
1548
- } catch (err) {
1549
- var msg = (err && err.message) ? err.message : String(err);
1550
- console.error('[viewer] loadDashboard failed:', err);
1551
- el.innerHTML =
1552
- '<div class="loading" style="color:var(--accent);">' +
1553
- 'Dashboard failed to load: ' + msg +
1554
- '<br><br><span style="font-size:12px;color:var(--ink-muted);">' +
1555
- 'Check the browser console for the full error. If you see CSP ' +
1556
- 'violations, please open an issue with the agentmemory version ' +
1557
- '(top-right of the viewer) and the violation text.' +
1558
- '</span></div>';
1559
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1560
  }
1561
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1562
  function renderDashboard() {
1563
  var el = document.getElementById('view-dashboard');
1564
  var d = state.dashboard;
@@ -1851,146 +1919,45 @@
1851
  var el = document.getElementById('view-graph');
1852
  el.innerHTML = '<div class="graph-container"><div class="graph-canvas-wrap"><canvas id="graph-canvas"></canvas><div class="graph-controls"><button title="Zoom In" data-action="zoom-graph" data-dir="1">+</button><button title="Zoom Out" data-action="zoom-graph" data-dir="-1">&minus;</button><div class="ctrl-divider"></div><button title="Recenter" data-action="recenter-graph">⌖</button></div><div class="graph-tooltip" id="graph-tooltip"></div></div><div class="graph-sidebar" id="graph-sidebar"></div></div>';
1853
 
1854
- var results = await Promise.all([
1855
- apiGet('sessions'),
1856
- apiGet('memories?latest=true&limit=2000'),
1857
- apiGet('graph/stats')
1858
- ]);
1859
-
1860
- var sessions = (results[0] && results[0].sessions) || [];
1861
- var memories = (results[1] && results[1].memories) || [];
1862
- var stats = results[2] || {};
1863
-
1864
  state.graph.queryError = null;
 
 
 
 
 
 
 
1865
 
1866
- // Extract unique folders (projects)
1867
- var folderMap = {};
1868
- sessions.forEach(function(s) {
1869
- var path = s.project ? String(s.project).trim() : '';
1870
- if (!path) return;
1871
- if (!path.includes('/') && !path.includes('\\')) return;
1872
- if (!folderMap[path]) {
1873
- folderMap[path] = {
1874
- id: path,
1875
- name: path.split(/[\\/]/).pop() || path,
1876
- fullPath: path,
1877
- type: 'folder',
1878
- sessions: [],
1879
- memories: [],
1880
- concepts: new Set(),
1881
- firstPrompts: [],
1882
- summaries: []
1883
- };
1884
- }
1885
- folderMap[path].sessions.push(s);
1886
- if (s.firstPrompt) folderMap[path].firstPrompts.push(s.firstPrompt);
1887
- if (s.summary) folderMap[path].summaries.push(s.summary);
1888
- });
1889
-
1890
- memories.forEach(function(m) {
1891
- var path = m.project ? String(m.project).trim() : '';
1892
- if (!path) return;
1893
- if (!path.includes('/') && !path.includes('\\')) return;
1894
- if (!folderMap[path]) {
1895
- // If the folder is not in sessions, create it
1896
- folderMap[path] = {
1897
- id: path,
1898
- name: path.split(/[\\/]/).pop() || path,
1899
- fullPath: path,
1900
- type: 'folder',
1901
- sessions: [],
1902
- memories: [],
1903
- concepts: new Set(),
1904
- firstPrompts: [],
1905
- summaries: []
1906
- };
1907
- }
1908
- folderMap[path].memories.push(m);
1909
- if (m.concepts && Array.isArray(m.concepts)) {
1910
- m.concepts.forEach(function(c) {
1911
- folderMap[path].concepts.add(c.toLowerCase());
1912
- });
1913
- }
1914
  });
1915
 
1916
- var folders = Object.values(folderMap);
1917
-
1918
- // Compute detailed properties/descriptions
1919
- folders.forEach(function(f) {
1920
- var desc = '';
1921
- if (f.summaries.length > 0) {
1922
- desc += '<strong>Summaries:</strong><br>' + f.summaries.slice(0, 3).map(function(s) { return '&bull; ' + esc(s); }).join('<br>') + '<br><br>';
1923
- } else if (f.firstPrompts.length > 0) {
1924
- desc += '<strong>Prompts:</strong><br>' + f.firstPrompts.slice(0, 3).map(function(p) { return '&bull; ' + esc(p); }).join('<br>') + '<br><br>';
1925
- }
1926
-
1927
- var conceptsArr = Array.from(f.concepts);
1928
- if (conceptsArr.length > 0) {
1929
- desc += '<strong>Concepts:</strong><br>' + conceptsArr.slice(0, 10).map(function(c) { return '<span class="tag">' + esc(c) + '</span>'; }).join(' ') + '<br><br>';
1930
- }
1931
-
1932
- if (f.memories.length > 0) {
1933
- desc += '<strong>Memories:</strong><br>' + f.memories.slice(0, 5).map(function(m) { return '&bull; ' + esc(m.title); }).join('<br>');
1934
- }
1935
-
1936
- f.properties = {
1937
- description: desc || 'No description available for this folder.',
1938
- fullPath: f.fullPath,
1939
- sessionCount: f.sessions.length,
1940
- memoryCount: f.memories.length
1941
  };
1942
  });
1943
 
1944
- // Compute edges (connections between folders)
1945
- var edges = [];
1946
- for (var i = 0; i < folders.length; i++) {
1947
- for (var j = i + 1; j < folders.length; j++) {
1948
- var f1 = folders[i];
1949
- var f2 = folders[j];
1950
-
1951
- var sharedConcepts = [];
1952
- f1.concepts.forEach(function(c) {
1953
- if (f2.concepts.has(c)) {
1954
- sharedConcepts.push(c);
1955
- }
1956
- });
1957
-
1958
- var path1 = f1.fullPath.split(/[\\/]/).filter(Boolean);
1959
- var path2 = f2.fullPath.split(/[\\/]/).filter(Boolean);
1960
- var commonSubdirs = 0;
1961
- for (var k = 0; k < Math.min(path1.length, path2.length); k++) {
1962
- if (path1[k].toLowerCase() === path2[k].toLowerCase()) {
1963
- var p = path1[k].toLowerCase();
1964
- if (p !== 'c:' && p !== 'd:' && p !== 'downloads' && p !== 'projects' && p !== 'other projects') {
1965
- commonSubdirs++;
1966
- }
1967
- } else {
1968
- break;
1969
- }
1970
- }
1971
-
1972
- var connectionType = [];
1973
- var weight = 0;
1974
- if (sharedConcepts.length > 0) {
1975
- connectionType.push('Shared concepts: ' + sharedConcepts.slice(0, 2).join(', '));
1976
- weight += sharedConcepts.length * 0.8;
1977
- }
1978
- if (commonSubdirs > 0) {
1979
- connectionType.push('Common parent path');
1980
- weight += commonSubdirs * 1.5;
1981
- }
1982
-
1983
- if (weight > 0) {
1984
- edges.push({
1985
- id: f1.fullPath + '->' + f2.fullPath,
1986
- sourceNodeId: f1.fullPath,
1987
- targetNodeId: f2.fullPath,
1988
- type: connectionType.join(' & '),
1989
- weight: Math.min(5, weight)
1990
- });
1991
- }
1992
- }
1993
- }
1994
 
1995
  state.graph.nodes = folders;
1996
  state.graph.edges = edges;
@@ -2810,7 +2777,7 @@
2810
 
2811
  async function confirmDeleteMemory(id) {
2812
  closeModal();
2813
- await apiDelete('governance/memories', { memoryIds: [id], reason: 'Deleted via viewer' });
2814
  state.memories.loaded = false;
2815
  loadMemories();
2816
  }
@@ -2819,63 +2786,132 @@
2819
  document.getElementById('modal-overlay').classList.remove('open');
2820
  }
2821
 
2822
- async function loadTimeline() {
 
 
 
 
 
 
2823
  var el = document.getElementById('view-timeline');
 
2824
  el.innerHTML = '<div class="loading">Loading timeline...</div>';
2825
- var sessResult = await apiGet('sessions');
2826
- var sessions = (sessResult && sessResult.sessions) || [];
2827
- state.timeline.loaded = true;
2828
-
2829
- if (sessions.length > 0 && !state.timeline.sessionId) {
2830
- var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); });
2831
- var firstSelectable = sorted.find(function(s) { return sessionId(s); });
2832
- state.timeline.sessionId = firstSelectable ? sessionId(firstSelectable) : '';
 
 
 
 
2833
  }
2834
-
2835
- renderTimelineToolbar(sessions);
2836
- if (state.timeline.sessionId) await loadObservations();
2837
  }
2838
 
2839
- function renderTimelineToolbar(sessions) {
2840
  var el = document.getElementById('view-timeline');
2841
- var html = '<div class="toolbar">';
2842
- html += '<select id="tl-session"><option value="">Select session</option>';
2843
- sessions.sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).forEach(function(s) {
2844
- var id = sessionId(s);
2845
- var disabled = id ? '' : ' disabled';
2846
- html += '<option value="' + esc(id) + '"' + (id && state.timeline.sessionId === id ? ' selected' : '') + disabled + '>' + esc(sessionLabel(s)) + '</option>';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2847
  });
2848
  html += '</select>';
2849
- html += '<select id="tl-importance"><option value="0">All importance</option>';
2850
- for (var i = 1; i <= 9; i++) {
2851
- html += '<option value="' + i + '"' + (state.timeline.minImportance === i ? ' selected' : '') + '>&ge; ' + i + '</option>';
 
 
 
 
 
 
 
 
2852
  }
2853
- html += '</select></div>';
2854
- html += '<div id="tl-content"></div>';
2855
  el.innerHTML = html;
2856
 
2857
- document.getElementById('tl-session').addEventListener('change', function() {
2858
- state.timeline.sessionId = this.value;
2859
- state.timeline.page = 0;
2860
- loadObservations();
2861
- });
2862
- document.getElementById('tl-importance').addEventListener('change', function() {
2863
- state.timeline.minImportance = parseInt(this.value);
2864
- renderObservations();
2865
- });
 
 
 
 
 
 
2866
  }
2867
 
2868
- async function loadObservations() {
2869
- var content = document.getElementById('tl-content');
2870
- if (!content) return;
2871
- if (!state.timeline.sessionId) {
2872
- content.innerHTML = '<div class="empty-state"><div class="empty-icon">&#128337;</div><p>Select a session to view observations</p></div>';
2873
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2874
  }
2875
- content.innerHTML = '<div class="loading">Loading observations...</div>';
2876
- var result = await apiGet('observations?sessionId=' + encodeURIComponent(state.timeline.sessionId));
2877
- state.timeline.observations = (result && result.observations) || [];
2878
- renderObservations();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2879
  }
2880
 
2881
  var tlTypeFilter = '';
@@ -4128,7 +4164,19 @@
4128
  if (state.ws !== ws) return;
4129
  try {
4130
  var msg = JSON.parse(e.data);
4131
- if (msg.type === 'stream' && msg.event) {
 
 
 
 
 
 
 
 
 
 
 
 
4132
  handleStreamEvent(msg);
4133
  } else if (msg.event_type && msg.data) {
4134
  handleStreamEvent({ event: { type: 'create', data: msg.data, event_type: msg.event_type } });
 
1119
  </head>
1120
  <body>
1121
  <div class="app-header">
1122
+ <a class="brand" href="#folders" data-tab-link="folders" aria-label="Open folders view">
1123
  <h1>agentmemory</h1>
1124
  <span class="version">v__AGENTMEMORY_VERSION__</span>
1125
  </a>
 
1131
  </div>
1132
 
1133
  <div class="tab-bar" id="tab-bar">
1134
+ <button class="active" data-tab="folders" aria-current="page">Folders</button>
 
1135
  <button data-tab="memories">Memories</button>
1136
+ <button data-tab="graph">Graph</button>
1137
  <button data-tab="timeline">Timeline</button>
 
 
 
 
 
 
 
 
 
1138
  </div>
1139
 
1140
  <div id="flag-banners" class="flag-banners"></div>
1141
  <div id="viewer-auth" class="viewer-auth"></div>
1142
 
1143
+ <div id="view-folders" class="view active"></div>
1144
  <div id="view-graph" class="view"></div>
1145
  <div id="view-memories" class="view"></div>
 
 
 
1146
  <div id="view-timeline" class="view"></div>
 
 
1147
  <div id="view-activity" class="view"></div>
1148
  <div id="view-profile" class="view"></div>
1149
  <div id="view-replay" class="view"></div>
 
1254
  task: '&#9745;', other: '&#128196;'
1255
  };
1256
  var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' };
1257
+ var TAB_IDS = ['folders', 'memories', 'graph', 'timeline'];
1258
  var VIEWER_TOKEN_STORAGE_KEY = 'agentmemory-viewer-token';
1259
 
1260
  var state = {
 
1438
 
1439
  function normalizeTab(tab) {
1440
  var normalized = String(tab || '').replace(/^#/, '').toLowerCase();
1441
+ return TAB_IDS.indexOf(normalized) >= 0 ? normalized : 'folders';
1442
  }
1443
 
1444
  function tabFromRoute() {
1445
  try {
1446
  return normalizeTab(decodeURIComponent(window.location.hash.slice(1)));
1447
  } catch (_) {
1448
+ return 'folders';
1449
  }
1450
  }
1451
 
 
1462
  function switchTab(tab, opts) {
1463
  opts = opts || {};
1464
  tab = normalizeTab(tab);
 
 
 
1465
  if (!opts.skipRoute) {
1466
  updateTabRoute(tab, !!opts.replaceRoute);
1467
  }
 
1484
 
1485
  async function loadTab(tab) {
1486
  switch(tab) {
1487
+ case 'folders': await loadFolders(); break;
1488
+ case 'graph': await loadGraph(); break;
1489
+ case 'memories': await loadMemories(); break;
1490
+ case 'timeline': await loadTimeline(); break;
 
 
 
 
 
 
 
 
 
1491
  }
1492
  }
1493
 
1494
  async function loadDashboard() {
1495
+ // Dashboard removed — redirect to folders
1496
+ switchTab('folders');
1497
+ }
1498
+
1499
+ // =====================================================================
1500
+ // Folders Tab
1501
+ // =====================================================================
1502
+ async function loadFolders() {
1503
+ var el = document.getElementById('view-folders');
1504
+ if (!el) return;
1505
+ el.innerHTML = '<div class="loading">Loading folders...</div>';
1506
  try {
1507
+ var result = await apiPost('folders-list', null, 'GET');
1508
+ var folders = (result && result.folders) || [];
1509
+ renderFolderList(folders);
1510
+ } catch(err) {
1511
+ el.innerHTML = '<div class="loading" style="color:var(--accent);">Failed to load folders: ' + esc(String(err)) + '</div>';
1512
+ }
1513
+ }
1514
+
1515
+ function renderFolderList(folders) {
1516
+ var el = document.getElementById('view-folders');
1517
+ if (!el) return;
1518
+ if (folders.length === 0) {
1519
+ el.innerHTML = '<div class="empty-state"><div class="empty-icon">&#128193;</div><p>No folder memories yet.</p><p style="font-size:13px;color:var(--ink-muted);margin-top:8px;">Use <code>agent_observe</code> with a <code>folderPath</code> to start recording.</p></div>';
1520
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1521
  }
1522
+ var html = '<div style="padding:20px 24px;">';
1523
+ html += '<h2 style="font-family:var(--font-display);font-size:18px;font-weight:700;color:var(--ink);margin-bottom:16px;">Folder Memories</h2>';
1524
+ html += '<table style="width:100%;border-collapse:collapse;font-family:var(--font-ui);font-size:13px;">';
1525
+ html += '<thead><tr style="border-bottom:2px solid var(--border);">';
1526
+ html += '<th style="text-align:left;padding:8px 12px;color:var(--ink-muted);font-weight:600;">Folder</th>';
1527
+ html += '<th style="text-align:left;padding:8px 12px;color:var(--ink-muted);font-weight:600;">Agent</th>';
1528
+ html += '<th style="text-align:right;padding:8px 12px;color:var(--ink-muted);font-weight:600;">Observations</th>';
1529
+ html += '<th style="text-align:left;padding:8px 12px;color:var(--ink-muted);font-weight:600;">Last Updated</th>';
1530
+ html += '</tr></thead><tbody>';
1531
+ folders.forEach(function(f) {
1532
+ var fp = f.folderPath || '';
1533
+ var aid = f.agentId || '';
1534
+ var obs = f.obsCount || 0;
1535
+ var updated = f.lastUpdated ? new Date(f.lastUpdated).toLocaleString() : '—';
1536
+ html += '<tr class="folder-row" data-folder="' + esc(fp) + '" data-agent="' + esc(aid) + '" style="border-bottom:1px solid var(--border-light);cursor:pointer;" onmouseover="this.style.background=\'var(--bg-subtle)\'" onmouseout="this.style.background=\'\'">';
1537
+ html += '<td style="padding:10px 12px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + esc(fp) + '"><span style="font-family:var(--font-mono);font-size:12px;">' + esc(fp) + '</span></td>';
1538
+ html += '<td style="padding:10px 12px;"><span style="background:var(--bg-inset);padding:2px 8px;border-radius:3px;font-size:11px;font-weight:600;">' + esc(aid) + '</span></td>';
1539
+ html += '<td style="padding:10px 12px;text-align:right;font-weight:600;color:var(--accent);">' + obs + '</td>';
1540
+ html += '<td style="padding:10px 12px;color:var(--ink-muted);font-size:12px;">' + esc(updated) + '</td>';
1541
+ html += '</tr>';
1542
+ });
1543
+ html += '</tbody></table></div>';
1544
+ el.innerHTML = html;
1545
+
1546
+ el.querySelectorAll('.folder-row').forEach(function(row) {
1547
+ row.addEventListener('click', function() {
1548
+ loadFolderDetail(row.dataset.folder, row.dataset.agent);
1549
+ });
1550
+ });
1551
  }
1552
 
1553
+ async function loadFolderDetail(folderPath, agentId) {
1554
+ var el = document.getElementById('view-folders');
1555
+ if (!el) return;
1556
+ el.innerHTML = '<div class="loading">Loading observations...</div>';
1557
+ try {
1558
+ var params = 'folderPath=' + encodeURIComponent(folderPath) + '&agentId=' + encodeURIComponent(agentId);
1559
+ var result = await apiFetchRaw('/agentmemory/folder/observations?' + params);
1560
+ var obs = (result && result.observations) || [];
1561
+
1562
+ var html = '<div style="padding:20px 24px;">';
1563
+ html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;">';
1564
+ html += '<button onclick="loadFolders()" style="background:none;border:1px solid var(--border-light);padding:6px 14px;cursor:pointer;font-family:var(--font-ui);font-size:12px;border-radius:3px;">&larr; Back</button>';
1565
+ html += '<span style="font-family:var(--font-mono);font-size:13px;color:var(--ink);">' + esc(folderPath) + '</span>';
1566
+ html += '<span style="background:var(--bg-inset);padding:2px 8px;border-radius:3px;font-size:11px;font-weight:600;">' + esc(agentId) + '</span>';
1567
+ html += '<span style="margin-left:auto;font-size:12px;color:var(--ink-muted);">' + obs.length + ' observations</span>';
1568
+ html += '</div>';
1569
+
1570
+ if (obs.length === 0) {
1571
+ html += '<div class="empty-state"><p>No observations found.</p></div>';
1572
+ } else {
1573
+ obs.forEach(function(o) {
1574
+ var ts = o.timestamp ? new Date(o.timestamp).toLocaleString() : '';
1575
+ var typeColor = {'file_edit':'var(--blue)','command_run':'var(--orange)','search':'var(--green)','error':'var(--red)','conversation':'var(--purple)'}[o.type] || 'var(--ink-muted)';
1576
+ html += '<div style="border:1px solid var(--border-light);border-radius:4px;padding:14px 16px;margin-bottom:10px;background:var(--bg);">';
1577
+ html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">';
1578
+ html += '<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:' + typeColor + ';background:var(--bg-subtle);padding:2px 7px;border-radius:3px;">' + esc(o.type || 'other') + '</span>';
1579
+ html += '<span style="font-size:11px;color:var(--ink-muted);font-family:var(--font-mono);">' + esc(ts) + '</span>';
1580
+ html += '</div>';
1581
+ if (o.title) html += '<div style="font-weight:600;font-size:14px;color:var(--ink);margin-bottom:4px;">' + esc(o.title) + '</div>';
1582
+ if (o.text) html += '<div style="font-size:13px;color:var(--ink-secondary);line-height:1.5;white-space:pre-wrap;">' + esc(o.text.length > 300 ? o.text.slice(0,300)+'…' : o.text) + '</div>';
1583
+ html += '</div>';
1584
+ });
1585
+ }
1586
+
1587
+ html += '<div style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border-light);">';
1588
+ html += '<button onclick="deleteFolderMemory(\'' + esc(folderPath).replace(/'/g,"\\'") + '\',\'' + esc(agentId).replace(/'/g,"\\'") + '\')" style="background:var(--accent);color:white;border:none;padding:8px 18px;cursor:pointer;font-family:var(--font-ui);font-size:13px;border-radius:3px;">Delete folder memory</button>';
1589
+ html += '</div></div>';
1590
+ el.innerHTML = html;
1591
+ } catch(err) {
1592
+ el.innerHTML = '<div style="padding:20px;"><button onclick="loadFolders()" style="background:none;border:1px solid var(--border-light);padding:6px 14px;cursor:pointer;">&larr; Back</button><div style="margin-top:16px;color:var(--accent);">Error: ' + esc(String(err)) + '</div></div>';
1593
+ }
1594
+ }
1595
+
1596
+ async function deleteFolderMemory(folderPath, agentId) {
1597
+ if (!confirm('Delete all observations for ' + folderPath + ' / ' + agentId + '?')) return;
1598
+ try {
1599
+ await apiFetchRaw('/agentmemory/forget', { method: 'POST', body: JSON.stringify({ folderPath: folderPath, agentId: agentId }) });
1600
+ loadFolders();
1601
+ } catch(err) {
1602
+ alert('Delete failed: ' + String(err));
1603
+ }
1604
+ }
1605
+
1606
+ async function apiFetchRaw(path, opts) {
1607
+ opts = opts || {};
1608
+ var headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
1609
+ if (state.token) headers['Authorization'] = 'Bearer ' + state.token;
1610
+ var url = (path.startsWith('/') ? '' : '/agentmemory/') + path;
1611
+ var res = await fetch(url, Object.assign({}, opts, { headers: headers }));
1612
+ if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
1613
+ return res.json();
1614
+ }
1615
+
1616
+ async function apiPost(endpoint, body, method) {
1617
+ method = method || 'POST';
1618
+ var headers = { 'Content-Type': 'application/json' };
1619
+ if (state.token) headers['Authorization'] = 'Bearer ' + state.token;
1620
+ var url = '/agentmemory/' + endpoint;
1621
+ var opts = { method: method, headers: headers };
1622
+ if (body !== null && body !== undefined) opts.body = JSON.stringify(body);
1623
+ var res = await fetch(url, opts);
1624
+ if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
1625
+ return res.json();
1626
+ }
1627
+
1628
+
1629
+
1630
  function renderDashboard() {
1631
  var el = document.getElementById('view-dashboard');
1632
  var d = state.dashboard;
 
1919
  var el = document.getElementById('view-graph');
1920
  el.innerHTML = '<div class="graph-container"><div class="graph-canvas-wrap"><canvas id="graph-canvas"></canvas><div class="graph-controls"><button title="Zoom In" data-action="zoom-graph" data-dir="1">+</button><button title="Zoom Out" data-action="zoom-graph" data-dir="-1">&minus;</button><div class="ctrl-divider"></div><button title="Recenter" data-action="recenter-graph">⌖</button></div><div class="graph-tooltip" id="graph-tooltip"></div></div><div class="graph-sidebar" id="graph-sidebar"></div></div>';
1921
 
 
 
 
 
 
 
 
 
 
 
1922
  state.graph.queryError = null;
1923
+ var graphData;
1924
+ try {
1925
+ graphData = await apiFetchRaw('/agentmemory/graph');
1926
+ } catch(err) {
1927
+ state.graph.queryError = String(err);
1928
+ graphData = { nodes: [], edges: [] };
1929
+ }
1930
 
1931
+ // Map server nodes to viewer format
1932
+ var folders = (graphData.nodes || []).map(function(n) {
1933
+ return {
1934
+ id: n.folderPath || n.id,
1935
+ name: n.label || (n.folderPath || n.id).split(/[\\/]/).pop() || n.id,
1936
+ fullPath: n.folderPath || n.id,
1937
+ type: 'folder',
1938
+ agentIds: n.agentIds || [],
1939
+ obsCount: n.obsCount || 0,
1940
+ color: n.color || folderColor(n.folderPath || n.id),
1941
+ properties: {
1942
+ description: 'Agents: ' + (n.agentIds || []).join(', ') + '<br>Observations: ' + (n.obsCount || 0),
1943
+ fullPath: n.folderPath || n.id,
1944
+ obsCount: n.obsCount || 0
1945
+ }
1946
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1947
  });
1948
 
1949
+ // Map server edges to viewer format
1950
+ var edges = (graphData.edges || []).map(function(e) {
1951
+ return {
1952
+ id: e.source + '->' + e.target + ':' + e.type,
1953
+ sourceNodeId: e.source,
1954
+ targetNodeId: e.target,
1955
+ type: e.type || 'connection',
1956
+ agentId: e.agentId,
1957
+ weight: e.type === 'cross-ref' ? 2 : 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1958
  };
1959
  });
1960
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1961
 
1962
  state.graph.nodes = folders;
1963
  state.graph.edges = edges;
 
2777
 
2778
  async function confirmDeleteMemory(id) {
2779
  closeModal();
2780
+ await apiPost('forget', { memoryId: id });
2781
  state.memories.loaded = false;
2782
  loadMemories();
2783
  }
 
2786
  document.getElementById('modal-overlay').classList.remove('open');
2787
  }
2788
 
2789
+ // =====================================================================
2790
+ // Timeline Tab — folder activity feed
2791
+ // =====================================================================
2792
+ var tlFolderFilter = '';
2793
+ var tlAgentFilter = '';
2794
+
2795
+ async function loadTimeline(folderPath, agentId) {
2796
  var el = document.getElementById('view-timeline');
2797
+ if (!el) return;
2798
  el.innerHTML = '<div class="loading">Loading timeline...</div>';
2799
+ try {
2800
+ var body = { limit: 100 };
2801
+ if (folderPath) body.folderPath = folderPath;
2802
+ if (agentId) body.agentId = agentId;
2803
+ var result = await apiFetchRaw('/agentmemory/timeline', {
2804
+ method: 'POST',
2805
+ body: JSON.stringify(body)
2806
+ });
2807
+ var obs = (result && result.observations) || [];
2808
+ renderTimeline(obs);
2809
+ } catch(err) {
2810
+ el.innerHTML = '<div class="loading" style="color:var(--accent);">Failed to load timeline: ' + esc(String(err)) + '</div>';
2811
  }
 
 
 
2812
  }
2813
 
2814
+ function renderTimeline(obs) {
2815
  var el = document.getElementById('view-timeline');
2816
+ if (!el) return;
2817
+
2818
+ // Collect unique agents for dropdown
2819
+ var agents = [];
2820
+ var agentSeen = {};
2821
+ obs.forEach(function(o) {
2822
+ if (o.agentId && !agentSeen[o.agentId]) {
2823
+ agentSeen[o.agentId] = true;
2824
+ agents.push(o.agentId);
2825
+ }
2826
+ });
2827
+
2828
+ var html = '<div style="padding:16px 24px;">';
2829
+ // Filter bar
2830
+ html += '<div style="display:flex;gap:12px;align-items:center;margin-bottom:16px;flex-wrap:wrap;">';
2831
+ html += '<input id="tl-folder-filter" type="text" placeholder="Filter by folder path..." value="' + esc(tlFolderFilter) + '" style="flex:1;min-width:200px;padding:7px 12px;border:1px solid var(--border-light);background:var(--bg);color:var(--ink);font-family:var(--font-mono);font-size:12px;">';
2832
+ html += '<select id="tl-agent-filter" style="padding:7px 12px;border:1px solid var(--border-light);background:var(--bg);color:var(--ink);font-family:var(--font-ui);font-size:12px;">';
2833
+ html += '<option value="">All agents</option>';
2834
+ agents.forEach(function(a) {
2835
+ html += '<option value="' + esc(a) + '"' + (tlAgentFilter === a ? ' selected' : '') + '>' + esc(a) + '</option>';
2836
  });
2837
  html += '</select>';
2838
+ html += '<span style="font-size:12px;color:var(--ink-muted);">' + obs.length + ' observations</span>';
2839
+ html += '</div>';
2840
+
2841
+ // Cards
2842
+ html += '<div id="tl-cards">';
2843
+ if (obs.length === 0) {
2844
+ html += '<div class="empty-state"><div class="empty-icon">&#128337;</div><p>No observations found.</p></div>';
2845
+ } else {
2846
+ obs.forEach(function(o) {
2847
+ html += renderTimelineCard(o);
2848
+ });
2849
  }
2850
+ html += '</div></div>';
 
2851
  el.innerHTML = html;
2852
 
2853
+ // Wire up filters
2854
+ var folderInput = document.getElementById('tl-folder-filter');
2855
+ var agentSelect = document.getElementById('tl-agent-filter');
2856
+ if (folderInput) {
2857
+ folderInput.addEventListener('input', function() {
2858
+ tlFolderFilter = this.value;
2859
+ loadTimeline(tlFolderFilter || undefined, tlAgentFilter || undefined);
2860
+ });
2861
+ }
2862
+ if (agentSelect) {
2863
+ agentSelect.addEventListener('change', function() {
2864
+ tlAgentFilter = this.value;
2865
+ loadTimeline(tlFolderFilter || undefined, tlAgentFilter || undefined);
2866
+ });
2867
+ }
2868
  }
2869
 
2870
+ function renderTimelineCard(o) {
2871
+ var ts = o.timestamp ? new Date(o.timestamp).toLocaleString() : '';
2872
+ var typeColors = {
2873
+ 'file_edit': 'var(--blue)', 'file_write': 'var(--blue)', 'file_read': 'var(--cyan)',
2874
+ 'command_run': 'var(--orange)', 'search': 'var(--green)',
2875
+ 'error': 'var(--red)', 'conversation': 'var(--purple)', 'subagent': 'var(--yellow)'
2876
+ };
2877
+ var typeColor = typeColors[o.type] || 'var(--ink-muted)';
2878
+ var fp = o.folderPath || '';
2879
+ var fpShort = fp.split(/[\\/]/).slice(-2).join('/');
2880
+ var html = '<div style="border:1px solid var(--border-light);border-radius:4px;padding:12px 16px;margin-bottom:8px;background:var(--bg);">';
2881
+ html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap;">';
2882
+ html += '<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:' + typeColor + ';background:var(--bg-subtle);padding:2px 7px;border-radius:3px;">' + esc(o.type || 'other') + '</span>';
2883
+ html += '<span style="font-size:10px;font-weight:600;background:var(--bg-inset);padding:2px 7px;border-radius:3px;">' + esc(o.agentId || '') + '</span>';
2884
+ html += '<span style="font-size:11px;color:var(--ink-muted);font-family:var(--font-mono);" title="' + esc(fp) + '">' + esc(fpShort) + '</span>';
2885
+ html += '<span style="margin-left:auto;font-size:11px;color:var(--ink-faint);font-family:var(--font-mono);">' + esc(ts) + '</span>';
2886
+ html += '</div>';
2887
+ if (o.title) html += '<div style="font-weight:600;font-size:13px;color:var(--ink);margin-bottom:3px;">' + esc(o.title) + '</div>';
2888
+ if (o.text) {
2889
+ var excerpt = o.text.length > 200 ? o.text.slice(0, 200) + '…' : o.text;
2890
+ html += '<div style="font-size:12px;color:var(--ink-secondary);line-height:1.5;">' + esc(excerpt) + '</div>';
2891
  }
2892
+ html += '</div>';
2893
+ return html;
2894
+ }
2895
+
2896
+ function prependTimelineCard(obs) {
2897
+ var cards = document.getElementById('tl-cards');
2898
+ if (!cards) return;
2899
+ var div = document.createElement('div');
2900
+ div.innerHTML = renderTimelineCard(obs);
2901
+ cards.insertBefore(div.firstChild, cards.firstChild);
2902
+ }
2903
+
2904
+ function updateFolderBadge(folderPath, agentId) {
2905
+ var rows = document.querySelectorAll('.folder-row');
2906
+ rows.forEach(function(row) {
2907
+ if (row.dataset.folder === folderPath && row.dataset.agent === agentId) {
2908
+ var countCell = row.querySelector('td:nth-child(3)');
2909
+ if (countCell) {
2910
+ var cur = parseInt(countCell.textContent) || 0;
2911
+ countCell.textContent = cur + 1;
2912
+ }
2913
+ }
2914
+ });
2915
  }
2916
 
2917
  var tlTypeFilter = '';
 
4164
  if (state.ws !== ws) return;
4165
  try {
4166
  var msg = JSON.parse(e.data);
4167
+ if (msg.type === 'folder_observation') {
4168
+ // New folder-based broadcast
4169
+ if (state.activeTab === 'timeline') {
4170
+ prependTimelineCard(msg.data || {});
4171
+ }
4172
+ if (state.activeTab === 'folders') {
4173
+ updateFolderBadge(msg.folderPath, msg.agentId);
4174
+ }
4175
+ } else if (msg.type === 'raw_observation' || msg.type === 'compressed_observation') {
4176
+ if (msg.data && msg.data.observation) {
4177
+ routeWsMessage({ observation: msg.data.observation });
4178
+ }
4179
+ } else if (msg.type === 'stream' && msg.event) {
4180
  handleStreamEvent(msg);
4181
  } else if (msg.event_type && msg.data) {
4182
  handleStreamEvent({ event: { type: 'create', data: msg.data, event_type: msg.event_type } });
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # tests package
tests/test_folder_graph_build.py ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for folderColor() and folder_graph_build() — REQ-023–REQ-028."""
2
+ import os
3
+ import sys
4
+ import tempfile
5
+
6
+ import pytest
7
+
8
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
9
+
10
+ from db import StateKV
11
+ from functions import KV, folder_graph_build, folderColor
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Fixtures
16
+ # ---------------------------------------------------------------------------
17
+
18
+ @pytest.fixture()
19
+ def kv(tmp_path):
20
+ """Return a fresh in-file StateKV backed by a temp SQLite database."""
21
+ db_file = str(tmp_path / "test.db")
22
+ return StateKV(db_path=db_file)
23
+
24
+
25
+ def _write_pair(
26
+ kv: StateKV,
27
+ folder_path: str,
28
+ agent_id: str,
29
+ obs_texts: list = None,
30
+ obs_count: int = None,
31
+ ) -> None:
32
+ """Insert a (folder_path, agent_id) entry into KV.folders and optionally write observations."""
33
+ obs_texts = obs_texts or []
34
+ count = obs_count if obs_count is not None else len(obs_texts)
35
+
36
+ # Write folders index entry
37
+ index_key = f"{folder_path}:{agent_id}"
38
+ kv.set(KV.folders, index_key, {
39
+ "folderPath": folder_path,
40
+ "agentId": agent_id,
41
+ "obsCount": count,
42
+ "lastUpdated": "2025-01-15T12:00:00.000Z",
43
+ })
44
+
45
+ # Write observation objects if text supplied
46
+ for i, text in enumerate(obs_texts):
47
+ obs_id = f"obs_{folder_path.replace('/', '_')}_{agent_id}_{i}"
48
+ obs = {
49
+ "id": obs_id,
50
+ "folderPath": folder_path,
51
+ "agentId": agent_id,
52
+ "timestamp": "2025-01-15T12:00:00.000Z",
53
+ "text": text,
54
+ "type": "other",
55
+ "title": f"title {i}",
56
+ "concepts": [],
57
+ "files": [],
58
+ "importance": 5,
59
+ }
60
+ kv.set(KV.folder_obs(folder_path, agent_id), obs_id, obs)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Tests — folderColor helper
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def test_folder_color_returns_hsl_string():
68
+ """folderColor should return a string matching hsl(...) format."""
69
+ color = folderColor("projects/alpha")
70
+ assert color.startswith("hsl(")
71
+ assert color.endswith(")")
72
+
73
+
74
+ def test_folder_color_deterministic():
75
+ """Same path always returns the same color."""
76
+ assert folderColor("projects/alpha") == folderColor("projects/alpha")
77
+
78
+
79
+ def test_folder_color_different_paths_produce_different_colors():
80
+ """Different paths should (almost always) produce different colors."""
81
+ # Use very distinct paths to ensure hash difference
82
+ assert folderColor("projects/alpha") != folderColor("projects/omega-completely-different")
83
+
84
+
85
+ def test_folder_color_hsl_values_in_range():
86
+ """HSL values should be within expected ranges."""
87
+ color = folderColor("some/path")
88
+ # Strip "hsl(" and ")" then parse
89
+ inner = color[4:-1] # e.g. "200, 70%, 55%"
90
+ parts = [p.strip().rstrip("%") for p in inner.split(",")]
91
+ hue, sat, lig = int(parts[0]), int(parts[1]), int(parts[2])
92
+ assert 0 <= hue < 360
93
+ assert 55 <= sat <= 79 # 55 + (h % 25)
94
+ assert 38 <= lig <= 51 # 38 + (h % 14)
95
+
96
+
97
+ def test_folder_color_empty_string():
98
+ """folderColor on empty string should not raise."""
99
+ color = folderColor("")
100
+ assert color.startswith("hsl(")
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Tests — empty KV returns empty graph (REQ-023)
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def test_empty_kv_returns_empty_graph(kv):
108
+ """Empty KV returns {nodes: [], edges: []}."""
109
+ result = folder_graph_build(kv)
110
+ assert result == {"nodes": [], "edges": []}
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Tests — node construction (REQ-023, REQ-024)
115
+ # ---------------------------------------------------------------------------
116
+
117
+ def test_one_node_per_unique_folder_path(kv):
118
+ """Two agents in the same folder produce a single node (REQ-023)."""
119
+ _write_pair(kv, "projects/alpha", "kiro", obs_count=3)
120
+ _write_pair(kv, "projects/alpha", "claude", obs_count=2)
121
+
122
+ result = folder_graph_build(kv)
123
+ assert len(result["nodes"]) == 1
124
+ node = result["nodes"][0]
125
+ assert node["folderPath"] == "projects/alpha"
126
+
127
+
128
+ def test_multiple_folders_produce_multiple_nodes(kv):
129
+ """Each distinct folder_path produces exactly one node."""
130
+ _write_pair(kv, "projects/alpha", "kiro")
131
+ _write_pair(kv, "projects/beta", "kiro")
132
+ _write_pair(kv, "projects/gamma", "claude")
133
+
134
+ result = folder_graph_build(kv)
135
+ folder_paths = {n["folderPath"] for n in result["nodes"]}
136
+ assert folder_paths == {"projects/alpha", "projects/beta", "projects/gamma"}
137
+
138
+
139
+ def test_node_fields_present(kv):
140
+ """Each node contains all required fields (REQ-024)."""
141
+ _write_pair(kv, "projects/alpha", "kiro", obs_count=5)
142
+
143
+ result = folder_graph_build(kv)
144
+ node = result["nodes"][0]
145
+ assert "id" in node
146
+ assert "label" in node
147
+ assert "folderPath" in node
148
+ assert "agentIds" in node
149
+ assert "obsCount" in node
150
+ assert "color" in node
151
+
152
+
153
+ def test_node_id_equals_folder_path(kv):
154
+ """Node id is the folderPath string."""
155
+ _write_pair(kv, "projects/alpha", "kiro")
156
+
157
+ result = folder_graph_build(kv)
158
+ node = result["nodes"][0]
159
+ assert node["id"] == "projects/alpha"
160
+ assert node["folderPath"] == "projects/alpha"
161
+
162
+
163
+ def test_node_label_is_basename(kv):
164
+ """Node label is the last path component."""
165
+ _write_pair(kv, "home/user/projects/myapp", "kiro")
166
+
167
+ result = folder_graph_build(kv)
168
+ node = result["nodes"][0]
169
+ assert node["label"] == "myapp"
170
+
171
+
172
+ def test_node_agent_ids_aggregated_and_sorted(kv):
173
+ """agentIds is the sorted union of all agents for that folder."""
174
+ _write_pair(kv, "projects/alpha", "zorro", obs_count=1)
175
+ _write_pair(kv, "projects/alpha", "alice", obs_count=1)
176
+ _write_pair(kv, "projects/alpha", "bob", obs_count=1)
177
+
178
+ result = folder_graph_build(kv)
179
+ node = result["nodes"][0]
180
+ assert node["agentIds"] == ["alice", "bob", "zorro"]
181
+
182
+
183
+ def test_node_obs_count_summed_across_agents(kv):
184
+ """obsCount is the sum across all agents for that folder."""
185
+ _write_pair(kv, "projects/alpha", "kiro", obs_count=4)
186
+ _write_pair(kv, "projects/alpha", "claude", obs_count=6)
187
+
188
+ result = folder_graph_build(kv)
189
+ node = result["nodes"][0]
190
+ assert node["obsCount"] == 10
191
+
192
+
193
+ def test_node_color_is_hsl(kv):
194
+ """Node color comes from folderColor and is an HSL string."""
195
+ _write_pair(kv, "projects/alpha", "kiro")
196
+
197
+ result = folder_graph_build(kv)
198
+ node = result["nodes"][0]
199
+ assert node["color"].startswith("hsl(")
200
+ # Must match folderColor directly
201
+ assert node["color"] == folderColor("projects/alpha")
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Tests — same-parent edges (REQ-025)
206
+ # ---------------------------------------------------------------------------
207
+
208
+ def test_same_parent_edge_created(kv):
209
+ """Two folders with the same parent get a same-parent edge."""
210
+ _write_pair(kv, "projects/alpha", "kiro")
211
+ _write_pair(kv, "projects/beta", "kiro") # both under "projects"
212
+
213
+ result = folder_graph_build(kv)
214
+ same_parent_edges = [e for e in result["edges"] if e["type"] == "same-parent"]
215
+ assert len(same_parent_edges) == 1
216
+ edge = same_parent_edges[0]
217
+ assert set([edge["source"], edge["target"]]) == {"projects/alpha", "projects/beta"}
218
+
219
+
220
+ def test_no_same_parent_edge_for_different_parents(kv):
221
+ """Folders with different parents do not get a same-parent edge."""
222
+ _write_pair(kv, "projects/alpha", "kiro")
223
+ _write_pair(kv, "work/beta", "kiro")
224
+
225
+ result = folder_graph_build(kv)
226
+ same_parent_edges = [e for e in result["edges"] if e["type"] == "same-parent"]
227
+ assert same_parent_edges == []
228
+
229
+
230
+ def test_same_parent_edge_only_for_sharing_pairs(kv):
231
+ """Only pairs sharing a parent get same-parent edges; non-sharing pairs do not."""
232
+ _write_pair(kv, "a/x", "kiro")
233
+ _write_pair(kv, "a/y", "kiro") # shares parent "a" with a/x
234
+ _write_pair(kv, "b/z", "kiro") # different parent "b"
235
+
236
+ result = folder_graph_build(kv)
237
+ same_parent_edges = [e for e in result["edges"] if e["type"] == "same-parent"]
238
+ assert len(same_parent_edges) == 1
239
+ edge = same_parent_edges[0]
240
+ assert set([edge["source"], edge["target"]]) == {"a/x", "a/y"}
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # Tests — cross-reference edges (REQ-026)
245
+ # ---------------------------------------------------------------------------
246
+
247
+ def test_cross_ref_edge_when_obs_mentions_other_folder(kv):
248
+ """A cross-ref edge is created when folder A's obs text mentions folder B's path."""
249
+ _write_pair(kv, "projects/alpha", "kiro",
250
+ obs_texts=["I worked on projects/beta today"])
251
+ _write_pair(kv, "projects/beta", "kiro", obs_texts=["nothing"])
252
+
253
+ result = folder_graph_build(kv)
254
+ cross_edges = [e for e in result["edges"] if e["type"] == "cross-ref"]
255
+ assert len(cross_edges) >= 1
256
+ sources_targets = {(e["source"], e["target"]) for e in cross_edges}
257
+ assert ("projects/alpha", "projects/beta") in sources_targets
258
+
259
+
260
+ def test_no_cross_ref_edge_when_no_mention(kv):
261
+ """No cross-ref edge when obs texts don't mention another folder path."""
262
+ _write_pair(kv, "projects/alpha", "kiro", obs_texts=["Just some work here"])
263
+ _write_pair(kv, "projects/beta", "kiro", obs_texts=["Unrelated content"])
264
+
265
+ result = folder_graph_build(kv)
266
+ cross_edges = [e for e in result["edges"] if e["type"] == "cross-ref"]
267
+ assert cross_edges == []
268
+
269
+
270
+ def test_cross_ref_edge_from_title_mention(kv):
271
+ """Cross-ref edges are also detected via obs titles."""
272
+ _write_pair(kv, "projects/alpha", "kiro",
273
+ obs_texts=["some text"])
274
+ # Manually insert obs with a title that mentions the other folder
275
+ obs = {
276
+ "id": "obs_special",
277
+ "folderPath": "projects/alpha",
278
+ "agentId": "kiro",
279
+ "timestamp": "2025-01-15T12:00:00.000Z",
280
+ "text": "normal text",
281
+ "type": "other",
282
+ "title": "work on projects/beta",
283
+ "concepts": [],
284
+ "files": [],
285
+ "importance": 5,
286
+ }
287
+ kv.set(KV.folder_obs("projects/alpha", "kiro"), "obs_special", obs)
288
+ _write_pair(kv, "projects/beta", "kiro", obs_texts=["nothing"])
289
+
290
+ result = folder_graph_build(kv)
291
+ cross_edges = [e for e in result["edges"] if e["type"] == "cross-ref"]
292
+ sources = {e["source"] for e in cross_edges}
293
+ assert "projects/alpha" in sources
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Tests — agent-shared edges (REQ-027)
298
+ # ---------------------------------------------------------------------------
299
+
300
+ def test_agent_shared_edge_created(kv):
301
+ """Two folders with a common agent get an agent-shared edge."""
302
+ _write_pair(kv, "projects/alpha", "kiro")
303
+ _write_pair(kv, "projects/beta", "kiro") # same agent "kiro"
304
+
305
+ result = folder_graph_build(kv)
306
+ agent_edges = [e for e in result["edges"] if e["type"] == "agent-shared"]
307
+ assert len(agent_edges) >= 1
308
+ edge = agent_edges[0]
309
+ assert set([edge["source"], edge["target"]]) == {"projects/alpha", "projects/beta"}
310
+
311
+
312
+ def test_no_agent_shared_edge_when_no_common_agent(kv):
313
+ """Folders with no common agents do not get an agent-shared edge."""
314
+ _write_pair(kv, "projects/alpha", "kiro")
315
+ _write_pair(kv, "projects/beta", "claude") # different agents
316
+
317
+ result = folder_graph_build(kv)
318
+ agent_edges = [e for e in result["edges"] if e["type"] == "agent-shared"]
319
+ assert agent_edges == []
320
+
321
+
322
+ def test_agent_shared_edge_with_partial_overlap(kv):
323
+ """Two folders with one common agent among several agents still get an edge."""
324
+ _write_pair(kv, "projects/alpha", "kiro")
325
+ _write_pair(kv, "projects/alpha", "claude")
326
+ _write_pair(kv, "projects/beta", "claude")
327
+ _write_pair(kv, "projects/beta", "cursor")
328
+
329
+ result = folder_graph_build(kv)
330
+ agent_edges = [e for e in result["edges"] if e["type"] == "agent-shared"]
331
+ endpoints = {frozenset([e["source"], e["target"]]) for e in agent_edges}
332
+ assert frozenset({"projects/alpha", "projects/beta"}) in endpoints
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Tests — edge deduplication (REQ-028)
337
+ # ---------------------------------------------------------------------------
338
+
339
+ def test_no_duplicate_edges(kv):
340
+ """No two edges share the same (source, target, type) pair."""
341
+ _write_pair(kv, "projects/alpha", "kiro",
342
+ obs_texts=["mentions projects/beta"])
343
+ _write_pair(kv, "projects/beta", "kiro",
344
+ obs_texts=["mentions projects/alpha"])
345
+
346
+ result = folder_graph_build(kv)
347
+ seen = set()
348
+ for edge in result["edges"]:
349
+ key = (frozenset([edge["source"], edge["target"]]), edge["type"])
350
+ assert key not in seen, f"Duplicate edge: {edge}"
351
+ seen.add(key)
352
+
353
+
354
+ def test_ab_and_ba_treated_as_same_edge(kv):
355
+ """(a, b, type) and (b, a, type) are considered the same edge."""
356
+ # Both folders reference each other — should produce only one cross-ref edge
357
+ _write_pair(kv, "projects/alpha", "kiro",
358
+ obs_texts=["See also projects/beta for details"])
359
+ _write_pair(kv, "projects/beta", "kiro",
360
+ obs_texts=["Related to projects/alpha work"])
361
+
362
+ result = folder_graph_build(kv)
363
+ cross_edges = [e for e in result["edges"] if e["type"] == "cross-ref"]
364
+ # Should be exactly 1 cross-ref edge (not 2)
365
+ assert len(cross_edges) == 1
366
+
367
+
368
+ def test_same_parent_and_agent_shared_are_separate_edge_types(kv):
369
+ """same-parent and agent-shared edges between the same pair are both kept."""
370
+ # Both folders share parent "projects" AND share agent "kiro"
371
+ _write_pair(kv, "projects/alpha", "kiro")
372
+ _write_pair(kv, "projects/beta", "kiro")
373
+
374
+ result = folder_graph_build(kv)
375
+ edge_types = {e["type"] for e in result["edges"]}
376
+ # We expect both types to appear
377
+ assert "same-parent" in edge_types
378
+ assert "agent-shared" in edge_types
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Tests — return structure
383
+ # ---------------------------------------------------------------------------
384
+
385
+ def test_return_has_nodes_and_edges_keys(kv):
386
+ """Result always has 'nodes' and 'edges' keys."""
387
+ _write_pair(kv, "projects/alpha", "kiro")
388
+ result = folder_graph_build(kv)
389
+ assert "nodes" in result
390
+ assert "edges" in result
391
+
392
+
393
+ def test_edge_has_required_fields(kv):
394
+ """Each edge has source, target, and type fields."""
395
+ _write_pair(kv, "projects/alpha", "kiro")
396
+ _write_pair(kv, "projects/beta", "kiro")
397
+
398
+ result = folder_graph_build(kv)
399
+ for edge in result["edges"]:
400
+ assert "source" in edge
401
+ assert "target" in edge
402
+ assert "type" in edge
403
+
404
+
405
+ def test_single_folder_produces_no_edges(kv):
406
+ """A graph with only one folder produces no edges."""
407
+ _write_pair(kv, "projects/alpha", "kiro")
408
+
409
+ result = folder_graph_build(kv)
410
+ assert len(result["nodes"]) == 1
411
+ assert result["edges"] == []
412
+
413
+
414
+ def test_edge_types_are_valid(kv):
415
+ """All edge types are one of the three valid values."""
416
+ _write_pair(kv, "projects/alpha", "kiro",
417
+ obs_texts=["mentions projects/beta"])
418
+ _write_pair(kv, "projects/beta", "kiro")
419
+
420
+ result = folder_graph_build(kv)
421
+ valid_types = {"same-parent", "cross-ref", "agent-shared"}
422
+ for edge in result["edges"]:
423
+ assert edge["type"] in valid_types
tests/test_folder_observe.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for folder_observe (REQ-008, REQ-010, REQ-011, REQ-015)."""
2
+ import sys
3
+ import os
4
+ import pytest
5
+ import tempfile
6
+ import datetime
7
+
8
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
9
+ from db import StateKV
10
+ import functions
11
+ from functions import folder_observe, KV
12
+
13
+
14
+ def make_kv(tmp_path):
15
+ db_path = os.path.join(str(tmp_path), 'test.db')
16
+ return StateKV(db_path=db_path)
17
+
18
+
19
+ def base_payload(**overrides):
20
+ payload = {
21
+ 'folderPath': '/home/user/projects/myapp',
22
+ 'agentId': 'kiro',
23
+ 'text': 'Edited src/app.py to add a new route',
24
+ 'timestamp': datetime.datetime.utcnow().isoformat() + 'Z',
25
+ }
26
+ payload.update(overrides)
27
+ return payload
28
+
29
+
30
+ class TestFolderObserveMissingFields:
31
+ def test_missing_folder_path(self, tmp_path):
32
+ kv = make_kv(tmp_path)
33
+ with pytest.raises(ValueError, match='folderPath'):
34
+ folder_observe(kv, base_payload(folderPath=''))
35
+
36
+ def test_missing_agent_id(self, tmp_path):
37
+ kv = make_kv(tmp_path)
38
+ with pytest.raises(ValueError, match='agentId'):
39
+ folder_observe(kv, base_payload(agentId=''))
40
+
41
+ def test_missing_text(self, tmp_path):
42
+ kv = make_kv(tmp_path)
43
+ with pytest.raises(ValueError, match='text'):
44
+ folder_observe(kv, base_payload(text=''))
45
+
46
+ def test_missing_timestamp_defaults(self, tmp_path):
47
+ kv = make_kv(tmp_path)
48
+ payload = base_payload()
49
+ del payload['timestamp']
50
+ # timestamp is required — should raise
51
+ with pytest.raises(ValueError, match='timestamp'):
52
+ folder_observe(kv, payload)
53
+
54
+
55
+ class TestFolderObserveSuccess:
56
+ def test_returns_observation_id(self, tmp_path):
57
+ kv = make_kv(tmp_path)
58
+ result = folder_observe(kv, base_payload())
59
+ assert 'observationId' in result
60
+ assert result['observationId'].startswith('fobs_')
61
+
62
+ def test_obs_stored_in_kv(self, tmp_path):
63
+ kv = make_kv(tmp_path)
64
+ result = folder_observe(kv, base_payload())
65
+ obs_id = result['observationId']
66
+ fp = 'home/user/projects/myapp' # normalized
67
+ stored = kv.get(KV.folder_obs(fp, 'kiro'), obs_id)
68
+ assert stored is not None
69
+ assert stored['id'] == obs_id
70
+
71
+ def test_obs_count_incremented(self, tmp_path):
72
+ kv = make_kv(tmp_path)
73
+ folder_observe(kv, base_payload())
74
+ folder_observe(kv, base_payload())
75
+ fp = 'home/user/projects/myapp'
76
+ meta = kv.get(KV.folder_meta(fp, 'kiro'), 'meta')
77
+ assert meta is not None
78
+ assert meta['obsCount'] == 2
79
+
80
+ def test_folders_index_upserted(self, tmp_path):
81
+ kv = make_kv(tmp_path)
82
+ folder_observe(kv, base_payload())
83
+ fp = 'home/user/projects/myapp'
84
+ entry = kv.get(KV.folders, f'{fp}:kiro')
85
+ assert entry is not None
86
+ assert entry['folderPath'] == fp
87
+ assert entry['agentId'] == 'kiro'
88
+
89
+ def test_text_capped_at_4000(self, tmp_path):
90
+ kv = make_kv(tmp_path)
91
+ long_text = 'x' * 5000
92
+ result = folder_observe(kv, base_payload(text=long_text))
93
+ fp = 'home/user/projects/myapp'
94
+ stored = kv.get(KV.folder_obs(fp, 'kiro'), result['observationId'])
95
+ assert len(stored['text']) <= 4000
96
+
97
+
98
+ class TestFolderObserveCap:
99
+ def test_cap_enforced(self, tmp_path, monkeypatch):
100
+ monkeypatch.setenv('MAX_OBS_PER_FOLDER', '3')
101
+ kv = make_kv(tmp_path)
102
+ for _ in range(3):
103
+ folder_observe(kv, base_payload())
104
+ with pytest.raises(ValueError, match='limit'):
105
+ folder_observe(kv, base_payload())
106
+
107
+
108
+ class TestFolderObservePairIsolation:
109
+ def test_different_pairs_isolated(self, tmp_path):
110
+ kv = make_kv(tmp_path)
111
+ folder_observe(kv, base_payload(folderPath='/home/user/proj-a', agentId='kiro'))
112
+ folder_observe(kv, base_payload(folderPath='/home/user/proj-b', agentId='claude'))
113
+ fp_a = 'home/user/proj-a'
114
+ fp_b = 'home/user/proj-b'
115
+ obs_a = kv.list(KV.folder_obs(fp_a, 'kiro'))
116
+ obs_b = kv.list(KV.folder_obs(fp_b, 'claude'))
117
+ ids_a = {o['id'] for o in obs_a}
118
+ ids_b = {o['id'] for o in obs_b}
119
+ assert ids_a.isdisjoint(ids_b)
tests/test_forget.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for forget() folder-based deletion (REQ-029–REQ-033)."""
2
+ import sys
3
+ import os
4
+ import pytest
5
+ import datetime
6
+
7
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
8
+ from db import StateKV
9
+ from functions import folder_observe, forget, KV
10
+
11
+
12
+ def make_kv(tmp_path):
13
+ db_path = os.path.join(str(tmp_path), 'test.db')
14
+ return StateKV(db_path=db_path)
15
+
16
+
17
+ def add_obs(kv, folder='/home/user/proj', agent='kiro', n=1):
18
+ ids = []
19
+ for i in range(n):
20
+ result = folder_observe(kv, {
21
+ 'folderPath': folder,
22
+ 'agentId': agent,
23
+ 'text': f'observation {i}',
24
+ 'timestamp': datetime.datetime.utcnow().isoformat() + 'Z',
25
+ })
26
+ ids.append(result['observationId'])
27
+ return ids
28
+
29
+
30
+ class TestForgetFullPair:
31
+ def test_full_deletion_clears_obs(self, tmp_path):
32
+ kv = make_kv(tmp_path)
33
+ add_obs(kv, n=3)
34
+ fp = 'home/user/proj'
35
+ result = forget(kv, {'folderPath': '/home/user/proj', 'agentId': 'kiro'})
36
+ assert result['deleted'] >= 3
37
+ obs = kv.list(KV.folder_obs(fp, 'kiro'))
38
+ assert len(obs) == 0
39
+
40
+ def test_full_deletion_removes_index_entry(self, tmp_path):
41
+ kv = make_kv(tmp_path)
42
+ add_obs(kv)
43
+ fp = 'home/user/proj'
44
+ forget(kv, {'folderPath': '/home/user/proj', 'agentId': 'kiro'})
45
+ entry = kv.get(KV.folders, f'{fp}:kiro')
46
+ assert entry is None
47
+
48
+ def test_full_deletion_removes_meta(self, tmp_path):
49
+ kv = make_kv(tmp_path)
50
+ add_obs(kv)
51
+ fp = 'home/user/proj'
52
+ forget(kv, {'folderPath': '/home/user/proj', 'agentId': 'kiro'})
53
+ meta = kv.get(KV.folder_meta(fp, 'kiro'), 'meta')
54
+ assert meta is None
55
+
56
+ def test_deleted_count_matches(self, tmp_path):
57
+ kv = make_kv(tmp_path)
58
+ add_obs(kv, n=5)
59
+ result = forget(kv, {'folderPath': '/home/user/proj', 'agentId': 'kiro'})
60
+ assert result['deleted'] == 5
61
+
62
+
63
+ class TestForgetPartial:
64
+ def test_partial_deletion(self, tmp_path):
65
+ kv = make_kv(tmp_path)
66
+ ids = add_obs(kv, n=4)
67
+ fp = 'home/user/proj'
68
+ to_delete = ids[:2]
69
+ result = forget(kv, {
70
+ 'folderPath': '/home/user/proj',
71
+ 'agentId': 'kiro',
72
+ 'observationIds': to_delete,
73
+ })
74
+ assert result['deleted'] == 2
75
+ remaining = kv.list(KV.folder_obs(fp, 'kiro'))
76
+ remaining_ids = {o['id'] for o in remaining}
77
+ for oid in to_delete:
78
+ assert oid not in remaining_ids
79
+
80
+ def test_partial_decrements_obs_count(self, tmp_path):
81
+ kv = make_kv(tmp_path)
82
+ ids = add_obs(kv, n=4)
83
+ fp = 'home/user/proj'
84
+ forget(kv, {
85
+ 'folderPath': '/home/user/proj',
86
+ 'agentId': 'kiro',
87
+ 'observationIds': ids[:2],
88
+ })
89
+ meta = kv.get(KV.folder_meta(fp, 'kiro'), 'meta')
90
+ assert meta['obsCount'] == 2
91
+
92
+
93
+ class TestForgetMemory:
94
+ def test_delete_global_memory(self, tmp_path):
95
+ kv = make_kv(tmp_path)
96
+ from functions import remember
97
+ result = remember(kv, {'content': 'Important insight', 'type': 'fact'})
98
+ mem_id = result['memory']['id']
99
+ forget_result = forget(kv, {'memoryId': mem_id})
100
+ assert forget_result['deleted'] >= 1
101
+ stored = kv.get(KV.memories, mem_id)
102
+ assert stored is None
tests/test_graph.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for folder_graph_build (REQ-023–REQ-028)."""
2
+ import sys
3
+ import os
4
+ import pytest
5
+ import datetime
6
+
7
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
8
+ from db import StateKV
9
+ from functions import folder_observe, folder_graph_build
10
+
11
+
12
+ def make_kv(tmp_path):
13
+ db_path = os.path.join(str(tmp_path), 'test.db')
14
+ return StateKV(db_path=db_path)
15
+
16
+
17
+ def add_obs(kv, folder, agent='kiro', text='obs'):
18
+ folder_observe(kv, {
19
+ 'folderPath': folder,
20
+ 'agentId': agent,
21
+ 'text': text,
22
+ 'timestamp': datetime.datetime.utcnow().isoformat() + 'Z',
23
+ })
24
+
25
+
26
+ class TestGraphEmpty:
27
+ def test_empty_kv_returns_empty(self, tmp_path):
28
+ kv = make_kv(tmp_path)
29
+ result = folder_graph_build(kv)
30
+ assert result == {'nodes': [], 'edges': []}
31
+
32
+
33
+ class TestGraphNodes:
34
+ def test_one_node_per_folder(self, tmp_path):
35
+ kv = make_kv(tmp_path)
36
+ add_obs(kv, '/home/user/proj-a', agent='kiro')
37
+ add_obs(kv, '/home/user/proj-a', agent='claude') # same folder, different agent
38
+ add_obs(kv, '/home/user/proj-b', agent='kiro')
39
+ result = folder_graph_build(kv)
40
+ node_ids = [n['id'] for n in result['nodes']]
41
+ assert len(node_ids) == len(set(node_ids)) # no duplicates
42
+ # 2 unique folders
43
+ assert len(result['nodes']) == 2
44
+
45
+ def test_node_has_required_fields(self, tmp_path):
46
+ kv = make_kv(tmp_path)
47
+ add_obs(kv, '/home/user/proj')
48
+ result = folder_graph_build(kv)
49
+ node = result['nodes'][0]
50
+ assert 'id' in node
51
+ assert 'label' in node
52
+ assert 'folderPath' in node
53
+ assert 'agentIds' in node
54
+ assert 'obsCount' in node
55
+ assert 'color' in node
56
+
57
+ def test_agent_ids_aggregated(self, tmp_path):
58
+ kv = make_kv(tmp_path)
59
+ add_obs(kv, '/home/user/proj', agent='kiro')
60
+ add_obs(kv, '/home/user/proj', agent='claude')
61
+ result = folder_graph_build(kv)
62
+ node = result['nodes'][0]
63
+ assert 'kiro' in node['agentIds']
64
+ assert 'claude' in node['agentIds']
65
+
66
+
67
+ class TestGraphEdges:
68
+ def test_same_parent_edge(self, tmp_path):
69
+ kv = make_kv(tmp_path)
70
+ add_obs(kv, '/home/user/proj/src')
71
+ add_obs(kv, '/home/user/proj/tests')
72
+ result = folder_graph_build(kv)
73
+ same_parent = [e for e in result['edges'] if e['type'] == 'same-parent']
74
+ assert len(same_parent) >= 1
75
+
76
+ def test_no_duplicate_edges(self, tmp_path):
77
+ kv = make_kv(tmp_path)
78
+ add_obs(kv, '/home/user/proj/src')
79
+ add_obs(kv, '/home/user/proj/tests')
80
+ result = folder_graph_build(kv)
81
+ edge_keys = [(e['source'], e['target'], e['type']) for e in result['edges']]
82
+ assert len(edge_keys) == len(set(edge_keys))
83
+
84
+ def test_agent_shared_edge(self, tmp_path):
85
+ kv = make_kv(tmp_path)
86
+ add_obs(kv, '/home/user/proj-a', agent='kiro')
87
+ add_obs(kv, '/home/user/proj-b', agent='kiro')
88
+ result = folder_graph_build(kv)
89
+ agent_edges = [e for e in result['edges'] if e['type'] == 'agent-shared']
90
+ assert len(agent_edges) >= 1
91
+
92
+ def test_cross_ref_edge(self, tmp_path):
93
+ kv = make_kv(tmp_path)
94
+ fp_b = 'home/user/proj-b'
95
+ add_obs(kv, '/home/user/proj-a', text=f'Modified files in {fp_b}')
96
+ add_obs(kv, '/home/user/proj-b', text='Normal work')
97
+ result = folder_graph_build(kv)
98
+ cross_ref = [e for e in result['edges'] if e['type'] == 'cross-ref']
99
+ assert len(cross_ref) >= 1
tests/test_migration.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for migrate_sessions_to_folders (REQ-058–REQ-062)."""
2
+ import sys
3
+ import os
4
+ import pytest
5
+ import datetime
6
+
7
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
8
+ from db import StateKV
9
+ from functions import migrate_sessions_to_folders, KV
10
+
11
+
12
+ def make_kv(tmp_path):
13
+ db_path = os.path.join(str(tmp_path), 'test.db')
14
+ return StateKV(db_path=db_path)
15
+
16
+
17
+ def seed_session(kv, session_id='sess_001', cwd='/home/user/proj', agent='kiro', obs_count=3):
18
+ """Seed a legacy session with observations into the old schema."""
19
+ ts = datetime.datetime.utcnow().isoformat() + 'Z'
20
+ session = {
21
+ 'id': session_id,
22
+ 'project': cwd,
23
+ 'cwd': cwd,
24
+ 'agentId': agent,
25
+ 'startedAt': ts,
26
+ 'updatedAt': ts,
27
+ 'status': 'completed',
28
+ }
29
+ kv.set(KV.sessions, session_id, session)
30
+ obs_ids = []
31
+ for i in range(obs_count):
32
+ obs_id = f'obs_{i}'
33
+ obs = {
34
+ 'id': obs_id,
35
+ 'sessionId': session_id,
36
+ 'timestamp': ts,
37
+ 'type': 'file_edit',
38
+ 'title': f'Edit {i}',
39
+ 'narrative': f'Edited file {i}',
40
+ 'concepts': ['python'],
41
+ 'files': [f'src/file_{i}.py'],
42
+ 'importance': 5,
43
+ }
44
+ kv.set(KV.observations(session_id), obs_id, obs)
45
+ obs_ids.append(obs_id)
46
+ return obs_ids
47
+
48
+
49
+ class TestMigrateDryRun:
50
+ def test_dry_run_writes_nothing(self, tmp_path):
51
+ kv = make_kv(tmp_path)
52
+ seed_session(kv)
53
+ result = migrate_sessions_to_folders(kv, dry_run=True)
54
+ assert result['migrated_sessions'] > 0
55
+ assert result['migrated_observations'] > 0
56
+ # No folder obs should have been written
57
+ fp = 'home/user/proj'
58
+ obs = kv.list(KV.folder_obs(fp, 'kiro'))
59
+ assert len(obs) == 0
60
+
61
+ def test_dry_run_returns_counts(self, tmp_path):
62
+ kv = make_kv(tmp_path)
63
+ seed_session(kv, obs_count=5)
64
+ result = migrate_sessions_to_folders(kv, dry_run=True)
65
+ assert result['migrated_observations'] == 5
66
+ assert result['dry_run'] is True
67
+
68
+
69
+ class TestMigrateActual:
70
+ def test_migrates_observations(self, tmp_path):
71
+ kv = make_kv(tmp_path)
72
+ seed_session(kv, obs_count=3)
73
+ migrate_sessions_to_folders(kv, dry_run=False)
74
+ fp = 'home/user/proj'
75
+ obs = kv.list(KV.folder_obs(fp, 'kiro'))
76
+ assert len(obs) == 3
77
+
78
+ def test_skips_raw_observations(self, tmp_path):
79
+ kv = make_kv(tmp_path)
80
+ seed_session(kv, obs_count=2)
81
+ # Add a raw obs
82
+ kv.set(KV.observations('sess_001'), 'obs_0:raw', {
83
+ 'id': 'obs_0:raw', 'sessionId': 'sess_001',
84
+ 'timestamp': datetime.datetime.utcnow().isoformat() + 'Z',
85
+ })
86
+ result = migrate_sessions_to_folders(kv, dry_run=False)
87
+ # Raw obs should not be counted
88
+ assert result['migrated_observations'] == 2
89
+
90
+ def test_nondestructive(self, tmp_path):
91
+ kv = make_kv(tmp_path)
92
+ seed_session(kv)
93
+ migrate_sessions_to_folders(kv, dry_run=False)
94
+ # Old session data still there
95
+ session = kv.get(KV.sessions, 'sess_001')
96
+ assert session is not None
97
+
98
+ def test_unknown_fallback(self, tmp_path):
99
+ kv = make_kv(tmp_path)
100
+ # Session with no cwd or project
101
+ ts = datetime.datetime.utcnow().isoformat() + 'Z'
102
+ kv.set(KV.sessions, 'sess_no_path', {
103
+ 'id': 'sess_no_path', 'startedAt': ts, 'updatedAt': ts, 'status': 'completed',
104
+ })
105
+ kv.set(KV.observations('sess_no_path'), 'obs_x', {
106
+ 'id': 'obs_x', 'sessionId': 'sess_no_path',
107
+ 'timestamp': ts, 'type': 'other', 'title': 'x', 'narrative': 'x',
108
+ })
109
+ result = migrate_sessions_to_folders(kv, dry_run=False)
110
+ # Should succeed with 'unknown' fallback
111
+ assert result['migrated_sessions'] >= 1
112
+
113
+ def test_returns_error_list(self, tmp_path):
114
+ kv = make_kv(tmp_path)
115
+ seed_session(kv)
116
+ result = migrate_sessions_to_folders(kv, dry_run=False)
117
+ assert 'errors' in result
118
+ assert isinstance(result['errors'], list)
tests/test_normalize.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for normalize_folder_path (REQ-002, REQ-063, REQ-064, REQ-066)."""
2
+ import sys
3
+ import os
4
+ import pytest
5
+
6
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
7
+ from functions import normalize_folder_path
8
+
9
+
10
+ class TestNormalizeFolderPath:
11
+ def test_unix_path(self):
12
+ assert normalize_folder_path('/home/user/projects/myapp') == 'home/user/projects/myapp'
13
+
14
+ def test_windows_path(self):
15
+ result = normalize_folder_path('C:\\Users\\foo\\projects\\myapp')
16
+ assert '\\' not in result
17
+ assert 'Users' in result or 'users' in result.lower()
18
+
19
+ def test_trailing_slash_stripped(self):
20
+ result = normalize_folder_path('/home/user/projects/')
21
+ assert not result.endswith('/')
22
+
23
+ def test_leading_slash_stripped(self):
24
+ result = normalize_folder_path('/home/user/projects')
25
+ assert not result.startswith('/')
26
+
27
+ def test_double_slashes_collapsed(self):
28
+ result = normalize_folder_path('/home//user///projects')
29
+ assert '//' not in result
30
+
31
+ def test_empty_string_raises(self):
32
+ with pytest.raises(ValueError):
33
+ normalize_folder_path('')
34
+
35
+ def test_path_traversal_raises(self):
36
+ with pytest.raises(ValueError):
37
+ normalize_folder_path('/home/user/../../etc/passwd')
38
+
39
+ def test_length_cap(self):
40
+ long_path = 'a/' * 300
41
+ result = normalize_folder_path(long_path)
42
+ assert len(result) <= 512
43
+
44
+ def test_idempotent(self):
45
+ path = '/home/user/projects/myapp'
46
+ once = normalize_folder_path(path)
47
+ twice = normalize_folder_path(once)
48
+ assert once == twice
49
+
50
+ def test_relative_path(self):
51
+ result = normalize_folder_path('projects/myapp/src')
52
+ assert result == 'projects/myapp/src'
53
+
54
+ def test_windows_forward_slashes(self):
55
+ result = normalize_folder_path('C:/Users/foo/projects')
56
+ assert '\\' not in result
57
+
58
+ def test_single_segment(self):
59
+ result = normalize_folder_path('/workspace')
60
+ assert result == 'workspace'
tests/test_timeline.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for folder_timeline (REQ-020, REQ-021, REQ-022, REQ-071)."""
2
+ import sys
3
+ import os
4
+ import pytest
5
+ import datetime
6
+
7
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
8
+ from db import StateKV
9
+ from functions import folder_observe, folder_timeline
10
+
11
+
12
+ def make_kv(tmp_path):
13
+ db_path = os.path.join(str(tmp_path), 'test.db')
14
+ return StateKV(db_path=db_path)
15
+
16
+
17
+ def ts(offset_seconds=0):
18
+ dt = datetime.datetime(2025, 1, 15, 10, 0, 0) + datetime.timedelta(seconds=offset_seconds)
19
+ return dt.isoformat() + 'Z'
20
+
21
+
22
+ def add_obs(kv, folder='/home/user/proj', agent='kiro', timestamp=None, text='obs'):
23
+ return folder_observe(kv, {
24
+ 'folderPath': folder,
25
+ 'agentId': agent,
26
+ 'text': text,
27
+ 'timestamp': timestamp or ts(),
28
+ })
29
+
30
+
31
+ class TestTimelineOrdering:
32
+ def test_results_sorted_desc(self, tmp_path):
33
+ kv = make_kv(tmp_path)
34
+ add_obs(kv, timestamp=ts(0))
35
+ add_obs(kv, timestamp=ts(60))
36
+ add_obs(kv, timestamp=ts(30))
37
+ results = folder_timeline(kv)
38
+ timestamps = [r['timestamp'] for r in results]
39
+ assert timestamps == sorted(timestamps, reverse=True)
40
+
41
+ def test_empty_returns_empty(self, tmp_path):
42
+ kv = make_kv(tmp_path)
43
+ results = folder_timeline(kv)
44
+ assert results == []
45
+
46
+
47
+ class TestTimelineLimit:
48
+ def test_limit_respected(self, tmp_path):
49
+ kv = make_kv(tmp_path)
50
+ for i in range(10):
51
+ add_obs(kv, timestamp=ts(i))
52
+ results = folder_timeline(kv, limit=5)
53
+ assert len(results) == 5
54
+
55
+ def test_default_limit_100(self, tmp_path):
56
+ kv = make_kv(tmp_path)
57
+ for i in range(150):
58
+ add_obs(kv, timestamp=ts(i))
59
+ results = folder_timeline(kv)
60
+ assert len(results) == 100
61
+
62
+
63
+ class TestTimelineFilters:
64
+ def test_folder_filter(self, tmp_path):
65
+ kv = make_kv(tmp_path)
66
+ add_obs(kv, folder='/home/user/proj-a', timestamp=ts(0))
67
+ add_obs(kv, folder='/home/user/proj-b', timestamp=ts(1))
68
+ results = folder_timeline(kv, folder_path='home/user/proj-a')
69
+ assert all(r['folderPath'] == 'home/user/proj-a' for r in results)
70
+ assert len(results) == 1
71
+
72
+ def test_agent_filter(self, tmp_path):
73
+ kv = make_kv(tmp_path)
74
+ add_obs(kv, agent='kiro', timestamp=ts(0))
75
+ add_obs(kv, agent='claude', timestamp=ts(1))
76
+ results = folder_timeline(kv, agent_id='kiro')
77
+ assert all(r['agentId'] == 'kiro' for r in results)
78
+
79
+ def test_before_filter(self, tmp_path):
80
+ kv = make_kv(tmp_path)
81
+ add_obs(kv, timestamp=ts(0)) # 10:00:00
82
+ add_obs(kv, timestamp=ts(60)) # 10:01:00
83
+ add_obs(kv, timestamp=ts(120)) # 10:02:00
84
+ results = folder_timeline(kv, before=ts(90))
85
+ # Should only include obs before 10:01:30
86
+ for r in results:
87
+ assert r['timestamp'] < ts(90)
88
+
89
+ def test_after_filter(self, tmp_path):
90
+ kv = make_kv(tmp_path)
91
+ add_obs(kv, timestamp=ts(0))
92
+ add_obs(kv, timestamp=ts(60))
93
+ add_obs(kv, timestamp=ts(120))
94
+ results = folder_timeline(kv, after=ts(30))
95
+ for r in results:
96
+ assert r['timestamp'] > ts(30)