eklavya 1.18.2 → 1.19.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 (98) hide show
  1. package/dist/assets/dashboard.html +559 -14
  2. package/dist/assets/tutor/references/focus-and-level.md +29 -0
  3. package/dist/cli.js +648 -20
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config-path.js +156 -0
  6. package/dist/config-path.js.map +1 -0
  7. package/dist/config.js +231 -2
  8. package/dist/config.js.map +1 -1
  9. package/dist/dashboard.js +355 -0
  10. package/dist/dashboard.js.map +1 -1
  11. package/dist/eval/retrieval-score.js +77 -0
  12. package/dist/eval/retrieval-score.js.map +1 -0
  13. package/dist/hooks/capture-tool.js +107 -0
  14. package/dist/hooks/capture-tool.js.map +1 -0
  15. package/dist/hooks/checkpoint-quiz.js +5 -6
  16. package/dist/hooks/checkpoint-quiz.js.map +1 -1
  17. package/dist/hooks/lib.js +7 -33
  18. package/dist/hooks/lib.js.map +1 -1
  19. package/dist/hooks/memory-lib.js +181 -0
  20. package/dist/hooks/memory-lib.js.map +1 -0
  21. package/dist/hooks/prompt-submit-nudge.js +56 -20
  22. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  23. package/dist/hooks/session-start.js +73 -74
  24. package/dist/hooks/session-start.js.map +1 -1
  25. package/dist/hooks/stop-quiz-check.js +44 -7
  26. package/dist/hooks/stop-quiz-check.js.map +1 -1
  27. package/dist/install.js +37 -0
  28. package/dist/install.js.map +1 -1
  29. package/dist/memory/capture.js +123 -0
  30. package/dist/memory/capture.js.map +1 -0
  31. package/dist/memory/code.js +186 -0
  32. package/dist/memory/code.js.map +1 -0
  33. package/dist/memory/collections.js +87 -0
  34. package/dist/memory/collections.js.map +1 -0
  35. package/dist/memory/embed.js +81 -0
  36. package/dist/memory/embed.js.map +1 -0
  37. package/dist/memory/hosts.js +71 -0
  38. package/dist/memory/hosts.js.map +1 -0
  39. package/dist/memory/identity.js +74 -0
  40. package/dist/memory/identity.js.map +1 -0
  41. package/dist/memory/import.js +916 -0
  42. package/dist/memory/import.js.map +1 -0
  43. package/dist/memory/learning.js +148 -0
  44. package/dist/memory/learning.js.map +1 -0
  45. package/dist/memory/notify.js +220 -0
  46. package/dist/memory/notify.js.map +1 -0
  47. package/dist/memory/privacy.js +117 -0
  48. package/dist/memory/privacy.js.map +1 -0
  49. package/dist/memory/provider.js +155 -0
  50. package/dist/memory/provider.js.map +1 -0
  51. package/dist/memory/recall.js +327 -0
  52. package/dist/memory/recall.js.map +1 -0
  53. package/dist/memory/replay.js +174 -0
  54. package/dist/memory/replay.js.map +1 -0
  55. package/dist/memory/search.js +143 -0
  56. package/dist/memory/search.js.map +1 -0
  57. package/dist/memory/spool.js +93 -0
  58. package/dist/memory/spool.js.map +1 -0
  59. package/dist/memory/store.js +399 -0
  60. package/dist/memory/store.js.map +1 -0
  61. package/dist/memory/summarize.js +178 -0
  62. package/dist/memory/summarize.js.map +1 -0
  63. package/dist/memory/sync.js +595 -0
  64. package/dist/memory/sync.js.map +1 -0
  65. package/dist/memory/tokens.js +55 -0
  66. package/dist/memory/tokens.js.map +1 -0
  67. package/dist/memory/worker.js +207 -0
  68. package/dist/memory/worker.js.map +1 -0
  69. package/dist/migrations/009_memory.sql +226 -0
  70. package/dist/migrations/010_import.sql +26 -0
  71. package/dist/migrations/011_sync.sql +76 -0
  72. package/dist/migrations/012_job_backoff.sql +13 -0
  73. package/dist/migrations/013_batch_provenance.sql +18 -0
  74. package/dist/migrations/014_batch_events_index.sql +18 -0
  75. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  76. package/dist/plugin/agents/tutor.md +3 -1
  77. package/dist/plugin/hooks/CLAUDE.md +30 -6
  78. package/dist/plugin/hooks/hooks.json +10 -0
  79. package/dist/plugin/skills/CLAUDE.md +17 -7
  80. package/dist/plugin/skills/memory/SKILL.md +88 -0
  81. package/dist/plugin/skills/setup/SKILL.md +16 -2
  82. package/dist/plugin/skills/tutor/references/focus-and-level.md +29 -0
  83. package/dist/time.js +41 -0
  84. package/dist/time.js.map +1 -0
  85. package/dist/tools/code_tools.js +73 -0
  86. package/dist/tools/code_tools.js.map +1 -0
  87. package/dist/tools/collection_tools.js +92 -0
  88. package/dist/tools/collection_tools.js.map +1 -0
  89. package/dist/tools/config_tools.js +104 -1
  90. package/dist/tools/config_tools.js.map +1 -1
  91. package/dist/tools/index.js +15 -0
  92. package/dist/tools/index.js.map +1 -1
  93. package/dist/tools/memory_read_tools.js +277 -0
  94. package/dist/tools/memory_read_tools.js.map +1 -0
  95. package/dist/tools/memory_write_tools.js +115 -0
  96. package/dist/tools/memory_write_tools.js.map +1 -0
  97. package/dist/user-skill/eklavya/SKILL.md +64 -8
  98. package/package.json +2 -1
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Token accounting for the savings ledger (PRD MET-01).
3
+ *
4
+ * The contract is not "be exact" — it is "be the same method on both sides of
5
+ * the subtraction, and say which method it was". A receipt stores `method`, and
6
+ * a percentage is only computed from two counts that share it. That is what
7
+ * stops the classic dishonest number: a generous estimate of what was avoided
8
+ * against a tight count of what was sent.
9
+ */
10
+ /** Identity of the estimator, stored on every receipt. */
11
+ export const ESTIMATOR = 'chars4-v1';
12
+ /**
13
+ * Four characters per token, the usual English-code approximation. It is an
14
+ * estimate and every surface that shows a number derived from it says so.
15
+ */
16
+ export function estimateTokens(text) {
17
+ if (!text)
18
+ return 0;
19
+ return Math.ceil(text.length / 4);
20
+ }
21
+ export function estimateTokensOf(parts) {
22
+ return estimateTokens(parts.filter(Boolean).join('\n'));
23
+ }
24
+ /**
25
+ * The one place the percentage is computed. Every caller — banner, dashboard,
26
+ * CLI — goes through it, so they cannot drift into three different arithmetics.
27
+ *
28
+ * Negative saving is reported as overhead rather than clamped to zero: reuse
29
+ * that costs more than it saves is a real outcome and hiding it is how a
30
+ * measurement becomes marketing.
31
+ */
32
+ export function savingsFrom(input) {
33
+ const { baseTokens: base, deliveredTokens: delivered, delivery } = input;
34
+ if (base <= 0 && delivered <= 0)
35
+ return { kind: 'none' };
36
+ if (delivery !== 'confirmed' || base <= 0)
37
+ return { kind: 'unavailable' };
38
+ if (delivered > base)
39
+ return { kind: 'overhead', tokens: delivered - base, base, delivered };
40
+ return { kind: 'saving', percent: Math.round((100 * (base - delivered)) / base), base, delivered };
41
+ }
42
+ /** The banner line for a savings result (UX-01 wording). */
43
+ export function savingsLine(s) {
44
+ switch (s.kind) {
45
+ case 'none':
46
+ return 'Your savings: — no context reused yet';
47
+ case 'unavailable':
48
+ return 'Your savings: — unavailable';
49
+ case 'overhead':
50
+ return `Reuse overhead: ${s.tokens} tokens (estimated)`;
51
+ case 'saving':
52
+ return `Your savings: ${s.percent}% less context from reuse (estimated)`;
53
+ }
54
+ }
55
+ //# sourceMappingURL=tokens.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/memory/tokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,0DAA0D;AAC1D,MAAM,CAAC,MAAM,SAAS,GAAG,WAAW,CAAC;AAErC;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,IAA+B;IAC5D,IAAI,CAAC,IAAI;QAAE,OAAO,CAAC,CAAC;IACpB,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACpC,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAoC;IACnE,OAAO,cAAc,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;AAC1D,CAAC;AAcD;;;;;;;GAOG;AACH,MAAM,UAAU,WAAW,CAAC,KAAmB;IAC7C,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,eAAe,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC;IACzE,IAAI,IAAI,IAAI,CAAC,IAAI,SAAS,IAAI,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IACzD,IAAI,QAAQ,KAAK,WAAW,IAAI,IAAI,IAAI,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC;IAC1E,IAAI,SAAS,GAAG,IAAI;QAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;IAC7F,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,GAAG,CAAC,IAAI,GAAG,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AACrG,CAAC;AAED,4DAA4D;AAC5D,MAAM,UAAU,WAAW,CAAC,CAAU;IACpC,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAC;QACf,KAAK,MAAM;YACT,OAAO,uCAAuC,CAAC;QACjD,KAAK,aAAa;YAChB,OAAO,6BAA6B,CAAC;QACvC,KAAK,UAAU;YACb,OAAO,mBAAmB,CAAC,CAAC,MAAM,qBAAqB,CAAC;QAC1D,KAAK,QAAQ;YACX,OAAO,iBAAiB,CAAC,CAAC,OAAO,uCAAuC,CAAC;IAC7E,CAAC;AACH,CAAC"}
@@ -0,0 +1,207 @@
1
+ import crypto from 'node:crypto';
2
+ import { nowIso } from '../time.js';
3
+ import { ProviderError, ProviderSummarizer } from './provider.js';
4
+ import { LocalSummarizer, summarizeSession } from './summarize.js';
5
+ import { addCandidate, batchById, batchEvents, batchSession, claimJob, failJob, finishJob, insertEntry, replaceEntry, timeline, } from './store.js';
6
+ /**
7
+ * The job worker (ADR-05).
8
+ *
9
+ * There is no daemon. Hooks and the CLI call `processPending` at the seams
10
+ * where work naturally arrives, under a lease that makes concurrent callers
11
+ * safe. The failure this avoids is the reference's worst class: an orphaned
12
+ * background process holding a port, restarted by self-healing after the
13
+ * developer uninstalled it.
14
+ *
15
+ * `processPending` is bounded by `maxJobs` and never throws: a memory failure
16
+ * must not take a coding session with it.
17
+ */
18
+ export function summarizerFor(config) {
19
+ return config.providers.observer ? new ProviderSummarizer(config.providers.observer) : new LocalSummarizer();
20
+ }
21
+ /**
22
+ * What governed one summarisation run (PRD MEM-01: "a versioned input batch and
23
+ * prompt/config identity").
24
+ *
25
+ * `generator` on an entry names the summariser, but only after the fact and
26
+ * only for a batch that produced something — a batch that came out wrong, or
27
+ * empty, leaves no row to ask. Recorded on the batch, it is what makes "re-run
28
+ * this batch against the prompt that produced it" an answerable question.
29
+ *
30
+ * The digest covers the three settings that change what a summary comes out as:
31
+ * what capture accepted, what redaction removed from it, and which model read
32
+ * it. `ProviderConfig` names an environment variable rather than carrying a key
33
+ * (SEC-01), so nothing secret is hashed.
34
+ *
35
+ * ponytail: the provider's system prompt is versioned by `provider.ts` alone —
36
+ * `summarizer.id` carries the model, not the prompt. Fold a prompt version into
37
+ * `ProviderSummarizer.id` once that prompt starts changing between releases.
38
+ */
39
+ function configDigest(config) {
40
+ return crypto
41
+ .createHash('sha256')
42
+ .update(JSON.stringify([config.memory, config.privacy, config.providers.observer]))
43
+ .digest('hex')
44
+ .slice(0, 12);
45
+ }
46
+ /**
47
+ * The roll-up's own generator id, versioned like any other summariser, so an
48
+ * entry always says which code wrote it.
49
+ */
50
+ const SESSION_SUMMARY_GENERATOR = 'session-rollup-v1';
51
+ /**
52
+ * Writes — or refreshes — this session's summary. Returns its entry id, or null
53
+ * when the session has nothing worth summarising.
54
+ *
55
+ * Called at the session seam, which fires at the end of every turn rather than
56
+ * once at the end of a session, so the row is rewritten in place as the session
57
+ * grows instead of one summary being written per turn.
58
+ *
59
+ * ponytail: with `providers.observer` set, the seam only *queues* the batch, so
60
+ * the last turn's observations land after this ran and the summary trails them
61
+ * by one turn until the next seam. Move the call after `processPending` if the
62
+ * provider path ever stops being asynchronous.
63
+ */
64
+ export function writeSessionSummary(db, project, sessionId) {
65
+ // Bounded: the oldest observations of a day-long session are not where it
66
+ // left off, and an unbounded roll-up is an unbounded row in every recall.
67
+ const observations = timeline(db, { project, sessionId, kind: 'observation', limit: 24 });
68
+ const draft = summarizeSession(observations);
69
+ if (!draft)
70
+ return null;
71
+ const fields = {
72
+ project,
73
+ sessionId,
74
+ kind: 'session_summary',
75
+ type: draft.type,
76
+ title: draft.title,
77
+ narrative: draft.narrative,
78
+ facts: draft.facts,
79
+ files: draft.files,
80
+ tags: draft.tags,
81
+ generator: SESSION_SUMMARY_GENERATOR,
82
+ confidence: draft.confidence,
83
+ // The newest observation's time, so the summary sorts above the work it
84
+ // covers instead of below the session's first batch.
85
+ occurredAt: observations[0].occurred_at,
86
+ eventIds: draft.eventIds,
87
+ };
88
+ const existing = timeline(db, { project, sessionId, kind: 'session_summary', limit: 1 })[0];
89
+ if (existing) {
90
+ replaceEntry(db, existing.id, fields);
91
+ return existing.id;
92
+ }
93
+ return insertEntry(db, fields);
94
+ }
95
+ export async function processPending(db, config, opts = {}) {
96
+ const owner = opts.owner ?? `${process.pid}-${crypto.randomUUID().slice(0, 8)}`;
97
+ const maxJobs = opts.maxJobs ?? 3;
98
+ const summarizer = summarizerFor(config);
99
+ const result = { processed: 0, entries: 0, failed: 0, skipped: 0 };
100
+ for (let i = 0; i < maxJobs; i++) {
101
+ const job = claimJob(db, owner);
102
+ if (!job)
103
+ break;
104
+ const batch = batchById(db, job.batch_id);
105
+ const events = batchEvents(db, job.batch_id);
106
+ if (!batch || events.length === 0) {
107
+ // A batch with no events cannot produce anything and will never succeed.
108
+ finishJob(db, job.id, owner);
109
+ result.skipped++;
110
+ continue;
111
+ }
112
+ // Before the call, not after it: a batch that fails is the one whose
113
+ // provenance someone comes looking for.
114
+ db.prepare('UPDATE memory_batches SET summarizer = ?, config_digest = ? WHERE id = ?').run(summarizer.id, configDigest(config), batch.id);
115
+ try {
116
+ const drafts = await summarizer.summarize({
117
+ project: batch.project,
118
+ sessionId: batch.session_id,
119
+ events,
120
+ });
121
+ // One transaction for the entries, the candidates and the event status:
122
+ // a crash between them would leave events marked done with nothing to
123
+ // show for them, which is the one loss the batch cannot recover from.
124
+ db.transaction(() => {
125
+ for (const draft of drafts) {
126
+ const entryId = insertEntry(db, {
127
+ project: batch.project,
128
+ sessionId: batch.session_id,
129
+ batchId: batch.id,
130
+ type: draft.type,
131
+ title: draft.title,
132
+ narrative: draft.narrative,
133
+ facts: draft.facts,
134
+ files: draft.files,
135
+ tags: draft.tags,
136
+ generator: summarizer.id,
137
+ confidence: draft.confidence,
138
+ occurredAt: events[0].occurred_at,
139
+ eventIds: draft.eventIds,
140
+ });
141
+ result.entries++;
142
+ for (const concept of draft.concepts ?? []) {
143
+ addCandidate(db, {
144
+ entryId,
145
+ slug: concept.slug,
146
+ name: concept.name,
147
+ domain: concept.domain,
148
+ confidence: draft.confidence,
149
+ project: batch.project,
150
+ });
151
+ }
152
+ }
153
+ db.prepare("UPDATE evidence_events SET status = 'summarized' WHERE batch_id = ?").run(batch.id);
154
+ })();
155
+ finishJob(db, job.id, owner);
156
+ result.processed++;
157
+ }
158
+ catch (error) {
159
+ const errorClass = error instanceof ProviderError ? error.errorClass : 'transient';
160
+ failJob(db, job.id, owner, errorClass, error instanceof Error ? error.message : String(error));
161
+ result.failed++;
162
+ // A paused provider will fail the next job the same way; stop rather than
163
+ // burn the remaining budget re-learning that the key is rejected.
164
+ if (errorClass === 'auth' || errorClass === 'quota')
165
+ break;
166
+ }
167
+ }
168
+ return result;
169
+ }
170
+ /**
171
+ * The session seam: close the open batch and drain the queue.
172
+ *
173
+ * Called by the Stop hook and by `eklavya memory flush`. Batching at the seam
174
+ * rather than per tool call is what keeps one provider request from riding on
175
+ * every edit (PRD MEM-01).
176
+ */
177
+ export async function flushSession(db, config, project, sessionId, reason = 'session_seam') {
178
+ batchSession(db, { project, sessionId, reason, maxEvents: config.memory.batch_max_events });
179
+ return processPending(db, config, { maxJobs: 4 });
180
+ }
181
+ /** Age of the oldest unprocessed job, for the health surfaces. */
182
+ export function queueDepth(db) {
183
+ const row = db
184
+ .prepare(`SELECT
185
+ COALESCE(SUM(CASE WHEN status IN ('pending','claimed') THEN 1 ELSE 0 END), 0) AS pending,
186
+ COALESCE(SUM(CASE WHEN status = 'paused' THEN 1 ELSE 0 END), 0) AS paused,
187
+ COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed,
188
+ MIN(CASE WHEN status IN ('pending','claimed') THEN created_at END) AS oldest
189
+ FROM memory_jobs`)
190
+ .get();
191
+ return row;
192
+ }
193
+ /** Retention sweep (PRD SEC-02). Raw evidence ages out; entries are kept. */
194
+ export function pruneEvidence(db, config) {
195
+ const days = config.memory.retention_days;
196
+ if (!days)
197
+ return 0;
198
+ const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
199
+ const info = db
200
+ .prepare(`DELETE FROM evidence_events
201
+ WHERE occurred_at < ? AND status = 'summarized'
202
+ AND id NOT IN (SELECT event_id FROM memory_entry_events)`)
203
+ .run(cutoff);
204
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('memory_pruned_at', ?)").run(nowIso());
205
+ return info.changes;
206
+ }
207
+ //# sourceMappingURL=worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.js","sourceRoot":"","sources":["../../src/memory/worker.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,226 @@
1
+ -- Memory: the evidence half of the unified plugin (.plan/unified-memory-learning).
2
+ --
3
+ -- Learning already records what the developer was *asked* and what they knew.
4
+ -- These tables record what actually *happened* -- the prompts, tool uses, edits
5
+ -- and failures a session produced -- and the observations distilled from them.
6
+ -- They live in the same file as the learning tables (ADR-02) so one transaction
7
+ -- can say "this evidence produced this concept candidate", and they hold no
8
+ -- foreign key into learning except that one explicit link table.
9
+ --
10
+ -- Nothing here is trusted instruction material. Everything an observer or an
11
+ -- import writes is derived data with provenance, quoted back to the model as
12
+ -- evidence and never as a system prompt (PRD SEC-01).
13
+
14
+ -- Raw accepted evidence. One row per host event that survived exclusion and
15
+ -- sanitisation. `event_uid` is the idempotency key: hooks and transcript replay
16
+ -- both converge here and must not record the same turn twice (PRD CAP-02).
17
+ CREATE TABLE IF NOT EXISTS evidence_events (
18
+ id INTEGER PRIMARY KEY,
19
+ event_uid TEXT NOT NULL UNIQUE,
20
+ project TEXT NOT NULL, -- projectKey: worktrees folded into their checkout
21
+ checkout TEXT, -- the unfolded root, so a worktree stays distinguishable
22
+ session_id TEXT NOT NULL,
23
+ agent_id TEXT, -- subagent identity, NULL for the main thread
24
+ host TEXT NOT NULL DEFAULT 'claude-code',
25
+ source TEXT NOT NULL DEFAULT 'hook', -- 'hook' | 'replay' | 'manual' | 'import'
26
+ kind TEXT NOT NULL, -- prompt|tool_use|tool_error|file_edit|file_read|assistant|lifecycle|note
27
+ tool TEXT,
28
+ title TEXT,
29
+ body TEXT NOT NULL DEFAULT '',
30
+ files TEXT, -- JSON array of repo-relative paths
31
+ occurred_at TEXT NOT NULL, -- when it happened on the host
32
+ received_at TEXT NOT NULL DEFAULT (datetime('now')),
33
+ redacted INTEGER NOT NULL DEFAULT 0, -- 1 when the privacy filter removed something
34
+ batch_id INTEGER, -- set once a batch claims it
35
+ status TEXT NOT NULL DEFAULT 'accepted' -- accepted|batched|summarized
36
+ );
37
+
38
+ -- The immutable input to one observation job. A provider request must be
39
+ -- reconstructible from storage after a crash, not from a lost in-memory
40
+ -- conversation (PRD DATA-03), so the batch is written before the job runs.
41
+ CREATE TABLE IF NOT EXISTS memory_batches (
42
+ id INTEGER PRIMARY KEY,
43
+ project TEXT NOT NULL,
44
+ session_id TEXT NOT NULL,
45
+ reason TEXT NOT NULL, -- 'size' | 'time' | 'session_seam' | 'manual'
46
+ event_count INTEGER NOT NULL DEFAULT 0,
47
+ byte_count INTEGER NOT NULL DEFAULT 0,
48
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
49
+ );
50
+
51
+ -- Durable jobs with claim leases. A crashed worker's lease expires and the job
52
+ -- is retried; `attempts` and `last_error` classify why, so a permanent failure
53
+ -- (bad auth) is distinguishable from a transient one (timeout).
54
+ CREATE TABLE IF NOT EXISTS memory_jobs (
55
+ id INTEGER PRIMARY KEY,
56
+ batch_id INTEGER NOT NULL REFERENCES memory_batches(id) ON DELETE CASCADE,
57
+ kind TEXT NOT NULL DEFAULT 'summarize',
58
+ status TEXT NOT NULL DEFAULT 'pending', -- pending|claimed|done|failed|paused
59
+ attempts INTEGER NOT NULL DEFAULT 0,
60
+ lease_owner TEXT,
61
+ lease_until TEXT,
62
+ last_error TEXT,
63
+ error_class TEXT, -- transient|auth|quota|overflow|malformed|permanent
64
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
65
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
66
+ );
67
+
68
+ -- An observation: the distilled, searchable unit of memory. `kind` separates a
69
+ -- generated observation from a session summary and from a note the developer
70
+ -- wrote by hand. Corrections supersede rather than overwrite, so the audit
71
+ -- trail survives (PRD MEM-03).
72
+ CREATE TABLE IF NOT EXISTS memory_entries (
73
+ id INTEGER PRIMARY KEY,
74
+ entry_uid TEXT NOT NULL UNIQUE,
75
+ project TEXT NOT NULL,
76
+ session_id TEXT,
77
+ batch_id INTEGER REFERENCES memory_batches(id) ON DELETE SET NULL,
78
+ kind TEXT NOT NULL DEFAULT 'observation', -- observation|session_summary|note
79
+ type TEXT, -- bugfix|feature|refactor|decision|discovery|...
80
+ title TEXT NOT NULL,
81
+ narrative TEXT NOT NULL DEFAULT '',
82
+ facts TEXT, -- JSON array of strings
83
+ files TEXT, -- JSON array of repo-relative paths
84
+ generator TEXT NOT NULL DEFAULT 'local', -- summarizer id, e.g. 'local-v1' or a provider id
85
+ confidence REAL,
86
+ occurred_at TEXT NOT NULL,
87
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
88
+ superseded_by INTEGER REFERENCES memory_entries(id) ON DELETE SET NULL,
89
+ deleted_at TEXT,
90
+ import_source TEXT -- provenance when the row came from an importer
91
+ );
92
+
93
+ -- Which raw events an entry came from. Drill-down from an observation to the
94
+ -- evidence behind it, and the reason an entry can be rebuilt after a bad batch.
95
+ CREATE TABLE IF NOT EXISTS memory_entry_events (
96
+ entry_id INTEGER NOT NULL REFERENCES memory_entries(id) ON DELETE CASCADE,
97
+ event_id INTEGER NOT NULL REFERENCES evidence_events(id) ON DELETE CASCADE,
98
+ PRIMARY KEY (entry_id, event_id)
99
+ );
100
+
101
+ -- Tags are a table rather than JSON because filtering by tag is a first-class
102
+ -- retrieval path and `json_each` over the whole corpus is not an index.
103
+ CREATE TABLE IF NOT EXISTS memory_entry_tags (
104
+ entry_id INTEGER NOT NULL REFERENCES memory_entries(id) ON DELETE CASCADE,
105
+ tag TEXT NOT NULL,
106
+ PRIMARY KEY (entry_id, tag)
107
+ );
108
+
109
+ -- Keyword retrieval. Contentless FTS5: the row content lives in memory_entries
110
+ -- and the index stores only the terms, so there is one copy of the text.
111
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
112
+ title, narrative, facts, files,
113
+ content = 'memory_entries',
114
+ content_rowid = 'id',
115
+ tokenize = 'unicode61 remove_diacritics 2'
116
+ );
117
+
118
+ -- Semantic retrieval (ADR-03). One row per entry per embedder: switching
119
+ -- embedders re-embeds in the background and vectors of different models are
120
+ -- never compared, because `embedder_id` is part of the read filter.
121
+ CREATE TABLE IF NOT EXISTS memory_vectors (
122
+ entry_id INTEGER NOT NULL REFERENCES memory_entries(id) ON DELETE CASCADE,
123
+ embedder_id TEXT NOT NULL,
124
+ dim INTEGER NOT NULL,
125
+ vec BLOB NOT NULL, -- float32 little-endian, L2-normalised
126
+ PRIMARY KEY (entry_id, embedder_id)
127
+ );
128
+
129
+ -- The savings ledger (PRD MET-01). One receipt per injection: what would have
130
+ -- been read (`base_tokens`), what was actually delivered (`delivered_tokens`),
131
+ -- how both were counted, and whether delivery was confirmed. A percentage the
132
+ -- dashboard or the banner shows must be derivable from these rows alone.
133
+ CREATE TABLE IF NOT EXISTS context_receipts (
134
+ id INTEGER PRIMARY KEY,
135
+ receipt_uid TEXT NOT NULL UNIQUE,
136
+ project TEXT NOT NULL,
137
+ session_id TEXT,
138
+ scope TEXT NOT NULL, -- 'session_start' | 'search' | 'resume' | ...
139
+ method TEXT NOT NULL, -- tokenizer/estimator identity
140
+ base_tokens INTEGER NOT NULL DEFAULT 0,
141
+ delivered_tokens INTEGER NOT NULL DEFAULT 0,
142
+ item_count INTEGER NOT NULL DEFAULT 0,
143
+ delivery TEXT NOT NULL DEFAULT 'unknown', -- confirmed|unknown|prepared
144
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
145
+ );
146
+
147
+ -- Per-item accounting, so a later detail fetch updates the same episode's total
148
+ -- instead of leaving the optimistic index-only percentage standing.
149
+ CREATE TABLE IF NOT EXISTS context_receipt_items (
150
+ receipt_id INTEGER NOT NULL REFERENCES context_receipts(id) ON DELETE CASCADE,
151
+ entry_id INTEGER NOT NULL REFERENCES memory_entries(id) ON DELETE CASCADE,
152
+ source_tokens INTEGER NOT NULL DEFAULT 0,
153
+ sent_tokens INTEGER NOT NULL DEFAULT 0,
154
+ stage TEXT NOT NULL DEFAULT 'index', -- 'index' | 'detail'
155
+ PRIMARY KEY (receipt_id, entry_id, stage)
156
+ );
157
+
158
+ -- The one explicit bridge between the two halves (PRD LRN-02, DATA-01).
159
+ -- An evidence-derived concept candidate. `status` is the gate: a candidate is
160
+ -- not a concept, and no row here ever records an attempt or moves mastery.
161
+ CREATE TABLE IF NOT EXISTS learning_sources (
162
+ id INTEGER PRIMARY KEY,
163
+ entry_id INTEGER REFERENCES memory_entries(id) ON DELETE CASCADE,
164
+ event_id INTEGER REFERENCES evidence_events(id) ON DELETE CASCADE,
165
+ concept_id INTEGER REFERENCES concepts(id),
166
+ slug TEXT, -- proposed slug before validation
167
+ name TEXT,
168
+ domain TEXT,
169
+ confidence REAL NOT NULL DEFAULT 0,
170
+ status TEXT NOT NULL DEFAULT 'candidate', -- candidate|accepted|rejected|duplicate
171
+ project TEXT,
172
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
173
+ );
174
+
175
+ -- Saved focused knowledge collections (PRD RET-04). The filter is stored so a
176
+ -- rebuild reproduces the same query; membership is materialised so a failed
177
+ -- rebuild can be discarded without losing the last good set.
178
+ CREATE TABLE IF NOT EXISTS memory_collections (
179
+ id INTEGER PRIMARY KEY,
180
+ name TEXT NOT NULL UNIQUE,
181
+ description TEXT,
182
+ filter TEXT NOT NULL DEFAULT '{}', -- JSON query descriptor
183
+ project TEXT,
184
+ built_at TEXT,
185
+ status TEXT NOT NULL DEFAULT 'ready', -- ready|building|failed
186
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
187
+ );
188
+
189
+ CREATE TABLE IF NOT EXISTS memory_collection_items (
190
+ collection_id INTEGER NOT NULL REFERENCES memory_collections(id) ON DELETE CASCADE,
191
+ entry_id INTEGER NOT NULL REFERENCES memory_entries(id) ON DELETE CASCADE,
192
+ rank REAL NOT NULL DEFAULT 0,
193
+ PRIMARY KEY (collection_id, entry_id)
194
+ );
195
+
196
+ -- Read paths on a user's critical path: the SessionStart recall, the dashboard
197
+ -- timeline, and the worker's claim scan.
198
+ CREATE INDEX IF NOT EXISTS idx_events_project_time ON evidence_events(project, occurred_at DESC);
199
+ CREATE INDEX IF NOT EXISTS idx_events_session ON evidence_events(session_id, occurred_at DESC);
200
+ CREATE INDEX IF NOT EXISTS idx_events_status ON evidence_events(status, project);
201
+ CREATE INDEX IF NOT EXISTS idx_entries_project_time ON memory_entries(project, occurred_at DESC);
202
+ CREATE INDEX IF NOT EXISTS idx_entries_session ON memory_entries(session_id);
203
+ CREATE INDEX IF NOT EXISTS idx_jobs_status ON memory_jobs(status, lease_until);
204
+ CREATE INDEX IF NOT EXISTS idx_receipts_project ON context_receipts(project, created_at DESC);
205
+ CREATE INDEX IF NOT EXISTS idx_sources_status ON learning_sources(status, project);
206
+ CREATE INDEX IF NOT EXISTS idx_tags_tag ON memory_entry_tags(tag);
207
+
208
+ -- FTS stays in step through triggers rather than through every call site
209
+ -- remembering to update it. External-content FTS5 needs the old values on
210
+ -- delete, which only a trigger has to hand.
211
+ CREATE TRIGGER IF NOT EXISTS memory_entries_ai AFTER INSERT ON memory_entries BEGIN
212
+ INSERT INTO memory_fts(rowid, title, narrative, facts, files)
213
+ VALUES (new.id, new.title, new.narrative, new.facts, new.files);
214
+ END;
215
+
216
+ CREATE TRIGGER IF NOT EXISTS memory_entries_ad AFTER DELETE ON memory_entries BEGIN
217
+ INSERT INTO memory_fts(memory_fts, rowid, title, narrative, facts, files)
218
+ VALUES ('delete', old.id, old.title, old.narrative, old.facts, old.files);
219
+ END;
220
+
221
+ CREATE TRIGGER IF NOT EXISTS memory_entries_au AFTER UPDATE ON memory_entries BEGIN
222
+ INSERT INTO memory_fts(memory_fts, rowid, title, narrative, facts, files)
223
+ VALUES ('delete', old.id, old.title, old.narrative, old.facts, old.files);
224
+ INSERT INTO memory_fts(rowid, title, narrative, facts, files)
225
+ VALUES (new.id, new.title, new.narrative, new.facts, new.files);
226
+ END;
@@ -0,0 +1,26 @@
1
+ -- Import provenance: the durable id map that makes an import resumable and
2
+ -- idempotent (PRD MIG-02).
3
+ --
4
+ -- Without it, a run interrupted halfway has no way to tell what it already
5
+ -- wrote, and the only safe second attempt is a full re-import that duplicates
6
+ -- everything. Entry and event uids are deterministic, so duplication is already
7
+ -- prevented at the row level -- but "already present" and "just written" are
8
+ -- indistinguishable without a map, and a report that cannot say which is which
9
+ -- cannot validate its own counts.
10
+ --
11
+ -- Keyed by (source, source_db, source_table, source_id) rather than by source
12
+ -- id alone: two Claude Mem databases (a laptop's and a restored backup) share
13
+ -- their autoincrement ids, and folding them together would silently drop half
14
+ -- of the second import.
15
+ CREATE TABLE IF NOT EXISTS import_id_map (
16
+ source TEXT NOT NULL, -- importer id, e.g. 'claude-mem'
17
+ source_db TEXT NOT NULL, -- identity of the source file, not its path of the day
18
+ source_table TEXT NOT NULL,
19
+ source_id TEXT NOT NULL,
20
+ target_table TEXT NOT NULL, -- 'memory_entries' | 'evidence_events' | 'learning_sources'
21
+ target_id INTEGER NOT NULL,
22
+ imported_at TEXT NOT NULL DEFAULT (datetime('now')),
23
+ PRIMARY KEY (source, source_db, source_table, source_id)
24
+ );
25
+
26
+ CREATE INDEX IF NOT EXISTS idx_import_target ON import_id_map(target_table, target_id);
@@ -0,0 +1,76 @@
1
+ -- Multi-device sync (ADR-09): a shared *directory*, never a server.
2
+ --
3
+ -- Two devices point at one folder -- Dropbox, iCloud, Syncthing, a mounted
4
+ -- share, a git repo -- and exchange revision records as files. None of those is
5
+ -- a dependency, because the target is a path. The hosted team server in the
6
+ -- PRD's Phase 6 is deliberately not built.
7
+ --
8
+ -- What crosses is memory entries, their tags and their tombstones. Attempts,
9
+ -- mastery, gates, receipts and raw `evidence_events` never do (PRD SEC-02): a
10
+ -- developer's assessment history is theirs, and pointing a team at one shared
11
+ -- folder must not hand them everyone's quiz record. `src/memory/sync.ts`
12
+ -- enforces that with a column allowlist; these tables simply have nowhere to
13
+ -- put anything else.
14
+
15
+ -- Per-device high-water marks, including this device's own counter.
16
+ --
17
+ -- One uniform meaning for every row: "revisions from this device up to and
18
+ -- including `last_revision` are reflected in this database". For a peer that
19
+ -- makes a pull incremental -- only files above the mark are read. For the local
20
+ -- device it is the last revision handed out, so `revision` is monotonic per
21
+ -- device and a pair (device_id, revision) names one version for ever.
22
+ CREATE TABLE IF NOT EXISTS sync_state (
23
+ device_id TEXT PRIMARY KEY,
24
+ last_revision INTEGER NOT NULL DEFAULT 0,
25
+ last_sync_at TEXT
26
+ );
27
+
28
+ -- The version vector, one row per entry uid: which device wrote the version
29
+ -- this database currently holds, at which revision, and what it descended from.
30
+ --
31
+ -- Keyed by `entry_uid` and not by the entry's integer id, and with no foreign
32
+ -- key, on purpose. Row ids are local; `entry_uid` is the only name two devices
33
+ -- agree on. And a tombstone has to outlive the row it buries -- without a
34
+ -- record that survives the delete, the next pull from a device that still had
35
+ -- the entry would resurrect it, which is the exact bug this table prevents.
36
+ --
37
+ -- `base` is the version this one replaced, spelled `<device>:<revision>` (NULL
38
+ -- for a first write). An incoming record whose `base` is what we already hold
39
+ -- is a fast-forward and applies. Anything else is divergence, and divergence is
40
+ -- quarantined rather than merged.
41
+ --
42
+ -- `hash` covers the payload, so the push scan can tell a changed entry from an
43
+ -- untouched one without a write-time hook in `memory/store.ts`.
44
+ CREATE TABLE IF NOT EXISTS sync_records (
45
+ entry_uid TEXT PRIMARY KEY,
46
+ device_id TEXT NOT NULL,
47
+ revision INTEGER NOT NULL,
48
+ base TEXT,
49
+ op TEXT NOT NULL DEFAULT 'upsert', -- 'upsert' | 'delete' (a tombstone)
50
+ hash TEXT NOT NULL,
51
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
52
+ );
53
+
54
+ -- Losing versions, kept readable rather than dropped.
55
+ --
56
+ -- Two devices editing one entry between syncs is not something a timestamp can
57
+ -- adjudicate, so nothing here is overwritten silently: the local version stays
58
+ -- live, the incoming one lands here with its whole payload, and a repair call
59
+ -- picks a side. `payload` is the wire record verbatim so that choosing the
60
+ -- remote side needs nothing from the target directory, which may be offline by
61
+ -- the time somebody looks.
62
+ CREATE TABLE IF NOT EXISTS sync_conflicts (
63
+ id INTEGER PRIMARY KEY,
64
+ entry_uid TEXT NOT NULL,
65
+ device_id TEXT NOT NULL, -- the device whose version lost
66
+ revision INTEGER NOT NULL,
67
+ base TEXT,
68
+ local_ref TEXT NOT NULL, -- `<device>:<revision>` held at the time
69
+ payload TEXT NOT NULL, -- the incoming record, as JSON
70
+ detected_at TEXT NOT NULL DEFAULT (datetime('now')),
71
+ resolved_at TEXT,
72
+ resolution TEXT -- 'local' | 'remote' | 'superseded'
73
+ );
74
+
75
+ CREATE INDEX IF NOT EXISTS idx_sync_records_device ON sync_records(device_id, revision);
76
+ CREATE INDEX IF NOT EXISTS idx_sync_conflicts_open ON sync_conflicts(resolved_at, entry_uid);
@@ -0,0 +1,13 @@
1
+ -- Retry backoff for the observation queue.
2
+ --
3
+ -- A transient provider failure returned the job straight to 'pending', which
4
+ -- the very next hook reclaimed -- so a job failing against a timing-out or
5
+ -- rate-limited provider spun against it once per tool call until it burned its
6
+ -- five attempts, turning one bad minute into a permanent loss. `next_attempt`
7
+ -- is the floor under the next claim: `failJob` sets it, `claimJob` skips a job
8
+ -- whose floor is still in the future (quality.md, "Failure containment").
9
+ --
10
+ -- Nullable, and NULL means claimable now: every job that already exists was
11
+ -- claimable when this ran, and a migration that invented a cooldown for them
12
+ -- would stall a healthy queue on upgrade.
13
+ ALTER TABLE memory_jobs ADD COLUMN next_attempt TEXT;
@@ -0,0 +1,18 @@
1
+ -- Batch provenance (PRD MEM-01: "a versioned input batch and prompt/config
2
+ -- identity").
3
+ --
4
+ -- 009 made the input reconstructible -- the events, the reason, the counts --
5
+ -- but not the run. `memory_entries.generator` records the summariser after the
6
+ -- fact and only when the batch produced something, so a batch that came out
7
+ -- wrong, or produced nothing at all, left no way to know which summariser or
8
+ -- which configuration had read it. Both are written when the job is claimed,
9
+ -- which is the point at which they are known and before the call that may fail.
10
+ --
11
+ -- Nullable because every batch written before this migration ran genuinely has
12
+ -- no answer, and a backfilled guess would be worse than an honest NULL.
13
+ ALTER TABLE memory_batches ADD COLUMN summarizer TEXT;
14
+
15
+ -- Digest over the settings that change what a summary comes out as: what
16
+ -- capture accepted, what redaction removed, which model read it. Never a key --
17
+ -- a provider config names an environment variable, not its value (SEC-01).
18
+ ALTER TABLE memory_batches ADD COLUMN config_digest TEXT;
@@ -0,0 +1,18 @@
1
+ -- The worker reads and retires a batch by `batch_id`, and nothing indexed it.
2
+ --
3
+ -- `batchEvents` (`SELECT … WHERE batch_id = ?`) and the worker's
4
+ -- `UPDATE … SET status = 'summarized' WHERE batch_id = ?` both reported
5
+ -- `SCAN evidence_events`, so summarising one forty-event batch cost a full scan
6
+ -- of every event ever captured. The batch does not grow; the table does, which
7
+ -- makes the total summarising work over a database's life quadratic in its own
8
+ -- history. Measured at the fixture `quality.md` asks for: 1.2ms per batch at
9
+ -- 2,000 events, 26ms at 200,000, 613ms at 1,000,000 -- for identical input.
10
+ --
11
+ -- It stayed hidden because the 2k and 20k perf runs wrote a fixed ~2,000 events
12
+ -- whatever the entry count, pinning the one axis that drives it.
13
+ --
14
+ -- `memory.retention_days` defaults to null, so evidence never shrinks by itself
15
+ -- and the curve has nothing to flatten it. This is on the worker's path, at a
16
+ -- session seam, not on a human's -- which is why it was slow rather than
17
+ -- visible.
18
+ CREATE INDEX IF NOT EXISTS idx_events_batch ON evidence_events(batch_id);