Spaces:
Sleeping
Sleeping
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 +1 -0
- .kiro/specs/folder-based-memory/design.md +1026 -0
- .kiro/specs/folder-based-memory/requirements.md +141 -0
- .kiro/specs/folder-based-memory/tasks.md +320 -0
- requirements.txt +4 -0
- src/app.py +295 -908
- src/functions.py +1034 -228
- src/viewer/index.html +300 -252
- tests/__init__.py +1 -0
- tests/test_folder_graph_build.py +423 -0
- tests/test_folder_observe.py +119 -0
- tests/test_forget.py +102 -0
- tests/test_graph.py +99 -0
- tests/test_migration.py +118 -0
- tests/test_normalize.py +60 -0
- tests/test_timeline.py +96 -0
.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 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 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 |
-
"
|
| 369 |
-
"
|
| 370 |
-
"
|
| 371 |
-
"hookType": "post_tool_use",
|
| 372 |
"timestamp": timestamp,
|
| 373 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
}
|
| 375 |
-
|
| 376 |
-
|
| 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 |
-
|
| 411 |
-
|
| 412 |
-
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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 722 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
|
| 724 |
-
|
| 725 |
-
|
|
|
|
| 726 |
auth_err = check_auth()
|
| 727 |
if auth_err:
|
| 728 |
return auth_err
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
"limit": limit
|
| 740 |
-
})
|
| 741 |
-
return jsonify(res), 200
|
| 742 |
|
| 743 |
-
|
| 744 |
-
|
|
|
|
| 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 |
-
|
| 752 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
except Exception as e:
|
| 754 |
return jsonify({"error": str(e)}), 400
|
| 755 |
|
| 756 |
-
|
| 757 |
-
|
|
|
|
| 758 |
auth_err = check_auth()
|
| 759 |
if auth_err:
|
| 760 |
return auth_err
|
| 761 |
-
|
| 762 |
-
|
| 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 |
-
|
| 770 |
-
|
|
|
|
| 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 |
-
|
| 778 |
-
|
| 779 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 942 |
-
|
| 943 |
-
|
| 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 |
-
|
| 995 |
-
|
| 996 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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": "
|
| 1915 |
"inputSchema": {"type": "object", "properties": {}}
|
| 1916 |
},
|
| 1917 |
{
|
| 1918 |
"name": "memory_forget",
|
| 1919 |
-
"description": "Delete a memory
|
| 1920 |
"inputSchema": {
|
| 1921 |
"type": "object",
|
| 1922 |
"properties": {
|
| 1923 |
"memoryId": {"type": "string", "description": "Memory ID to delete"},
|
| 1924 |
-
"
|
|
|
|
| 1925 |
"observationIds": {
|
| 1926 |
"oneOf": [
|
| 1927 |
{"type": "string", "description": "Comma-separated observation IDs to delete"},
|
| 1928 |
-
{"type": "array", "items": {"type": "string"}
|
| 1929 |
]
|
| 1930 |
}
|
| 1931 |
}
|
|
@@ -1933,208 +1670,84 @@ def mcp_tools_list():
|
|
| 1933 |
},
|
| 1934 |
{
|
| 1935 |
"name": "memory_export",
|
| 1936 |
-
"description": "Export all
|
| 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
|
| 1948 |
"inputSchema": {
|
| 1949 |
"type": "object",
|
| 1950 |
"properties": {
|
| 1951 |
-
"
|
| 1952 |
-
"
|
| 1953 |
-
"
|
| 1954 |
-
"
|
| 1955 |
-
"
|
| 1956 |
-
"
|
| 1957 |
-
"
|
| 1958 |
-
"
|
|
|
|
|
|
|
| 1959 |
},
|
| 1960 |
-
"required": ["
|
| 1961 |
}
|
| 1962 |
},
|
| 1963 |
{
|
| 1964 |
"name": "agent_remember",
|
| 1965 |
-
"description": "Explicitly save a key insight, fact,
|
| 1966 |
"inputSchema": {
|
| 1967 |
"type": "object",
|
| 1968 |
"properties": {
|
| 1969 |
-
"agentId": {"type": "string", "description": "ID/Name of the agent (
|
| 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"}
|
| 1977 |
]
|
| 1978 |
},
|
| 1979 |
"files": {
|
| 1980 |
"oneOf": [
|
| 1981 |
{"type": "string", "description": "Comma-separated relevant file paths"},
|
| 1982 |
-
{"type": "array", "items": {"type": "string"}
|
| 1983 |
]
|
| 1984 |
}
|
| 1985 |
},
|
| 1986 |
-
"required": ["content"
|
| 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": "
|
| 2017 |
-
"description": "List all
|
| 2018 |
"inputSchema": {"type": "object", "properties": {}}
|
| 2019 |
},
|
| 2020 |
{
|
| 2021 |
-
"name": "
|
| 2022 |
-
"description": "
|
| 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 |
-
"
|
| 2088 |
-
"
|
| 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": ["
|
| 2100 |
}
|
| 2101 |
},
|
| 2102 |
{
|
| 2103 |
-
"name": "
|
| 2104 |
-
"description": "
|
| 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 |
-
"
|
| 2136 |
-
|
| 2137 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2164 |
-
|
|
|
|
| 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 |
-
|
| 2206 |
-
|
| 2207 |
-
|
| 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 |
-
"
|
|
|
|
| 2273 |
"observationIds": obs_ids
|
| 2274 |
})
|
| 2275 |
text_out = json.dumps(res, indent=2)
|
| 2276 |
|
| 2277 |
elif name == "memory_export":
|
| 2278 |
-
|
| 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 |
-
|
| 2288 |
-
|
| 2289 |
-
|
| 2290 |
-
|
| 2291 |
-
|
| 2292 |
-
|
| 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 |
-
"
|
| 2300 |
-
"
|
| 2301 |
-
"
|
| 2302 |
-
"
|
| 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.
|
| 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 |
-
|
| 2324 |
-
return jsonify({"error": "content and project are required"}), 400
|
| 2325 |
-
|
| 2326 |
payload = {
|
| 2327 |
-
"content": content,
|
| 2328 |
-
"
|
| 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 == "
|
| 2354 |
-
|
| 2355 |
-
|
| 2356 |
-
|
| 2357 |
-
|
| 2358 |
-
|
| 2359 |
-
|
| 2360 |
-
|
| 2361 |
-
|
| 2362 |
-
|
| 2363 |
-
|
| 2364 |
-
|
| 2365 |
-
|
| 2366 |
-
|
| 2367 |
-
|
| 2368 |
-
|
| 2369 |
-
|
| 2370 |
-
|
| 2371 |
-
|
| 2372 |
-
|
| 2373 |
-
|
| 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 |
-
#
|
| 30 |
class KV:
|
| 31 |
-
|
| 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 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
|
| 77 |
@staticmethod
|
| 78 |
-
def
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
@staticmethod
|
| 82 |
-
def
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
|
| 86 |
-
def team_profile(team_id: str) -> str:
|
| 87 |
-
return f"mem:team:{team_id}:profile"
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
return f"mem:enriched:{session_id}"
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 410 |
cursor.execute(
|
| 411 |
-
"SELECT DISTINCT scope FROM kv_store WHERE scope LIKE
|
| 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(['
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 767 |
for k in ["hookType", "raw", "toolName", "toolInput", "toolOutput", "userPrompt"]:
|
| 768 |
-
if k in
|
| 769 |
-
synthetic[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 |
-
|
| 924 |
-
kv.
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
if _vector_index:
|
| 933 |
-
_vector_index.remove(
|
|
|
|
| 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}
|
| 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(
|
| 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(
|
| 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 |
-
|
| 1250 |
-
|
| 1251 |
-
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
| 1693 |
-
|
| 1694 |
-
|
|
|
|
|
|
|
|
|
|
| 1695 |
continue
|
| 1696 |
-
obs_list = kv.list(KV.
|
| 1697 |
for obs in obs_list:
|
| 1698 |
-
|
| 1699 |
-
|
| 1700 |
-
|
| 1701 |
-
|
| 1702 |
-
|
| 1703 |
-
|
| 1704 |
-
|
| 1705 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1904 |
-
|
| 1905 |
-
|
| 1906 |
-
|
| 1907 |
-
|
| 1908 |
-
|
| 1909 |
-
|
| 1910 |
-
|
| 1911 |
-
|
| 1912 |
-
|
| 1913 |
-
|
| 1914 |
-
|
| 1915 |
-
|
| 1916 |
-
|
| 1917 |
-
|
| 1918 |
-
|
| 1919 |
-
|
| 1920 |
-
|
| 1921 |
-
|
| 1922 |
-
|
| 1923 |
-
|
| 1924 |
-
|
| 1925 |
-
|
| 1926 |
-
|
|
|
|
| 1927 |
memories = kv.list(KV.memories)
|
| 1928 |
-
|
| 1929 |
-
|
| 1930 |
-
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2135 |
-
kv.
|
| 2136 |
-
|
| 2137 |
-
|
| 2138 |
-
|
| 2139 |
-
|
| 2140 |
-
|
| 2141 |
-
|
| 2142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2143 |
if _vector_index:
|
| 2144 |
-
_vector_index.remove(
|
|
|
|
| 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": "
|
| 2179 |
-
"
|
| 2180 |
-
"
|
| 2181 |
-
"
|
| 2182 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="#
|
| 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="
|
| 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-
|
| 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: '☑', other: '📄'
|
| 1269 |
};
|
| 1270 |
var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' };
|
| 1271 |
-
var TAB_IDS = ['
|
| 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 : '
|
| 1456 |
}
|
| 1457 |
|
| 1458 |
function tabFromRoute() {
|
| 1459 |
try {
|
| 1460 |
return normalizeTab(decodeURIComponent(window.location.hash.slice(1)));
|
| 1461 |
} catch (_) {
|
| 1462 |
-
return '
|
| 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 '
|
| 1505 |
-
case 'graph':
|
| 1506 |
-
case 'memories':
|
| 1507 |
-
case 'timeline':
|
| 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 |
-
|
| 1522 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1523 |
try {
|
| 1524 |
-
var
|
| 1525 |
-
|
| 1526 |
-
|
| 1527 |
-
|
| 1528 |
-
|
| 1529 |
-
|
| 1530 |
-
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 1534 |
-
|
| 1535 |
-
|
| 1536 |
-
state.
|
| 1537 |
-
|
| 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">−</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 |
-
//
|
| 1867 |
-
var
|
| 1868 |
-
|
| 1869 |
-
|
| 1870 |
-
|
| 1871 |
-
|
| 1872 |
-
|
| 1873 |
-
|
| 1874 |
-
|
| 1875 |
-
|
| 1876 |
-
|
| 1877 |
-
|
| 1878 |
-
|
| 1879 |
-
|
| 1880 |
-
|
| 1881 |
-
|
| 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 |
-
|
| 1917 |
-
|
| 1918 |
-
|
| 1919 |
-
|
| 1920 |
-
|
| 1921 |
-
|
| 1922 |
-
|
| 1923 |
-
|
| 1924 |
-
|
| 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 '• ' + 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
|
| 2814 |
state.memories.loaded = false;
|
| 2815 |
loadMemories();
|
| 2816 |
}
|
|
@@ -2819,63 +2786,132 @@
|
|
| 2819 |
document.getElementById('modal-overlay').classList.remove('open');
|
| 2820 |
}
|
| 2821 |
|
| 2822 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2823 |
var el = document.getElementById('view-timeline');
|
|
|
|
| 2824 |
el.innerHTML = '<div class="loading">Loading timeline...</div>';
|
| 2825 |
-
|
| 2826 |
-
|
| 2827 |
-
|
| 2828 |
-
|
| 2829 |
-
|
| 2830 |
-
|
| 2831 |
-
|
| 2832 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2833 |
}
|
| 2834 |
-
|
| 2835 |
-
renderTimelineToolbar(sessions);
|
| 2836 |
-
if (state.timeline.sessionId) await loadObservations();
|
| 2837 |
}
|
| 2838 |
|
| 2839 |
-
function
|
| 2840 |
var el = document.getElementById('view-timeline');
|
| 2841 |
-
|
| 2842 |
-
|
| 2843 |
-
|
| 2844 |
-
|
| 2845 |
-
|
| 2846 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2847 |
});
|
| 2848 |
html += '</select>';
|
| 2849 |
-
html += '<
|
| 2850 |
-
|
| 2851 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2852 |
}
|
| 2853 |
-
html += '</
|
| 2854 |
-
html += '<div id="tl-content"></div>';
|
| 2855 |
el.innerHTML = html;
|
| 2856 |
|
| 2857 |
-
|
| 2858 |
-
|
| 2859 |
-
|
| 2860 |
-
|
| 2861 |
-
|
| 2862 |
-
|
| 2863 |
-
|
| 2864 |
-
|
| 2865 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2866 |
}
|
| 2867 |
|
| 2868 |
-
|
| 2869 |
-
var
|
| 2870 |
-
|
| 2871 |
-
|
| 2872 |
-
|
| 2873 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2874 |
}
|
| 2875 |
-
|
| 2876 |
-
|
| 2877 |
-
|
| 2878 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 === '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: '☑', other: '📄'
|
| 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">📁</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;">← 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;">← 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">−</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">🕑</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)
|