engrm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.mcp.json +9 -0
  2. package/AUTH-DESIGN.md +436 -0
  3. package/BRIEF.md +197 -0
  4. package/CLAUDE.md +44 -0
  5. package/COMPETITIVE.md +174 -0
  6. package/CONTEXT-OPTIMIZATION.md +305 -0
  7. package/INFRASTRUCTURE.md +252 -0
  8. package/LICENSE +105 -0
  9. package/MARKET.md +230 -0
  10. package/PLAN.md +278 -0
  11. package/README.md +121 -0
  12. package/SENTINEL.md +293 -0
  13. package/SERVER-API-PLAN.md +553 -0
  14. package/SPEC.md +843 -0
  15. package/SWOT.md +148 -0
  16. package/SYNC-ARCHITECTURE.md +294 -0
  17. package/VIBE-CODER-STRATEGY.md +250 -0
  18. package/bun.lock +375 -0
  19. package/hooks/post-tool-use.ts +144 -0
  20. package/hooks/session-start.ts +64 -0
  21. package/hooks/stop.ts +131 -0
  22. package/mem-page.html +1305 -0
  23. package/package.json +30 -0
  24. package/src/capture/dedup.test.ts +103 -0
  25. package/src/capture/dedup.ts +76 -0
  26. package/src/capture/extractor.test.ts +245 -0
  27. package/src/capture/extractor.ts +330 -0
  28. package/src/capture/quality.test.ts +168 -0
  29. package/src/capture/quality.ts +104 -0
  30. package/src/capture/retrospective.test.ts +115 -0
  31. package/src/capture/retrospective.ts +121 -0
  32. package/src/capture/scanner.test.ts +131 -0
  33. package/src/capture/scanner.ts +100 -0
  34. package/src/capture/scrubber.test.ts +144 -0
  35. package/src/capture/scrubber.ts +181 -0
  36. package/src/cli.ts +517 -0
  37. package/src/config.ts +238 -0
  38. package/src/context/inject.test.ts +940 -0
  39. package/src/context/inject.ts +382 -0
  40. package/src/embeddings/backfill.ts +50 -0
  41. package/src/embeddings/embedder.test.ts +76 -0
  42. package/src/embeddings/embedder.ts +139 -0
  43. package/src/lifecycle/aging.test.ts +103 -0
  44. package/src/lifecycle/aging.ts +36 -0
  45. package/src/lifecycle/compaction.test.ts +264 -0
  46. package/src/lifecycle/compaction.ts +190 -0
  47. package/src/lifecycle/purge.test.ts +100 -0
  48. package/src/lifecycle/purge.ts +37 -0
  49. package/src/lifecycle/scheduler.test.ts +120 -0
  50. package/src/lifecycle/scheduler.ts +101 -0
  51. package/src/provisioning/browser-auth.ts +172 -0
  52. package/src/provisioning/provision.test.ts +198 -0
  53. package/src/provisioning/provision.ts +94 -0
  54. package/src/register.test.ts +167 -0
  55. package/src/register.ts +178 -0
  56. package/src/server.ts +436 -0
  57. package/src/storage/migrations.test.ts +244 -0
  58. package/src/storage/migrations.ts +261 -0
  59. package/src/storage/outbox.test.ts +229 -0
  60. package/src/storage/outbox.ts +131 -0
  61. package/src/storage/projects.test.ts +137 -0
  62. package/src/storage/projects.ts +184 -0
  63. package/src/storage/sqlite.test.ts +798 -0
  64. package/src/storage/sqlite.ts +934 -0
  65. package/src/storage/vec.test.ts +198 -0
  66. package/src/sync/auth.test.ts +76 -0
  67. package/src/sync/auth.ts +68 -0
  68. package/src/sync/client.ts +183 -0
  69. package/src/sync/engine.test.ts +94 -0
  70. package/src/sync/engine.ts +127 -0
  71. package/src/sync/pull.test.ts +279 -0
  72. package/src/sync/pull.ts +170 -0
  73. package/src/sync/push.test.ts +117 -0
  74. package/src/sync/push.ts +230 -0
  75. package/src/tools/get.ts +34 -0
  76. package/src/tools/pin.ts +47 -0
  77. package/src/tools/save.test.ts +301 -0
  78. package/src/tools/save.ts +231 -0
  79. package/src/tools/search.test.ts +69 -0
  80. package/src/tools/search.ts +181 -0
  81. package/src/tools/timeline.ts +64 -0
  82. package/tsconfig.json +22 -0
package/SPEC.md ADDED
@@ -0,0 +1,843 @@
1
+ # Technical Specification — Engrm
2
+
3
+ ## 1. Project Identity
4
+
5
+ ### The Problem
6
+
7
+ The same project lives at different paths on different machines:
8
+ - `/Users/david/code/aimy-agent` (MacBook)
9
+ - `/home/david/projects/aimy-agent` (desktop)
10
+ - `/Volumes/Data/devs/aimy-agent` (external drive)
11
+
12
+ A string like `"aimy-agent"` is ambiguous — two different repos could share the same directory name. We need a **canonical project ID** that's the same everywhere.
13
+
14
+ ### Solution: Git Remote as Canonical ID
15
+
16
+ For git repos (the vast majority of real projects), the remote URL is a globally unique, stable identifier. Normalise it to strip protocol and auth variations:
17
+
18
+ ```
19
+ git@github.com:unimpossible/aimy-agent.git → github.com/unimpossible/aimy-agent
20
+ https://github.com/unimpossible/aimy-agent.git → github.com/unimpossible/aimy-agent
21
+ https://david@github.com/unimpossible/aimy-agent → github.com/unimpossible/aimy-agent
22
+ ```
23
+
24
+ **Normalisation rules**:
25
+ 1. Strip protocol (`https://`, `git@`, `ssh://`)
26
+ 2. Replace `:` with `/` (for SSH-style URLs)
27
+ 3. Strip `.git` suffix
28
+ 4. Strip auth credentials (`user@`)
29
+ 5. Lowercase the host
30
+
31
+ **Fallbacks** (in order):
32
+ 1. Git remote `origin` URL → normalised (preferred)
33
+ 2. Git remote — any remote if `origin` doesn't exist
34
+ 3. Manual `project_id` in `.engrm.json` in project root (for non-git projects)
35
+ 4. Last resort: directory name (not great, but better than failing)
36
+
37
+ ### Local Projects Table
38
+
39
+ ```sql
40
+ CREATE TABLE projects (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ canonical_id TEXT UNIQUE NOT NULL, -- normalised git remote URL
43
+ name TEXT NOT NULL, -- human-readable (repo name portion)
44
+ local_path TEXT, -- path on THIS machine
45
+ remote_url TEXT, -- original git remote URL
46
+ first_seen_epoch INTEGER NOT NULL,
47
+ last_active_epoch INTEGER NOT NULL -- updated on every observation
48
+ );
49
+ ```
50
+
51
+ **Auto-detection**: On session start, the plugin runs `git remote get-url origin` in the working directory. If the canonical ID exists in `projects`, update `local_path` and `last_active_epoch`. If not, insert a new row. Zero config for the developer.
52
+
53
+ ### Project Config File (Optional)
54
+
55
+ For non-git projects or overrides, drop a `.engrm.json` in the project root:
56
+
57
+ ```json
58
+ {
59
+ "project_id": "internal/design-system",
60
+ "name": "Design System"
61
+ }
62
+ ```
63
+
64
+ This is optional. Git repos need nothing — it's fully automatic.
65
+
66
+ ---
67
+
68
+ ## 2. Observation Schema
69
+
70
+ ### Observation Lifecycle
71
+
72
+ Observations are not permanent. They have a lifecycle that manages growth and keeps search results relevant.
73
+
74
+ ```
75
+ ┌───────────┐ 30 days ┌───────────┐ 90 days ┌───────────┐
76
+ │ active │ ──────────────→ │ aging │ ──────────────→ │ archived │
77
+ │ │ │ │ │ │
78
+ │ Full text │ │ Full text │ │ Summarised │
79
+ │ in FTS5 │ │ in FTS5 │ │ out of FTS │
80
+ │ in Vector │ │ in Vector │ │ out of Vec │
81
+ │ Full score │ │ 0.7x score │ │ Local only │
82
+ └───────────┘ └───────────┘ └───────────┘
83
+
84
+ 12 months
85
+
86
+
87
+ ┌───────────┐
88
+ │ purged │
89
+ │ (deleted) │
90
+ └───────────┘
91
+ ```
92
+
93
+ | State | Age | Search weight | In FTS5 | In Candengo Vector | Counts toward quota |
94
+ |---|---|---|---|---|---|
95
+ | `active` | 0-30 days | 1.0x | Yes | Yes | Yes |
96
+ | `aging` | 30-90 days | 0.7x | Yes | Yes | Yes |
97
+ | `archived` | 90-365 days | 0.3x (local only) | No | Removed | No |
98
+ | `purged` | >365 days | — | — | — | No |
99
+
100
+ **Key insight**: Archived observations are removed from Candengo Vector (freeing quota and reducing noise in cross-device search) but kept in local SQLite. They're still searchable locally at reduced weight. This means the free tier's 10K limit applies to active+aging observations in the vector store, not total local history.
101
+
102
+ **Exceptions**: Observations can be **pinned** (`lifecycle = 'pinned'`) to prevent aging. Useful for architectural decisions, critical gotchas, and other knowledge that stays relevant indefinitely.
103
+
104
+ ### Observation Quality Scoring
105
+
106
+ Not all observations are equal. A quality score (0.0-1.0) is assigned at capture time and influences search ranking and lifecycle.
107
+
108
+ | Signal | Score contribution | Rationale |
109
+ |---|---|---|
110
+ | Bug fix with root cause | +0.3 | High-value, prevents repeat debugging |
111
+ | Architectural decision | +0.3 | Long-lived, affects future work |
112
+ | Multiple files modified | +0.2 | Indicates non-trivial change |
113
+ | Error → fix sequence | +0.2 | Problem-solution pair is reusable knowledge |
114
+ | Test failure → fix | +0.2 | Specific, actionable |
115
+ | Pattern/gotcha identified | +0.2 | Transferable to other contexts |
116
+ | Single file read | +0.0 | Low signal, likely navigation |
117
+ | Simple config change | +0.05 | Minor, rarely worth retrieving |
118
+ | Duplicate of recent observation | -0.3 | Redundant |
119
+
120
+ Observations with quality < 0.1 are **not saved**. This is the primary noise filter.
121
+
122
+ **Compaction**: When observations are archived (90 days), related observations from the same project and session are **compacted** — summarised into a single "digest" observation. 20 observations from a debugging session become one concise summary of what was wrong, what was tried, and what fixed it. This preserves the knowledge while dramatically reducing storage.
123
+
124
+ ### Local SQLite Schema
125
+
126
+ ```sql
127
+ -- Projects (canonical identity across machines)
128
+ CREATE TABLE projects (
129
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
130
+ canonical_id TEXT UNIQUE NOT NULL, -- normalised git remote URL
131
+ name TEXT NOT NULL, -- human-readable
132
+ local_path TEXT, -- path on THIS machine
133
+ remote_url TEXT, -- original git remote URL
134
+ first_seen_epoch INTEGER NOT NULL,
135
+ last_active_epoch INTEGER NOT NULL
136
+ );
137
+
138
+ -- Core observations table
139
+ CREATE TABLE observations (
140
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
141
+ session_id TEXT,
142
+ project_id INTEGER NOT NULL REFERENCES projects(id),
143
+ type TEXT NOT NULL, -- bugfix | discovery | decision | pattern | change | feature | refactor
144
+ title TEXT NOT NULL,
145
+ narrative TEXT, -- Detailed description
146
+ facts TEXT, -- JSON array of factual assertions
147
+ concepts TEXT, -- JSON array: how-it-works, why-it-exists, etc.
148
+ files_read TEXT, -- JSON array of RELATIVE file paths
149
+ files_modified TEXT, -- JSON array of RELATIVE file paths
150
+ quality REAL DEFAULT 0.5, -- 0.0-1.0, influences search rank and lifecycle
151
+ lifecycle TEXT DEFAULT 'active', -- active | aging | archived | purged | pinned
152
+ sensitivity TEXT DEFAULT 'shared', -- shared | personal | secret
153
+ user_id TEXT NOT NULL,
154
+ device_id TEXT NOT NULL,
155
+ agent TEXT DEFAULT 'claude-code',
156
+ created_at TEXT NOT NULL,
157
+ created_at_epoch INTEGER NOT NULL,
158
+ archived_at_epoch INTEGER, -- when moved to archived state
159
+ compacted_into INTEGER REFERENCES observations(id) -- if summarised into a digest
160
+ );
161
+
162
+ -- Session tracking
163
+ CREATE TABLE sessions (
164
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
165
+ session_id TEXT UNIQUE NOT NULL,
166
+ project_id INTEGER REFERENCES projects(id),
167
+ user_id TEXT NOT NULL,
168
+ device_id TEXT NOT NULL,
169
+ agent TEXT DEFAULT 'claude-code',
170
+ status TEXT DEFAULT 'active', -- active | completed
171
+ observation_count INTEGER DEFAULT 0, -- running count for this session
172
+ started_at_epoch INTEGER,
173
+ completed_at_epoch INTEGER
174
+ );
175
+
176
+ -- Session summaries (generated on Stop hook)
177
+ CREATE TABLE session_summaries (
178
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
179
+ session_id TEXT UNIQUE NOT NULL,
180
+ project_id INTEGER REFERENCES projects(id),
181
+ user_id TEXT NOT NULL,
182
+ request TEXT, -- What was asked
183
+ investigated TEXT, -- What was explored
184
+ learned TEXT, -- Key learnings
185
+ completed TEXT, -- What was delivered
186
+ next_steps TEXT, -- Follow-up items
187
+ created_at_epoch INTEGER
188
+ );
189
+
190
+ -- Sync outbox (offline-first queue)
191
+ CREATE TABLE sync_outbox (
192
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
193
+ record_type TEXT NOT NULL, -- observation | summary
194
+ record_id INTEGER NOT NULL,
195
+ status TEXT DEFAULT 'pending', -- pending | syncing | synced | failed
196
+ retry_count INTEGER DEFAULT 0,
197
+ max_retries INTEGER DEFAULT 10,
198
+ last_error TEXT,
199
+ created_at_epoch INTEGER NOT NULL,
200
+ synced_at_epoch INTEGER,
201
+ next_retry_epoch INTEGER
202
+ );
203
+
204
+ -- Sync high-water mark
205
+ CREATE TABLE sync_state (
206
+ key TEXT PRIMARY KEY,
207
+ value TEXT NOT NULL
208
+ );
209
+ -- Keys: "last_synced_epoch", "last_backfill_epoch"
210
+
211
+ -- FTS5 for local offline search (only active + aging observations)
212
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
213
+ title, narrative, facts, concepts,
214
+ content=observations,
215
+ content_rowid=id
216
+ );
217
+
218
+ -- Indexes
219
+ CREATE INDEX idx_observations_project ON observations(project_id);
220
+ CREATE INDEX idx_observations_type ON observations(type);
221
+ CREATE INDEX idx_observations_created ON observations(created_at_epoch);
222
+ CREATE INDEX idx_observations_session ON observations(session_id);
223
+ CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
224
+ CREATE INDEX idx_observations_quality ON observations(quality);
225
+ CREATE INDEX idx_projects_canonical ON projects(canonical_id);
226
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
227
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
228
+ ```
229
+
230
+ ### File Paths: Always Relative
231
+
232
+ File paths in observations are stored **relative to the project root**, never absolute. This ensures paths match across machines where the project lives at different locations.
233
+
234
+ ```
235
+ Absolute (on this machine): /Users/david/code/aimy-agent/models/interview.py
236
+ Project root: /Users/david/code/aimy-agent/
237
+ Stored in observation: models/interview.py
238
+ ```
239
+
240
+ The plugin resolves relative paths at capture time using the project's `local_path`.
241
+
242
+ ### Candengo Vector Document Mapping
243
+
244
+ Each observation maps to a single Candengo Vector document:
245
+
246
+ ```json
247
+ {
248
+ "site_id": "unimpossible",
249
+ "namespace": "dev-memory",
250
+ "source_type": "discovery",
251
+ "source_id": "david-desktop1-obs-1234",
252
+ "content": "## Database type mismatch causing 500 errors\n\nThe interview endpoint was returning 500 errors because project_id was defined as UUID in the model but the database column was TEXT. Fixed by changing the SQLAlchemy column type to String.\n\nFacts:\n- project_id column type mismatch between model (UUID) and database (TEXT)\n- Fix: change Column(UUID) to Column(String) in Interview model\n- Affects: models/interview.py",
253
+ "metadata": {
254
+ "project_canonical": "github.com/unimpossible/aimy-agent",
255
+ "project_name": "aimy-agent",
256
+ "user_id": "david",
257
+ "device_id": "desktop-1",
258
+ "agent": "claude-code",
259
+ "title": "Fixed project_id column type mismatch in Interview model",
260
+ "type": "bugfix",
261
+ "quality": 0.7,
262
+ "concepts": ["problem-solution", "gotcha"],
263
+ "files_modified": ["models/interview.py"],
264
+ "session_id": "abc-123",
265
+ "created_at_epoch": 1740700000,
266
+ "local_id": 1234
267
+ }
268
+ }
269
+ ```
270
+
271
+ **Content composition**: Concatenate `title + narrative + facts` into a single `content` field. Candengo Vector auto-chunks at 400 tokens with 50-token overlap. Most observations fit in a single chunk.
272
+
273
+ **Source ID format**: `"{user_id}-{device_id}-obs-{local_sqlite_id}"` — unique across users AND devices. The old format `"{user_id}-obs-{id}"` could collide because two devices both start local IDs at 1.
274
+
275
+ **Project matching**: Candengo Vector metadata includes `project_canonical` (normalised git remote URL). When searching, the plugin sends its current project's canonical ID as a metadata filter. This ensures "aimy-agent on laptop" and "aimy-agent on desktop" match to the same project — because they share the same git remote.
276
+
277
+ ### Deduplication
278
+
279
+ Before saving a new observation, check for near-duplicates:
280
+
281
+ ```
282
+ 1. Query local SQLite: recent observations (last 24h) for same project
283
+ 2. Simple heuristic: title similarity > 0.8 (Jaccard on word tokens)
284
+ 3. If duplicate found: merge new facts into existing observation, don't create new row
285
+ 4. If no duplicate: save as new observation
286
+ ```
287
+
288
+ Full semantic deduplication (via Candengo Vector similarity) runs during the compaction job, not on the hot path. Keeps capture fast.
289
+
290
+ ---
291
+
292
+ ## 3. MCP Tool Interface
293
+
294
+ ### Tool: `search`
295
+ Find relevant observations from memory.
296
+
297
+ ```typescript
298
+ {
299
+ name: "search",
300
+ description: "Search memory for relevant observations, discoveries, and decisions",
301
+ inputSchema: {
302
+ query: string, // Natural language search query
303
+ project?: string, // Filter to specific project (auto-detected from cwd if omitted)
304
+ type?: string, // Filter by observation type
305
+ limit?: number, // Max results (default: 10)
306
+ scope?: "personal" | "team" | "all" // Search scope (default: "all")
307
+ include_archived?: boolean // Include archived observations (default: false)
308
+ }
309
+ }
310
+ ```
311
+
312
+ Returns compact index with IDs (~50-100 tokens per result):
313
+ ```
314
+ | ID | Time | Type | Q | Title | Project | By |
315
+ ```
316
+
317
+ The `Q` column shows quality score as a visual indicator (●●●○○ = 0.6). Results are ranked by `(relevance × quality × lifecycle_weight)`.
318
+
319
+ **Default behaviour**: Searches current project only. Pass `project: "*"` to search across all projects.
320
+
321
+ ### Tool: `timeline`
322
+ Get chronological context around a specific observation.
323
+
324
+ ```typescript
325
+ {
326
+ name: "timeline",
327
+ inputSchema: {
328
+ anchor: number, // Observation ID to centre on
329
+ depth_before?: number, // Observations before (default: 3)
330
+ depth_after?: number, // Observations after (default: 3)
331
+ project?: string // Auto-detected if omitted
332
+ }
333
+ }
334
+ ```
335
+
336
+ ### Tool: `get_observations`
337
+ Fetch full details for specific observation IDs.
338
+
339
+ ```typescript
340
+ {
341
+ name: "get_observations",
342
+ inputSchema: {
343
+ ids: number[]
344
+ }
345
+ }
346
+ ```
347
+
348
+ Returns full observation details (~500-1000 tokens per result).
349
+
350
+ ### Tool: `save_observation`
351
+ Manually save an observation (most are captured automatically via hooks).
352
+
353
+ ```typescript
354
+ {
355
+ name: "save_observation",
356
+ inputSchema: {
357
+ text: string, // Observation content (required)
358
+ title?: string, // Brief title (auto-generated if omitted)
359
+ type?: string, // Observation type (auto-classified if omitted)
360
+ project?: string, // Project (auto-detected from cwd if omitted)
361
+ pin?: boolean // Pin to prevent aging (default: false)
362
+ }
363
+ }
364
+ ```
365
+
366
+ ### Tool: `pin_observation`
367
+ Prevent an observation from aging out. Use for architectural decisions, critical gotchas, and other knowledge that stays relevant indefinitely.
368
+
369
+ ```typescript
370
+ {
371
+ name: "pin_observation",
372
+ inputSchema: {
373
+ id: number,
374
+ pinned: boolean // true to pin, false to unpin
375
+ }
376
+ }
377
+ ```
378
+
379
+ ---
380
+
381
+ ## 4. Sync Engine
382
+
383
+ ### Sync States
384
+
385
+ ```
386
+ ┌──────────┐
387
+ │ pending │ ← observation saved to SQLite
388
+ └─────┬─────┘
389
+ │ sync attempt
390
+ ┌─────▼─────┐
391
+ │ syncing │ ← HTTP request in flight
392
+ └─────┬─────┘
393
+ ╱ ╲
394
+ success failure
395
+ ╱ ╲
396
+ ┌───────▼┐ ┌────▼────┐
397
+ │ synced │ │ failed │ ← retry_count++
398
+ └────────┘ └────┬────┘
399
+ │ next_retry_epoch reached
400
+ │ (if retry_count < max_retries)
401
+ ┌─────▼─────┐
402
+ │ pending │ ← back in queue
403
+ └───────────┘
404
+ ```
405
+
406
+ ### Retry Schedule (Exponential Backoff)
407
+
408
+ | Retry # | Delay |
409
+ |---|---|
410
+ | 1 | 30 seconds |
411
+ | 2 | 1 minute |
412
+ | 3 | 2 minutes |
413
+ | 4 | 5 minutes |
414
+ | 5+ | 5 minutes (cap) |
415
+
416
+ ### Sync Triggers
417
+
418
+ 1. **Immediate**: On observation save → fire-and-forget push
419
+ 2. **Timer**: Every 30 seconds → flush pending outbox items (batch of 50)
420
+ 3. **Startup**: On boot → check high-water mark, sync anything newer
421
+ 4. **Manual**: `engrm sync` CLI command → force full sync
422
+
423
+ ### Backfill Algorithm (High-Water Mark)
424
+
425
+ ```
426
+ 1. Read last_synced_epoch from sync_state table
427
+ 2. SELECT * FROM observations
428
+ WHERE created_at_epoch > last_synced_epoch
429
+ AND sensitivity != 'secret'
430
+ 3. Batch push to Candengo Vector via POST /v1/ingest/batch
431
+ 4. Update last_synced_epoch to max(created_at_epoch) of pushed items
432
+ ```
433
+
434
+ No need to query remote for existing IDs. Simple, efficient, scales to any count.
435
+
436
+ ### Conflict Resolution
437
+
438
+ **Strategy: Source ID namespacing, no conflicts possible**
439
+
440
+ - Each observation has a unique `source_id`: `"{user_id}-{device_id}-obs-{local_id}"`
441
+ - Candengo Vector upserts by `source_id` — re-syncing is idempotent
442
+ - No two users can overwrite each other's observations
443
+ - No two devices can collide (both start local autoincrement at 1, but device_id distinguishes them)
444
+
445
+ Search results show attribution (`user_id/device_id`) so users know who captured what and where.
446
+
447
+ ---
448
+
449
+ ## 5. Lifecycle Management
450
+
451
+ ### Aging Job
452
+
453
+ Runs once per day (on plugin startup if >24h since last run):
454
+
455
+ ```
456
+ 1. UPDATE observations SET lifecycle = 'aging'
457
+ WHERE lifecycle = 'active'
458
+ AND created_at_epoch < now() - 30 days
459
+
460
+ 2. For observations moving to 'aging':
461
+ - No immediate action (still in FTS5 and Candengo Vector)
462
+ - Search scoring applies 0.7x weight
463
+ ```
464
+
465
+ ### Archival + Compaction Job
466
+
467
+ Runs once per week:
468
+
469
+ ```
470
+ 1. Find observations WHERE lifecycle = 'aging'
471
+ AND created_at_epoch < now() - 90 days
472
+
473
+ 2. Group by (project_id, session_id)
474
+
475
+ 3. For each group:
476
+ a. Generate a "digest" observation:
477
+ - Summarise: what was the session about, key findings, outcomes
478
+ - Type: "digest"
479
+ - Quality: max(quality) of source observations
480
+ - Lifecycle: "pinned" (digests don't age out)
481
+ b. Mark source observations:
482
+ - lifecycle = 'archived'
483
+ - compacted_into = digest.id
484
+ - archived_at_epoch = now()
485
+
486
+ 4. Remove archived observations from FTS5 index
487
+
488
+ 5. Queue removal from Candengo Vector:
489
+ - DELETE source_ids from Candengo Vector
490
+ - Ingest the new digest observation
491
+ ```
492
+
493
+ This means a 3-month-old debugging session with 25 observations becomes a single digest observation. The detail is preserved in local SQLite (for forensic use) but doesn't pollute search results or count toward vector storage quota.
494
+
495
+ ### Purge Job
496
+
497
+ Runs monthly. Permanently deletes archived observations older than 12 months:
498
+
499
+ ```
500
+ DELETE FROM observations
501
+ WHERE lifecycle = 'archived'
502
+ AND archived_at_epoch < now() - 12 months
503
+ ```
504
+
505
+ Digests (lifecycle = 'pinned') are never purged. Pinned individual observations are never purged.
506
+
507
+ ### Quota Calculation
508
+
509
+ For free tier enforcement (10K observation limit):
510
+
511
+ ```sql
512
+ SELECT COUNT(*) FROM observations
513
+ WHERE lifecycle IN ('active', 'aging')
514
+ AND sensitivity != 'secret'
515
+ ```
516
+
517
+ Only active + aging observations that are synced to Candengo Vector count toward the quota. Archived and local-only observations are free. This means compaction directly frees quota — a user generating 500 observations/month who stays under 10K active+aging can use the free tier indefinitely.
518
+
519
+ ---
520
+
521
+ ## 6. Secret Scrubbing Pipeline
522
+
523
+ ### Pre-Storage Scrubbing
524
+
525
+ Before any observation is saved (even to local SQLite), content is scrubbed:
526
+
527
+ ```
528
+ Input text → Pattern matching → Replacement → Scrubbed text
529
+ ```
530
+
531
+ ### Default Patterns
532
+
533
+ | Pattern | Replacement | Catches |
534
+ |---|---|---|
535
+ | `sk-[a-zA-Z0-9]{20,}` | `[REDACTED_API_KEY]` | OpenAI keys |
536
+ | `Bearer [a-zA-Z0-9\-._~+/]+=*` | `[REDACTED_BEARER]` | Auth headers |
537
+ | `password[=:]\s*\S+` | `password=[REDACTED]` | Passwords in config |
538
+ | `postgresql://[^\s]+` | `[REDACTED_DB_URL]` | Postgres connection strings |
539
+ | `mongodb://[^\s]+` | `[REDACTED_DB_URL]` | Mongo connection strings |
540
+ | `mysql://[^\s]+` | `[REDACTED_DB_URL]` | MySQL connection strings |
541
+ | `AKIA[A-Z0-9]{16}` | `[REDACTED_AWS_KEY]` | AWS access keys |
542
+ | `ghp_[a-zA-Z0-9]{36}` | `[REDACTED_GH_TOKEN]` | GitHub tokens |
543
+ | `cvk_[a-f0-9]{64}` | `[REDACTED_CANDENGO_KEY]` | Candengo API keys |
544
+ | Custom patterns from config | User-defined | Organisation-specific |
545
+
546
+ ### Sensitivity Levels
547
+
548
+ | Level | Behaviour |
549
+ |---|---|
550
+ | `shared` (default) | Scrubbed, stored locally, synced to Candengo Vector |
551
+ | `personal` | Scrubbed, stored locally, synced but only visible to same user |
552
+ | `secret` | Scrubbed, stored locally only, **never synced** |
553
+
554
+ ---
555
+
556
+ ## 7. Configuration & Authentication
557
+
558
+ > **Full auth design**: See `AUTH-DESIGN.md` for the complete authentication specification including all flows, server-side requirements, and implementation timeline.
559
+
560
+ ### Settings File: `~/.engrm/settings.json`
561
+
562
+ All configuration lives in a single directory: `~/.engrm/`.
563
+
564
+ ```json
565
+ {
566
+ "candengo_url": "https://www.candengo.com",
567
+ "candengo_api_key": "cvk_...",
568
+ "site_id": "unimpossible",
569
+ "namespace": "dev-memory",
570
+ "user_id": "david",
571
+ "user_email": "david@example.com",
572
+ "device_id": "macbook-a1b2c3d4",
573
+ "teams": [
574
+ { "id": "team_abc123", "name": "Unimpossible", "namespace": "dev-memory" }
575
+ ],
576
+ "sync": {
577
+ "enabled": true,
578
+ "interval_seconds": 30,
579
+ "batch_size": 50
580
+ },
581
+ "search": {
582
+ "default_limit": 10,
583
+ "local_boost": 1.2,
584
+ "scope": "all"
585
+ },
586
+ "scrubbing": {
587
+ "enabled": true,
588
+ "custom_patterns": [],
589
+ "default_sensitivity": "shared"
590
+ }
591
+ }
592
+ ```
593
+
594
+ **Changes from prior version**: Added `user_email` and `teams[]` array (supports multi-team membership).
595
+
596
+ ### Credential Types
597
+
598
+ | Prefix | Type | Lifetime | Purpose |
599
+ |--------|------|----------|---------|
600
+ | `cvk_` | API key | Permanent (revocable) | The ONE credential for all sync API calls |
601
+ | `cmt_` | Provisioning token | 1 hour, single-use | Web signup → exchange for `cvk_` key |
602
+
603
+ The `cvk_` API key is the single credential type for sync operations. Multiple authentication flows exist to **obtain** this key conveniently, but the credential itself is always the same.
604
+
605
+ **CI/CD**: The `ENGRM_TOKEN` environment variable takes precedence over settings.json, allowing pipelines to authenticate without config files.
606
+
607
+ ### Authentication Flows
608
+
609
+ Four flows to obtain a `cvk_` API key, covering all environments:
610
+
611
+ | Flow | Command | Use Case |
612
+ |------|---------|----------|
613
+ | **Browser OAuth** | `engrm init` | Desktop developers (default) |
614
+ | **Device code** | `engrm init --no-browser` | SSH, headless, WSL (RFC 8628) |
615
+ | **Provisioning token** | `engrm init --token=cmt_...` | Web signup copy-paste |
616
+ | **Manual** | `engrm init --manual` | Air-gapped, self-hosted |
617
+
618
+ All flows write the same `cvk_` API key to `~/.engrm/settings.json`.
619
+
620
+ #### Browser OAuth Flow (Default)
621
+
622
+ ```
623
+ 1. User runs: engrm init
624
+ 2. CLI opens browser → candengo.com/connect/mem
625
+ 3. User logs in → clicks "Authorize"
626
+ 4. Redirect to localhost callback with auth code
627
+ 5. CLI exchanges code → receives cvk_ API key
628
+ 6. Writes settings.json + registers MCP server
629
+ ```
630
+
631
+ #### Device Code Flow (Headless)
632
+
633
+ Auto-detected when no browser available, or via `--no-browser`:
634
+
635
+ ```
636
+ 1. CLI requests device code from server
637
+ 2. Prints: "Open https://candengo.com/connect/mem/device — Enter code: XXXX-YYYY"
638
+ 3. User authorises on any device with a browser
639
+ 4. CLI polls until authorised → receives cvk_ API key
640
+ 5. Writes settings.json
641
+ ```
642
+
643
+ #### Provisioning Token Flow (Web Signup)
644
+
645
+ ```
646
+ 1. User signs up at engrm.dev
647
+ 2. Page shows: npx engrm init --token=cmt_abc123...
648
+ 3. CLI exchanges cmt_ token → receives cvk_ API key
649
+ 4. Writes settings.json + registers MCP server
650
+ ```
651
+
652
+ ### Team Provisioning
653
+
654
+ Teams are **explicitly** created and joined — not auto-provisioned.
655
+
656
+ - **Personal namespace**: auto-provisioned on first auth (any flow)
657
+ - **Team creation**: admin at `engrm.dev/team`
658
+ - **Team join**: invite link `engrm.dev/join/{code}` or `engrm team join --code=INVITE_CODE`
659
+ - **Multi-team**: `teams[]` array supports belonging to multiple teams simultaneously
660
+
661
+ ### Token Revocation
662
+
663
+ - `engrm auth revoke` — revoke current key
664
+ - `engrm auth rotate` — atomic: create new key, update settings, revoke old
665
+ - Web dashboard: `engrm.dev/dashboard` — manage all keys
666
+ - Server stores key hashes only (SHA-256), with `key_prefix` for identification
667
+
668
+ ### What `engrm init` Does
669
+
670
+ ```
671
+ 1. Authenticate via one of the four flows above
672
+ 2. Exchange credentials → get cvk_ API key + account info
673
+ 3. Create ~/.engrm/ directory
674
+ 4. Write settings.json with credentials
675
+ 5. Generate device_id (hostname + random suffix)
676
+ 6. Create local SQLite database
677
+ 7. Register MCP server in Claude Code:
678
+ - Write to ~/.claude/mcp.json (or project .mcp.json)
679
+ 8. Register hooks in Claude Code:
680
+ - Write to ~/.claude/hooks.json (or merge with existing)
681
+ 9. Test connection to Candengo Vector (if online)
682
+ 10. Print success message with next steps
683
+ ```
684
+
685
+ ### Self-Hosted Provisioning
686
+
687
+ For self-hosted deployments:
688
+
689
+ ```bash
690
+ npx engrm init --url=https://vector.internal.company.com --token=cmt_...
691
+ ```
692
+
693
+ Or manual config for air-gapped environments:
694
+
695
+ ```bash
696
+ npx engrm init --manual
697
+ # Prompts for: endpoint, api_key, site_id, namespace, user_id
698
+ ```
699
+
700
+ ### Device ID
701
+
702
+ Auto-generated on first run using `hostname + random suffix`. Stored in settings. Used to tag all observations so you know which machine they came from.
703
+
704
+ ---
705
+
706
+ ## 8. Claude Code Integration
707
+
708
+ ### MCP Server Registration
709
+
710
+ `.mcp.json` in the project or user config:
711
+ ```json
712
+ {
713
+ "mcpServers": {
714
+ "engrm": {
715
+ "type": "stdio",
716
+ "command": "bun",
717
+ "args": ["run", "/path/to/engrm/src/server.ts"]
718
+ }
719
+ }
720
+ }
721
+ ```
722
+
723
+ ### Claude Code Hooks
724
+
725
+ Hooks are configured in `~/.claude/settings.json` (or `.claude/settings.json` for project scope) under the `"hooks"` key:
726
+
727
+ ```json
728
+ {
729
+ "hooks": {
730
+ "PostToolUse": [
731
+ {
732
+ "matcher": "Edit|Write|Bash",
733
+ "hooks": [
734
+ {
735
+ "type": "command",
736
+ "command": "bun run /path/to/engrm/hooks/post-tool-use.ts"
737
+ }
738
+ ]
739
+ }
740
+ ],
741
+ "Stop": [
742
+ {
743
+ "hooks": [
744
+ {
745
+ "type": "command",
746
+ "command": "bun run /path/to/engrm/hooks/stop.ts"
747
+ }
748
+ ]
749
+ }
750
+ ]
751
+ }
752
+ }
753
+ ```
754
+
755
+ ### Hook Data Flow
756
+
757
+ **PostToolUse** receives JSON on stdin:
758
+ ```json
759
+ {
760
+ "session_id": "abc123",
761
+ "hook_event_name": "PostToolUse",
762
+ "tool_name": "Edit",
763
+ "tool_input": { "file_path": "...", "old_string": "...", "new_string": "..." },
764
+ "tool_response": "Successfully edited /path/to/file.txt",
765
+ "cwd": "/path/to/project"
766
+ }
767
+ ```
768
+
769
+ The hook runs the observation extractor which decides whether this tool use is worth capturing. Signal (edits, error→fix, dependency changes) = yes. Noise (reads, navigation, git status) = skip.
770
+
771
+ **Stop** receives JSON on stdin:
772
+ ```json
773
+ {
774
+ "session_id": "abc123",
775
+ "hook_event_name": "Stop",
776
+ "stop_hook_active": false,
777
+ "last_assistant_message": "...",
778
+ "cwd": "/path/to/project"
779
+ }
780
+ ```
781
+
782
+ Stop hook actions:
783
+ 1. Check `stop_hook_active` to prevent infinite loops
784
+ 2. Mark session as completed in SQLite
785
+ 3. Session summary generation (deferred to Phase 3)
786
+ 4. Exit 0 to allow Claude to stop
787
+
788
+ ---
789
+
790
+ ## 9. Search Pipeline
791
+
792
+ ### Hybrid Search Architecture
793
+
794
+ ```
795
+ Query: "how to handle SSE streaming errors"
796
+ + project_canonical: "github.com/unimpossible/aimy-agent" (auto-detected)
797
+
798
+ ├──→ Local SQLite FTS5 (always, instant)
799
+ │ → BM25 keyword matching
800
+ │ → Filter: project_id + lifecycle IN ('active', 'aging', 'pinned')
801
+ │ → Returns top 20 candidates with scores
802
+
803
+ └──→ Candengo Vector /v1/search (if online)
804
+ → metadata_filter: project_canonical = "github.com/unimpossible/aimy-agent"
805
+ → BGE-M3 hybrid dense+sparse
806
+ → Cross-encoder reranking
807
+ → Returns top 20 candidates with scores
808
+
809
+
810
+ Result Merger
811
+ → Deduplicate by source_id
812
+ → Normalise scores to 0-1
813
+ → Weighted combination:
814
+ - Candengo score × 0.6 (semantic quality)
815
+ - Local FTS score × 0.15 (keyword precision)
816
+ - Quality score × 0.15 (observation quality)
817
+ - Recency bonus × 0.1 (newer slightly preferred)
818
+ → Lifecycle weight: active=1.0, aging=0.7, pinned=1.0
819
+ → Device boost: current device × 1.1
820
+ → Return top N results
821
+ ```
822
+
823
+ **Project scoping**: Search is always scoped to the current project by default. The canonical project ID (from git remote) is sent as a metadata filter to Candengo Vector and as a WHERE clause to local SQLite. This means a search in the `aimy-agent` project on your laptop matches observations from `aimy-agent` on your desktop — because both resolve to `github.com/unimpossible/aimy-agent`.
824
+
825
+ **Cross-project search**: Pass `project: "*"` to search across all projects. Useful for finding patterns that apply across codebases ("how did we handle CORS last time in any project?").
826
+
827
+ ### Result Format (Compact Index)
828
+
829
+ ```markdown
830
+ ### Memory Search Results (aimy-agent)
831
+
832
+ | ID | Time | T | Q | Title | By |
833
+ |----|------|---|---|-------|----|
834
+ | #574 | 4:26 PM | D | ●●●○○ | Conversation lookup allows multiple active per user | david/laptop |
835
+ | #573 | 4:26 PM | B | ●●●●○ | Chat stream crashes due to duplicate conversations | david/desktop |
836
+ | #201 | Feb 12 | P | ●●●●● | SSE streaming must wrap generator in try/finally | sarah/laptop |
837
+ | 📌 #89 | Jan 5 | DC | ●●●●● | Use FastAPI BackgroundTasks for non-blocking writes | david/laptop |
838
+
839
+ Access full details: get_observations([574, 573, 201, 89])
840
+ ```
841
+
842
+ Legend: B=bugfix, D=discovery, F=feature, R=refactor, C=change, P=pattern, DC=decision, DG=digest
843
+ 📌 = pinned (won't age out)