@jbarket/loomai 0.5.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 (176) hide show
  1. package/CHANGELOG.md +475 -0
  2. package/LICENSE +661 -0
  3. package/README.md +702 -0
  4. package/assets/demo.cast +396 -0
  5. package/assets/skill/SKILL.md +192 -0
  6. package/dist/backends/embedding-runtime.d.ts +39 -0
  7. package/dist/backends/embedding-runtime.js +265 -0
  8. package/dist/backends/episodes.d.ts +36 -0
  9. package/dist/backends/episodes.js +126 -0
  10. package/dist/backends/fastembed.d.ts +18 -0
  11. package/dist/backends/fastembed.js +95 -0
  12. package/dist/backends/glob.d.ts +10 -0
  13. package/dist/backends/glob.js +16 -0
  14. package/dist/backends/index.d.ts +6 -0
  15. package/dist/backends/index.js +78 -0
  16. package/dist/backends/migrations.d.ts +38 -0
  17. package/dist/backends/migrations.js +157 -0
  18. package/dist/backends/mmr.d.ts +45 -0
  19. package/dist/backends/mmr.js +82 -0
  20. package/dist/backends/proposals.d.ts +65 -0
  21. package/dist/backends/proposals.js +182 -0
  22. package/dist/backends/recall-log.d.ts +42 -0
  23. package/dist/backends/recall-log.js +143 -0
  24. package/dist/backends/retry.d.ts +29 -0
  25. package/dist/backends/retry.js +60 -0
  26. package/dist/backends/salience.d.ts +57 -0
  27. package/dist/backends/salience.js +186 -0
  28. package/dist/backends/sqlite-knowledge.d.ts +37 -0
  29. package/dist/backends/sqlite-knowledge.js +788 -0
  30. package/dist/backends/sqlite-vec.d.ts +64 -0
  31. package/dist/backends/sqlite-vec.js +737 -0
  32. package/dist/backends/ttl.d.ts +24 -0
  33. package/dist/backends/ttl.js +50 -0
  34. package/dist/backends/types.d.ts +562 -0
  35. package/dist/backends/types.js +8 -0
  36. package/dist/blocks/harness.d.ts +45 -0
  37. package/dist/blocks/harness.js +142 -0
  38. package/dist/blocks/model.d.ts +4 -0
  39. package/dist/blocks/model.js +56 -0
  40. package/dist/blocks/types.d.ts +40 -0
  41. package/dist/blocks/types.js +34 -0
  42. package/dist/categories.d.ts +16 -0
  43. package/dist/categories.js +29 -0
  44. package/dist/cli/args.d.ts +21 -0
  45. package/dist/cli/args.js +47 -0
  46. package/dist/cli/bootstrap.d.ts +2 -0
  47. package/dist/cli/bootstrap.js +214 -0
  48. package/dist/cli/doctor.d.ts +2 -0
  49. package/dist/cli/doctor.js +206 -0
  50. package/dist/cli/forget.d.ts +2 -0
  51. package/dist/cli/forget.js +97 -0
  52. package/dist/cli/harness.d.ts +2 -0
  53. package/dist/cli/harness.js +91 -0
  54. package/dist/cli/index.d.ts +2 -0
  55. package/dist/cli/index.js +127 -0
  56. package/dist/cli/inject.d.ts +2 -0
  57. package/dist/cli/inject.js +298 -0
  58. package/dist/cli/install.d.ts +2 -0
  59. package/dist/cli/install.js +137 -0
  60. package/dist/cli/io.d.ts +14 -0
  61. package/dist/cli/io.js +63 -0
  62. package/dist/cli/knowledge.d.ts +2 -0
  63. package/dist/cli/knowledge.js +216 -0
  64. package/dist/cli/memory.d.ts +2 -0
  65. package/dist/cli/memory.js +608 -0
  66. package/dist/cli/migrate.d.ts +2 -0
  67. package/dist/cli/migrate.js +116 -0
  68. package/dist/cli/recall.d.ts +2 -0
  69. package/dist/cli/recall.js +91 -0
  70. package/dist/cli/remember.d.ts +2 -0
  71. package/dist/cli/remember.js +111 -0
  72. package/dist/cli/serve.d.ts +2 -0
  73. package/dist/cli/serve.js +71 -0
  74. package/dist/cli/subcommands.d.ts +7 -0
  75. package/dist/cli/subcommands.js +11 -0
  76. package/dist/cli/tui/multi-select.d.ts +53 -0
  77. package/dist/cli/tui/multi-select.js +116 -0
  78. package/dist/cli/update-identity.d.ts +2 -0
  79. package/dist/cli/update-identity.js +90 -0
  80. package/dist/cli/update.d.ts +2 -0
  81. package/dist/cli/update.js +87 -0
  82. package/dist/cli/wake.d.ts +2 -0
  83. package/dist/cli/wake.js +53 -0
  84. package/dist/clients.d.ts +8 -0
  85. package/dist/clients.js +63 -0
  86. package/dist/config.d.ts +59 -0
  87. package/dist/config.js +160 -0
  88. package/dist/index.d.ts +15 -0
  89. package/dist/index.js +75 -0
  90. package/dist/injection/harnesses.d.ts +17 -0
  91. package/dist/injection/harnesses.js +51 -0
  92. package/dist/injection/render.d.ts +17 -0
  93. package/dist/injection/render.js +50 -0
  94. package/dist/injection/writer.d.ts +25 -0
  95. package/dist/injection/writer.js +118 -0
  96. package/dist/install/harnesses.d.ts +26 -0
  97. package/dist/install/harnesses.js +90 -0
  98. package/dist/install/names.d.ts +16 -0
  99. package/dist/install/names.js +36 -0
  100. package/dist/install/render.d.ts +18 -0
  101. package/dist/install/render.js +42 -0
  102. package/dist/install/skill-source.d.ts +1 -0
  103. package/dist/install/skill-source.js +11 -0
  104. package/dist/path-safety.d.ts +19 -0
  105. package/dist/path-safety.js +62 -0
  106. package/dist/server.d.ts +8 -0
  107. package/dist/server.js +571 -0
  108. package/dist/tools/append-maintenance-log.d.ts +52 -0
  109. package/dist/tools/append-maintenance-log.js +103 -0
  110. package/dist/tools/archive.d.ts +2 -0
  111. package/dist/tools/archive.js +21 -0
  112. package/dist/tools/bootstrap.d.ts +11 -0
  113. package/dist/tools/bootstrap.js +161 -0
  114. package/dist/tools/dossier.d.ts +1 -0
  115. package/dist/tools/dossier.js +102 -0
  116. package/dist/tools/eurorack-import.d.ts +47 -0
  117. package/dist/tools/eurorack-import.js +199 -0
  118. package/dist/tools/find-similar.d.ts +3 -0
  119. package/dist/tools/find-similar.js +26 -0
  120. package/dist/tools/forget.d.ts +6 -0
  121. package/dist/tools/forget.js +70 -0
  122. package/dist/tools/harness.d.ts +18 -0
  123. package/dist/tools/harness.js +54 -0
  124. package/dist/tools/identity-scaffold.d.ts +22 -0
  125. package/dist/tools/identity-scaffold.js +142 -0
  126. package/dist/tools/identity.d.ts +4 -0
  127. package/dist/tools/identity.js +226 -0
  128. package/dist/tools/knowledge-archive.d.ts +5 -0
  129. package/dist/tools/knowledge-archive.js +24 -0
  130. package/dist/tools/knowledge-history.d.ts +6 -0
  131. package/dist/tools/knowledge-history.js +55 -0
  132. package/dist/tools/knowledge-maintain.d.ts +9 -0
  133. package/dist/tools/knowledge-maintain.js +113 -0
  134. package/dist/tools/knowledge-merge.d.ts +8 -0
  135. package/dist/tools/knowledge-merge.js +40 -0
  136. package/dist/tools/knowledge-move.d.ts +10 -0
  137. package/dist/tools/knowledge-move.js +48 -0
  138. package/dist/tools/knowledge-purge.d.ts +5 -0
  139. package/dist/tools/knowledge-purge.js +20 -0
  140. package/dist/tools/knowledge-recall.d.ts +20 -0
  141. package/dist/tools/knowledge-recall.js +142 -0
  142. package/dist/tools/knowledge-restore.d.ts +4 -0
  143. package/dist/tools/knowledge-restore.js +24 -0
  144. package/dist/tools/knowledge-supersede.d.ts +6 -0
  145. package/dist/tools/knowledge-supersede.js +36 -0
  146. package/dist/tools/knowledge-verify.d.ts +8 -0
  147. package/dist/tools/knowledge-verify.js +39 -0
  148. package/dist/tools/knowledge-write.d.ts +25 -0
  149. package/dist/tools/knowledge-write.js +87 -0
  150. package/dist/tools/memory-audit.d.ts +3 -0
  151. package/dist/tools/memory-audit.js +47 -0
  152. package/dist/tools/memory-list.d.ts +4 -0
  153. package/dist/tools/memory-list.js +23 -0
  154. package/dist/tools/prune.d.ts +4 -0
  155. package/dist/tools/prune.js +28 -0
  156. package/dist/tools/recall.d.ts +4 -0
  157. package/dist/tools/recall.js +24 -0
  158. package/dist/tools/remember.d.ts +10 -0
  159. package/dist/tools/remember.js +43 -0
  160. package/dist/tools/restore.d.ts +2 -0
  161. package/dist/tools/restore.js +20 -0
  162. package/dist/tools/update-identity.d.ts +34 -0
  163. package/dist/tools/update-identity.js +194 -0
  164. package/dist/tools/update.d.ts +2 -0
  165. package/dist/tools/update.js +16 -0
  166. package/dist/transport/guards.d.ts +35 -0
  167. package/dist/transport/guards.js +107 -0
  168. package/dist/transport/http-server.d.ts +17 -0
  169. package/dist/transport/http-server.js +198 -0
  170. package/dist/transport/mcp-app-hello.d.ts +3 -0
  171. package/dist/transport/mcp-app-hello.js +66 -0
  172. package/dist/widgets/display-modes.html +131 -0
  173. package/dist/widgets/hello.html +138 -0
  174. package/dist/widgets/proposals.html +131 -0
  175. package/dist/widgets/top-of-mind.html +131 -0
  176. package/package.json +73 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Schema migration registry for memories.db.
3
+ *
4
+ * Each Migration is idempotent: the `pending` predicate checks current DB
5
+ * state before `run` is called, so migrations are safe to re-run on every
6
+ * backend connect.
7
+ *
8
+ * Add new migrations at the bottom of MIGRATIONS — never reorder or remove
9
+ * existing entries (future migrations may depend on prior state).
10
+ */
11
+ import type { Database } from 'better-sqlite3';
12
+ export interface Migration {
13
+ readonly id: string;
14
+ readonly description: string;
15
+ pending(db: Database): boolean;
16
+ run(db: Database): void;
17
+ }
18
+ export interface MigrationResult {
19
+ id: string;
20
+ description: string;
21
+ status: 'applied' | 'skipped' | 'failed';
22
+ error?: string;
23
+ }
24
+ export declare const MIGRATIONS: readonly Migration[];
25
+ /**
26
+ * Apply pending migrations to a database.
27
+ *
28
+ * With strict: true (default), throws immediately on the first migration
29
+ * failure — the database is in an unknown state and startup should abort.
30
+ *
31
+ * With strict: false, records the error and continues — useful for the CLI
32
+ * migrate subcommand which wants to report all results before exiting.
33
+ */
34
+ export declare function runMigrations(db: Database, opts?: {
35
+ strict?: boolean;
36
+ }): MigrationResult[];
37
+ /** Return migrations that have not yet been applied, without touching the DB. */
38
+ export declare function pendingMigrations(db: Database): readonly Migration[];
@@ -0,0 +1,157 @@
1
+ function hasColumn(db, table, column) {
2
+ const info = db.pragma(`table_info(${table})`);
3
+ return info.some((c) => c.name === column);
4
+ }
5
+ function hasIndex(db, table, name) {
6
+ const info = db.pragma(`index_list(${table})`);
7
+ return info.some((i) => i.name === name);
8
+ }
9
+ function hasTable(db, table) {
10
+ const row = db
11
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
12
+ .get(table);
13
+ return row !== undefined;
14
+ }
15
+ export const MIGRATIONS = [
16
+ {
17
+ id: 'add_archived',
18
+ description: 'Add archived column for soft-delete/archive tier (SLE-90)',
19
+ pending: (db) => !hasColumn(db, 'memories', 'archived'),
20
+ run: (db) => {
21
+ db.prepare('ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0').run();
22
+ },
23
+ },
24
+ {
25
+ id: 'add_archive_note',
26
+ description: 'Add archive_note column for tombstone data (SLE-90)',
27
+ pending: (db) => !hasColumn(db, 'memories', 'archive_note'),
28
+ run: (db) => {
29
+ db.prepare('ALTER TABLE memories ADD COLUMN archive_note TEXT').run();
30
+ },
31
+ },
32
+ {
33
+ id: 'idx_memories_archived',
34
+ description: 'Index memories.archived for query performance',
35
+ pending: (db) => !hasIndex(db, 'memories', 'idx_memories_archived'),
36
+ run: (db) => {
37
+ db.prepare('CREATE INDEX idx_memories_archived ON memories(archived)').run();
38
+ },
39
+ },
40
+ {
41
+ id: 'add_salience',
42
+ description: 'Add salience temperature for the tiered boot digest (it-loom-salience)',
43
+ pending: (db) => !hasColumn(db, 'memories', 'salience'),
44
+ run: (db) => {
45
+ // Fresh column; new writes set 1.0 (hot), the consolidation lane recomputes
46
+ // existing rows from their timestamps. Default 0 = cold until first refresh.
47
+ db.prepare('ALTER TABLE memories ADD COLUMN salience REAL NOT NULL DEFAULT 0').run();
48
+ },
49
+ },
50
+ {
51
+ id: 'add_proposals',
52
+ description: 'Add proposals staging table for the capture-propose queue — drafts a ' +
53
+ 'background lane stages for ratification; NOT authored canon until ratified',
54
+ pending: (db) => !hasTable(db, 'proposals'),
55
+ run: (db) => {
56
+ // The staging area for the capture-propose queue. A proposal is NOT an
57
+ // authored memory: it lives in its own table, invisible to recall / list /
58
+ // salience / find_similar, and only becomes a real memory via an explicit
59
+ // ratify (which routes through remember() so it is validated like any write).
60
+ db.prepare(`
61
+ CREATE TABLE proposals (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ uuid TEXT NOT NULL UNIQUE,
64
+ category TEXT,
65
+ title TEXT,
66
+ content TEXT,
67
+ project TEXT,
68
+ ttl TEXT,
69
+ metadata TEXT DEFAULT '{}',
70
+ source TEXT,
71
+ created TEXT,
72
+ status TEXT NOT NULL DEFAULT 'pending'
73
+ )
74
+ `).run();
75
+ },
76
+ },
77
+ {
78
+ id: 'add_memory_revisions',
79
+ description: 'Add memory_revisions table — snapshots displaced body on every update(), ' +
80
+ 'capped at 10 per memory (parity with knowledge wing page_revisions, t-327)',
81
+ pending: (db) => !hasTable(db, 'memory_revisions'),
82
+ run: (db) => {
83
+ // Body snapshots retained per memory; oldest pruned beyond this cap.
84
+ // op codes: 'update' (displaced by update()) | 'revision-restore' (displaced
85
+ // by a rollback to a prior snapshot).
86
+ db.prepare(`
87
+ CREATE TABLE memory_revisions (
88
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
89
+ memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
90
+ ref TEXT NOT NULL,
91
+ content TEXT NOT NULL,
92
+ op TEXT NOT NULL,
93
+ replaced_at TEXT NOT NULL
94
+ )
95
+ `).run();
96
+ db.prepare('CREATE INDEX idx_memory_revisions_memory ON memory_revisions(memory_id)').run();
97
+ },
98
+ },
99
+ {
100
+ id: 'add_memory_supersessions',
101
+ description: 'Add memory_supersessions table — records that new_ref replaced old_ref, ' +
102
+ 'so the supersession edge survives archive (parity with knowledge wing, t-327)',
103
+ pending: (db) => !hasTable(db, 'memory_supersessions'),
104
+ run: (db) => {
105
+ // A supersession is an explicit durable link: old_ref was retired because
106
+ // new_ref is the canonical replacement. old_ref is archived by the supersede()
107
+ // call; this table is the record that B replaced A (survives forever, refs
108
+ // are strings so they outlive archive/restore cycles).
109
+ db.prepare(`
110
+ CREATE TABLE memory_supersessions (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ old_ref TEXT NOT NULL,
113
+ new_ref TEXT NOT NULL,
114
+ note TEXT,
115
+ created TEXT NOT NULL
116
+ )
117
+ `).run();
118
+ db.prepare('CREATE INDEX idx_memory_supersessions_old ON memory_supersessions(old_ref)').run();
119
+ db.prepare('CREATE INDEX idx_memory_supersessions_new ON memory_supersessions(new_ref)').run();
120
+ },
121
+ },
122
+ ];
123
+ /**
124
+ * Apply pending migrations to a database.
125
+ *
126
+ * With strict: true (default), throws immediately on the first migration
127
+ * failure — the database is in an unknown state and startup should abort.
128
+ *
129
+ * With strict: false, records the error and continues — useful for the CLI
130
+ * migrate subcommand which wants to report all results before exiting.
131
+ */
132
+ export function runMigrations(db, opts = {}) {
133
+ const strict = opts.strict ?? true;
134
+ const results = [];
135
+ for (const m of MIGRATIONS) {
136
+ if (!m.pending(db)) {
137
+ results.push({ id: m.id, description: m.description, status: 'skipped' });
138
+ continue;
139
+ }
140
+ try {
141
+ m.run(db);
142
+ results.push({ id: m.id, description: m.description, status: 'applied' });
143
+ }
144
+ catch (err) {
145
+ const error = err instanceof Error ? err.message : String(err);
146
+ if (strict) {
147
+ throw new Error(`Migration '${m.id}' failed: ${error}`);
148
+ }
149
+ results.push({ id: m.id, description: m.description, status: 'failed', error });
150
+ }
151
+ }
152
+ return results;
153
+ }
154
+ /** Return migrations that have not yet been applied, without touching the DB. */
155
+ export function pendingMigrations(db) {
156
+ return MIGRATIONS.filter((m) => m.pending(db));
157
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Maximal Marginal Relevance (MMR) re-ranking for recall.
3
+ *
4
+ * A well-covered topic in a store of hundreds of memories yields a top-k
5
+ * that is mostly the same fact restated. MMR trades a little relevance for
6
+ * coverage: each pick maximises
7
+ *
8
+ * score(c) = λ·relevance(c) − (1−λ)·max_{s ∈ selected} sim(c, s)
9
+ *
10
+ * greedily until `limit` results are chosen. `sim` is cosine similarity
11
+ * between the candidates' stored embedding vectors, so "near-duplicate" is
12
+ * judged in the same space the search ranked in — not by text overlap.
13
+ *
14
+ * The knob exposed to callers is `diversity = 1 − λ` (0..1). At 0 the
15
+ * candidates come back in their original relevance order, untouched. The
16
+ * idea is borrowed from xai-org/grok-build's memory search (Apache-2.0;
17
+ * idea only, no code).
18
+ */
19
+ export interface MmrCandidate {
20
+ /** Search relevance, higher is better (loom uses 1 − cosine distance). */
21
+ relevance: number;
22
+ /** The candidate's embedding, used for pairwise similarity. */
23
+ vector: ArrayLike<number>;
24
+ }
25
+ export interface MmrResult<T> {
26
+ /** Chosen candidates in selection order (first pick = most relevant). */
27
+ selected: T[];
28
+ /**
29
+ * How many of the top-`limit` candidates by relevance were displaced by
30
+ * a less-relevant-but-different one. 0 means MMR changed nothing.
31
+ */
32
+ diversityDrops: number;
33
+ }
34
+ /** Default `diversity` (1 − λ) for recall: λ = 0.7. */
35
+ export declare const DEFAULT_DIVERSITY = 0.3;
36
+ export declare function cosineSimilarity(a: ArrayLike<number>, b: ArrayLike<number>): number;
37
+ /**
38
+ * Greedy MMR selection over a relevance-ordered candidate pool.
39
+ *
40
+ * Leaves the pool untouched (returns the first `limit` as-is) when
41
+ * `diversity` is 0 or there are no more candidates than `limit` — there is
42
+ * nothing to trade off in either case, and callers rely on the old
43
+ * ordering surviving both.
44
+ */
45
+ export declare function mmrSelect<T extends MmrCandidate>(candidates: readonly T[], limit: number, diversity?: number): MmrResult<T>;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Maximal Marginal Relevance (MMR) re-ranking for recall.
3
+ *
4
+ * A well-covered topic in a store of hundreds of memories yields a top-k
5
+ * that is mostly the same fact restated. MMR trades a little relevance for
6
+ * coverage: each pick maximises
7
+ *
8
+ * score(c) = λ·relevance(c) − (1−λ)·max_{s ∈ selected} sim(c, s)
9
+ *
10
+ * greedily until `limit` results are chosen. `sim` is cosine similarity
11
+ * between the candidates' stored embedding vectors, so "near-duplicate" is
12
+ * judged in the same space the search ranked in — not by text overlap.
13
+ *
14
+ * The knob exposed to callers is `diversity = 1 − λ` (0..1). At 0 the
15
+ * candidates come back in their original relevance order, untouched. The
16
+ * idea is borrowed from xai-org/grok-build's memory search (Apache-2.0;
17
+ * idea only, no code).
18
+ */
19
+ /** Default `diversity` (1 − λ) for recall: λ = 0.7. */
20
+ export const DEFAULT_DIVERSITY = 0.3;
21
+ export function cosineSimilarity(a, b) {
22
+ const n = Math.min(a.length, b.length);
23
+ let dot = 0;
24
+ let na = 0;
25
+ let nb = 0;
26
+ for (let i = 0; i < n; i++) {
27
+ dot += a[i] * b[i];
28
+ na += a[i] * a[i];
29
+ nb += b[i] * b[i];
30
+ }
31
+ if (na === 0 || nb === 0)
32
+ return 0;
33
+ return dot / Math.sqrt(na * nb);
34
+ }
35
+ /**
36
+ * Greedy MMR selection over a relevance-ordered candidate pool.
37
+ *
38
+ * Leaves the pool untouched (returns the first `limit` as-is) when
39
+ * `diversity` is 0 or there are no more candidates than `limit` — there is
40
+ * nothing to trade off in either case, and callers rely on the old
41
+ * ordering surviving both.
42
+ */
43
+ export function mmrSelect(candidates, limit, diversity = DEFAULT_DIVERSITY) {
44
+ if (!(limit > 0))
45
+ return { selected: [], diversityDrops: 0 };
46
+ if (!(diversity > 0) || candidates.length <= limit) {
47
+ return { selected: candidates.slice(0, limit), diversityDrops: 0 };
48
+ }
49
+ const lambda = 1 - Math.min(diversity, 1);
50
+ const remaining = candidates.map((_, i) => i);
51
+ const chosen = [];
52
+ // maxSim[i]: similarity of candidate i to the closest already-selected item.
53
+ const maxSim = new Float64Array(candidates.length);
54
+ while (chosen.length < limit && remaining.length > 0) {
55
+ let best = -1;
56
+ let bestScore = Number.NEGATIVE_INFINITY;
57
+ for (const i of remaining) {
58
+ const penalty = chosen.length === 0 ? 0 : maxSim[i];
59
+ const score = lambda * candidates[i].relevance - (1 - lambda) * penalty;
60
+ // Strict > keeps the earlier (more relevant) candidate on ties.
61
+ if (score > bestScore) {
62
+ bestScore = score;
63
+ best = i;
64
+ }
65
+ }
66
+ chosen.push(best);
67
+ remaining.splice(remaining.indexOf(best), 1);
68
+ const pickedVec = candidates[best].vector;
69
+ for (const i of remaining) {
70
+ const s = cosineSimilarity(candidates[i].vector, pickedVec);
71
+ if (s > maxSim[i])
72
+ maxSim[i] = s;
73
+ }
74
+ }
75
+ const topByRelevance = candidates
76
+ .map((_, i) => i)
77
+ .sort((a, b) => candidates[b].relevance - candidates[a].relevance || a - b)
78
+ .slice(0, limit);
79
+ const chosenSet = new Set(chosen);
80
+ const diversityDrops = topByRelevance.filter((i) => !chosenSet.has(i)).length;
81
+ return { selected: chosen.map((i) => candidates[i]), diversityDrops };
82
+ }
@@ -0,0 +1,65 @@
1
+ import type { MemoryRef } from './types.js';
2
+ export interface ProposalInput {
3
+ category: string;
4
+ title: string;
5
+ content: string;
6
+ project?: string;
7
+ ttl?: string;
8
+ metadata?: Record<string, unknown>;
9
+ /** Where the proposal came from, e.g. a lane name. */
10
+ source?: string;
11
+ }
12
+ export interface ProposalRow {
13
+ id: number;
14
+ uuid: string;
15
+ category: string | null;
16
+ title: string | null;
17
+ content: string | null;
18
+ project: string | null;
19
+ ttl: string | null;
20
+ metadata: string;
21
+ source: string | null;
22
+ created: string;
23
+ status: string;
24
+ }
25
+ export interface ProposalRef {
26
+ id: number;
27
+ uuid: string;
28
+ }
29
+ /** Optional Art-on-accept edits applied at ratify time. */
30
+ export interface ProposalOverrides {
31
+ title?: string;
32
+ content?: string;
33
+ category?: string;
34
+ project?: string;
35
+ ttl?: string;
36
+ }
37
+ /** Thrown when a proposal id can't be found in the queue. */
38
+ export declare class UnknownProposalError extends Error {
39
+ constructor(id: number);
40
+ }
41
+ /**
42
+ * Stage a pending proposal. A draft can be rough, so this does NOT run the
43
+ * full validateMemoryInput gate — but it DOES require a non-empty
44
+ * title + content + category so the row is at least shaped like a memory.
45
+ * Hard validation is deferred to ratify.
46
+ */
47
+ export declare function propose(contextDir: string, input: ProposalInput): ProposalRef;
48
+ /** All pending proposals, newest first. */
49
+ export declare function listProposals(contextDir: string): ProposalRow[];
50
+ /** Fetch a single proposal by id (any status), or null if not found. */
51
+ export declare function getProposal(contextDir: string, id: number): ProposalRow | null;
52
+ /** Delete a pending proposal. Returns false if no pending row with that id. */
53
+ export declare function rejectProposal(contextDir: string, id: number): boolean;
54
+ /**
55
+ * Ratify a pending proposal into a REAL memory.
56
+ *
57
+ * Loads the proposal, applies optional Art-on-accept overrides, then commits it
58
+ * via the existing remember() path — so validateMemoryInput runs. If the
59
+ * resulting memory is invalid, remember() throws its typed reason, NOTHING is
60
+ * committed, and the proposal row STAYS pending (the throw propagates before the
61
+ * delete). On success the memory is authored and the proposal row is removed.
62
+ *
63
+ * Throws UnknownProposalError if the id isn't in the queue.
64
+ */
65
+ export declare function ratifyProposal(contextDir: string, id: number, overrides?: ProposalOverrides): Promise<MemoryRef>;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Capture-propose queue — the loom-side staging area for drafted memory writes.
3
+ *
4
+ * THE INTEGRITY INVARIANT: a proposal is NOT authored canon. Proposals live in
5
+ * a SEPARATE table (`proposals`) and are INVISIBLE to recall / memory_list / the
6
+ * salience digest / find_similar. A background lane drafts memory writes here;
7
+ * Art ratifies them before they become real memories. There is no auto-commit —
8
+ * a proposal becomes canon only via an explicit `ratifyProposal`, which routes
9
+ * through the existing remember() tool path so it is validated like any write.
10
+ *
11
+ * This keeps loom's "one writer, authored" model intact while getting
12
+ * auto-capture ergonomics: drafts can be rough; ratification is the gate.
13
+ *
14
+ * The store is opened raw (like salience.ts) — no embedder needed for the
15
+ * staging table itself. Embedding happens at ratify-time, through remember().
16
+ */
17
+ import { randomUUID } from 'node:crypto';
18
+ import { existsSync } from 'node:fs';
19
+ import BetterSqlite3 from 'better-sqlite3';
20
+ import { resolveSqliteDbPath } from '../config.js';
21
+ import { runMigrations } from './migrations.js';
22
+ import { remember } from '../tools/remember.js';
23
+ import { retryWrite } from './retry.js';
24
+ /** Thrown when a proposal id can't be found in the queue. */
25
+ export class UnknownProposalError extends Error {
26
+ constructor(id) {
27
+ super(`proposal not found: ${id}`);
28
+ this.name = 'UnknownProposalError';
29
+ }
30
+ }
31
+ // ─── DB plumbing ──────────────────────────────────────────────────────────────
32
+ function hasTable(db, table) {
33
+ const row = db
34
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
35
+ .get(table);
36
+ return row !== undefined;
37
+ }
38
+ /**
39
+ * Open memories.db raw (no vector/embedder stack) and ensure the proposals
40
+ * table exists. Self-heals a store that predates the proposals migration —
41
+ * migrations are idempotent, so this is safe on any store.
42
+ */
43
+ function openProposalsDb(contextDir) {
44
+ const dbPath = resolveSqliteDbPath(contextDir);
45
+ const db = new BetterSqlite3(dbPath);
46
+ db.pragma('busy_timeout = 0');
47
+ if (!hasTable(db, 'proposals'))
48
+ runMigrations(db, { strict: false });
49
+ return db;
50
+ }
51
+ // ─── Queue operations ─────────────────────────────────────────────────────────
52
+ /**
53
+ * Stage a pending proposal. A draft can be rough, so this does NOT run the
54
+ * full validateMemoryInput gate — but it DOES require a non-empty
55
+ * title + content + category so the row is at least shaped like a memory.
56
+ * Hard validation is deferred to ratify.
57
+ */
58
+ export function propose(contextDir, input) {
59
+ if (typeof input.title !== 'string' || input.title.trim() === '') {
60
+ throw new Error('proposal title is required and cannot be empty');
61
+ }
62
+ if (typeof input.content !== 'string' || input.content.trim() === '') {
63
+ throw new Error('proposal content is required and cannot be empty');
64
+ }
65
+ if (typeof input.category !== 'string' || input.category.trim() === '') {
66
+ throw new Error('proposal category is required and cannot be empty');
67
+ }
68
+ const db = openProposalsDb(contextDir);
69
+ try {
70
+ const uuid = randomUUID();
71
+ const created = new Date().toISOString();
72
+ const result = retryWrite(() => db
73
+ .prepare(`
74
+ INSERT INTO proposals (
75
+ uuid, category, title, content, project, ttl, metadata, source, created, status
76
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
77
+ `)
78
+ .run(uuid, input.category, input.title, input.content, input.project ?? null, input.ttl ?? null, JSON.stringify(input.metadata ?? {}), input.source ?? null, created));
79
+ return { id: Number(result.lastInsertRowid), uuid };
80
+ }
81
+ finally {
82
+ db.close();
83
+ }
84
+ }
85
+ /** All pending proposals, newest first. */
86
+ export function listProposals(contextDir) {
87
+ const dbPath = resolveSqliteDbPath(contextDir);
88
+ if (!existsSync(dbPath))
89
+ return [];
90
+ const db = openProposalsDb(contextDir);
91
+ try {
92
+ return db
93
+ .prepare(`SELECT id, uuid, category, title, content, project, ttl, metadata, source, created, status
94
+ FROM proposals WHERE status = 'pending' ORDER BY id DESC`)
95
+ .all();
96
+ }
97
+ finally {
98
+ db.close();
99
+ }
100
+ }
101
+ /** Fetch a single proposal by id (any status), or null if not found. */
102
+ export function getProposal(contextDir, id) {
103
+ const dbPath = resolveSqliteDbPath(contextDir);
104
+ if (!existsSync(dbPath))
105
+ return null;
106
+ const db = openProposalsDb(contextDir);
107
+ try {
108
+ const row = db
109
+ .prepare(`SELECT id, uuid, category, title, content, project, ttl, metadata, source, created, status
110
+ FROM proposals WHERE id = ?`)
111
+ .get(id);
112
+ return row ?? null;
113
+ }
114
+ finally {
115
+ db.close();
116
+ }
117
+ }
118
+ /** Delete a pending proposal. Returns false if no pending row with that id. */
119
+ export function rejectProposal(contextDir, id) {
120
+ const dbPath = resolveSqliteDbPath(contextDir);
121
+ if (!existsSync(dbPath))
122
+ return false;
123
+ const db = openProposalsDb(contextDir);
124
+ try {
125
+ const result = retryWrite(() => db
126
+ .prepare("DELETE FROM proposals WHERE id = ? AND status = 'pending'")
127
+ .run(id));
128
+ return result.changes > 0;
129
+ }
130
+ finally {
131
+ db.close();
132
+ }
133
+ }
134
+ /**
135
+ * Ratify a pending proposal into a REAL memory.
136
+ *
137
+ * Loads the proposal, applies optional Art-on-accept overrides, then commits it
138
+ * via the existing remember() path — so validateMemoryInput runs. If the
139
+ * resulting memory is invalid, remember() throws its typed reason, NOTHING is
140
+ * committed, and the proposal row STAYS pending (the throw propagates before the
141
+ * delete). On success the memory is authored and the proposal row is removed.
142
+ *
143
+ * Throws UnknownProposalError if the id isn't in the queue.
144
+ */
145
+ export async function ratifyProposal(contextDir, id, overrides = {}) {
146
+ const proposal = getProposal(contextDir, id);
147
+ if (!proposal)
148
+ throw new UnknownProposalError(id);
149
+ const metadata = parseMetadata(proposal.metadata);
150
+ // remember() validates (validateMemoryInput) and throws on an invalid write
151
+ // BEFORE anything is committed — so an invalid proposal is refused with its
152
+ // typed reason and, because the throw happens before the delete below, the
153
+ // proposal stays pending for a later fix.
154
+ const ref = await remember(contextDir, {
155
+ category: overrides.category ?? proposal.category ?? '',
156
+ title: overrides.title ?? proposal.title ?? '',
157
+ content: overrides.content ?? proposal.content ?? '',
158
+ project: overrides.project ?? proposal.project ?? undefined,
159
+ ttl: overrides.ttl ?? proposal.ttl ?? undefined,
160
+ metadata,
161
+ });
162
+ // Committed as canon — drop the staging row.
163
+ const db = openProposalsDb(contextDir);
164
+ try {
165
+ retryWrite(() => db.prepare('DELETE FROM proposals WHERE id = ?').run(id));
166
+ }
167
+ finally {
168
+ db.close();
169
+ }
170
+ return ref;
171
+ }
172
+ function parseMetadata(raw) {
173
+ if (!raw)
174
+ return {};
175
+ try {
176
+ const parsed = JSON.parse(raw);
177
+ return parsed && typeof parsed === 'object' ? parsed : {};
178
+ }
179
+ catch {
180
+ return {};
181
+ }
182
+ }
@@ -0,0 +1,42 @@
1
+ import type { RecallObservation } from './types.js';
2
+ export declare const RECALL_LOG_FILE = "recall.jsonl";
3
+ export declare const QUERY_MAX_CHARS = 120;
4
+ export declare function recallLogPath(contextDir: string): string;
5
+ /** `LOOM_RECALL_LOG=0|off|false|no` disables the log; anything else (or unset) keeps it on. */
6
+ export declare function isRecallLogEnabled(env?: NodeJS.ProcessEnv): boolean;
7
+ /**
8
+ * Append one observation. Synchronous and cheap (one appendFileSync);
9
+ * creates the telemetry dir on first write; swallows every error.
10
+ */
11
+ export declare function appendRecallObservation(contextDir: string, obs: RecallObservation, env?: NodeJS.ProcessEnv): void;
12
+ /** Read observations, oldest first. Malformed lines are skipped. */
13
+ export declare function readRecallObservations(contextDir: string, opts?: {
14
+ since?: Date;
15
+ }): RecallObservation[];
16
+ /** Parse a `--since` window like "30m", "24h", "7d", "2w" into milliseconds. */
17
+ export declare function parseSinceDuration(spec: string): number;
18
+ export interface RecallMiss {
19
+ ts: string;
20
+ tool: RecallObservation['tool'];
21
+ query: string;
22
+ category?: string;
23
+ project?: string;
24
+ candidates: number;
25
+ }
26
+ export interface RecallStats {
27
+ searches: number;
28
+ hits: number;
29
+ misses: number;
30
+ /** hits / searches, or null when there were no searches. */
31
+ hitRate: number | null;
32
+ medianLatencyMs: number | null;
33
+ /** Median of topScore over hits, or null when there were none. */
34
+ medianTopScore: number | null;
35
+ diversityDrops: number;
36
+ /** Newest first, capped at `recentMisses` (default 10). */
37
+ recentMisses: RecallMiss[];
38
+ }
39
+ export declare function summarizeRecallObservations(rows: RecallObservation[], opts?: {
40
+ recentMisses?: number;
41
+ }): RecallStats;
42
+ export declare function formatRecallStats(stats: RecallStats, windowLabel: string): string;