@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,143 @@
1
+ /**
2
+ * Recall observation log — local-only telemetry for memory search.
3
+ *
4
+ * One JSON line per search, appended to `<contextDir>/telemetry/recall.jsonl`.
5
+ * Nothing leaves the machine: it is a file in the agent's own context dir,
6
+ * kept so `loom memory recall-stats` can answer "does recall actually
7
+ * work?" — hit rate, latency, score distribution, what missed. Set
8
+ * `LOOM_RECALL_LOG=0` to turn it off.
9
+ *
10
+ * Writes never throw. A failed write must never fail a search.
11
+ */
12
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
13
+ import { resolve } from 'node:path';
14
+ import { resolveTelemetryDir } from '../config.js';
15
+ export const RECALL_LOG_FILE = 'recall.jsonl';
16
+ export const QUERY_MAX_CHARS = 120;
17
+ export function recallLogPath(contextDir) {
18
+ return resolve(resolveTelemetryDir(contextDir), RECALL_LOG_FILE);
19
+ }
20
+ /** `LOOM_RECALL_LOG=0|off|false|no` disables the log; anything else (or unset) keeps it on. */
21
+ export function isRecallLogEnabled(env = process.env) {
22
+ const v = env.LOOM_RECALL_LOG?.trim().toLowerCase();
23
+ return !(v === '0' || v === 'off' || v === 'false' || v === 'no');
24
+ }
25
+ /**
26
+ * Append one observation. Synchronous and cheap (one appendFileSync);
27
+ * creates the telemetry dir on first write; swallows every error.
28
+ */
29
+ export function appendRecallObservation(contextDir, obs, env = process.env) {
30
+ if (!isRecallLogEnabled(env))
31
+ return;
32
+ try {
33
+ const dir = resolveTelemetryDir(contextDir);
34
+ if (!existsSync(dir))
35
+ mkdirSync(dir, { recursive: true });
36
+ const line = { ...obs, query: obs.query.slice(0, QUERY_MAX_CHARS) };
37
+ appendFileSync(resolve(dir, RECALL_LOG_FILE), JSON.stringify(line) + '\n', 'utf-8');
38
+ }
39
+ catch {
40
+ // Telemetry is best-effort by contract: never let it fail a search.
41
+ }
42
+ }
43
+ /** Read observations, oldest first. Malformed lines are skipped. */
44
+ export function readRecallObservations(contextDir, opts = {}) {
45
+ const path = recallLogPath(contextDir);
46
+ if (!existsSync(path))
47
+ return [];
48
+ let raw;
49
+ try {
50
+ raw = readFileSync(path, 'utf-8');
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ const sinceMs = opts.since?.getTime();
56
+ const out = [];
57
+ for (const line of raw.split('\n')) {
58
+ if (!line.trim())
59
+ continue;
60
+ let obs;
61
+ try {
62
+ obs = JSON.parse(line);
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ if (typeof obs !== 'object' || obs === null || typeof obs.ts !== 'string')
68
+ continue;
69
+ if (sinceMs !== undefined && Date.parse(obs.ts) < sinceMs)
70
+ continue;
71
+ out.push(obs);
72
+ }
73
+ return out;
74
+ }
75
+ const SINCE_RE = /^(\d+)(m|h|d|w)$/;
76
+ /** Parse a `--since` window like "30m", "24h", "7d", "2w" into milliseconds. */
77
+ export function parseSinceDuration(spec) {
78
+ const m = spec.trim().match(SINCE_RE);
79
+ if (!m)
80
+ throw new Error(`Invalid --since "${spec}". Use e.g. 30m, 24h, 7d, 2w.`);
81
+ const n = Number.parseInt(m[1], 10);
82
+ switch (m[2]) {
83
+ case 'm': return n * 60_000;
84
+ case 'h': return n * 3_600_000;
85
+ case 'd': return n * 86_400_000;
86
+ default: return n * 7 * 86_400_000;
87
+ }
88
+ }
89
+ function median(values) {
90
+ if (values.length === 0)
91
+ return null;
92
+ const sorted = [...values].sort((a, b) => a - b);
93
+ const mid = sorted.length >> 1;
94
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
95
+ }
96
+ export function summarizeRecallObservations(rows, opts = {}) {
97
+ const cap = opts.recentMisses ?? 10;
98
+ const hits = rows.filter((r) => r.hit);
99
+ const misses = rows.filter((r) => !r.hit);
100
+ return {
101
+ searches: rows.length,
102
+ hits: hits.length,
103
+ misses: misses.length,
104
+ hitRate: rows.length === 0 ? null : hits.length / rows.length,
105
+ medianLatencyMs: median(rows.map((r) => r.latencyMs).filter(Number.isFinite)),
106
+ medianTopScore: median(hits.map((r) => r.topScore).filter((s) => typeof s === 'number' && Number.isFinite(s))),
107
+ diversityDrops: rows.reduce((s, r) => s + (r.diversityDrops || 0), 0),
108
+ recentMisses: misses
109
+ .slice(-cap)
110
+ .reverse()
111
+ .map((r) => ({
112
+ ts: r.ts,
113
+ tool: r.tool,
114
+ query: r.query,
115
+ category: r.category,
116
+ project: r.project,
117
+ candidates: r.candidates,
118
+ })),
119
+ };
120
+ }
121
+ export function formatRecallStats(stats, windowLabel) {
122
+ const pct = (x) => (x === null ? 'n/a' : `${Math.round(x * 100)}%`);
123
+ const num = (x, digits, unit = '') => x === null ? 'n/a' : `${x.toFixed(digits)}${unit}`;
124
+ const lines = [
125
+ `Recall observations (last ${windowLabel}): ${stats.searches} search${stats.searches === 1 ? '' : 'es'}`,
126
+ ];
127
+ if (stats.searches === 0) {
128
+ lines.push(' (nothing logged in this window)');
129
+ return lines.join('\n');
130
+ }
131
+ lines.push(` hit rate ${stats.hits}/${stats.searches} (${pct(stats.hitRate)})`, ` median latency ${num(stats.medianLatencyMs, 0, ' ms')}`, ` median topScore ${num(stats.medianTopScore, 3)} (hits only)`, ` diversity drops ${stats.diversityDrops} candidate${stats.diversityDrops === 1 ? '' : 's'} displaced by MMR`);
132
+ if (stats.recentMisses.length > 0) {
133
+ lines.push(`Recent misses (newest first):`);
134
+ for (const m of stats.recentMisses) {
135
+ const scope = [
136
+ m.category ? `category=${m.category}` : null,
137
+ m.project ? `project=${m.project}` : null,
138
+ ].filter(Boolean).join(' ');
139
+ lines.push(` ${m.ts.slice(0, 16).replace('T', ' ')} "${m.query}"${scope ? ` [${scope}]` : ''}`);
140
+ }
141
+ }
142
+ return lines.join('\n');
143
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * SQLITE_BUSY retry ladder with exponential backoff + jitter.
3
+ *
4
+ * better-sqlite3 is synchronous; these retries are synchronous too.
5
+ * Atomics.wait() provides a blocking sleep without busy-spinning — available
6
+ * on the main thread in Node >= 22 (our minimum engine requirement).
7
+ *
8
+ * Reference design: Memori core/src/storage/manager.rs::write_batch —
9
+ * retries SQLSTATE 40001 with exponential backoff + up to 50% jitter,
10
+ * folding commit() into the retried result. t-326.
11
+ */
12
+ export interface RetryWriteOptions {
13
+ /** Maximum number of attempts (including the first). Default: 5. */
14
+ maxAttempts?: number;
15
+ /** Base delay in ms before the first retry; doubles each round. Default: 10. */
16
+ baseMs?: number;
17
+ }
18
+ /**
19
+ * Run fn(), retrying on SQLITE_BUSY with exponential back-off + up-to-50%
20
+ * random jitter. Any other error is rethrown immediately. SQLITE_BUSY after
21
+ * all attempts are exhausted is also rethrown.
22
+ *
23
+ * Delays: attempt 1→2 = baseMs, 2→3 = baseMs*2, … each + [0, delay*0.5)
24
+ * jitter. With defaults (maxAttempts=5, baseMs=10ms): 10, 20, 40, 80ms
25
+ * before each retry (plus jitter), worst-case ~220ms total wait.
26
+ */
27
+ export declare function retryWrite<T>(fn: () => T, opts?: RetryWriteOptions): T;
28
+ /** Synchronous sleep using Atomics.wait — blocks the thread without busy-spinning. */
29
+ export declare function sleepMs(ms: number): void;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * SQLITE_BUSY retry ladder with exponential backoff + jitter.
3
+ *
4
+ * better-sqlite3 is synchronous; these retries are synchronous too.
5
+ * Atomics.wait() provides a blocking sleep without busy-spinning — available
6
+ * on the main thread in Node >= 22 (our minimum engine requirement).
7
+ *
8
+ * Reference design: Memori core/src/storage/manager.rs::write_batch —
9
+ * retries SQLSTATE 40001 with exponential backoff + up to 50% jitter,
10
+ * folding commit() into the retried result. t-326.
11
+ */
12
+ /**
13
+ * Run fn(), retrying on SQLITE_BUSY with exponential back-off + up-to-50%
14
+ * random jitter. Any other error is rethrown immediately. SQLITE_BUSY after
15
+ * all attempts are exhausted is also rethrown.
16
+ *
17
+ * Delays: attempt 1→2 = baseMs, 2→3 = baseMs*2, … each + [0, delay*0.5)
18
+ * jitter. With defaults (maxAttempts=5, baseMs=10ms): 10, 20, 40, 80ms
19
+ * before each retry (plus jitter), worst-case ~220ms total wait.
20
+ */
21
+ export function retryWrite(fn, opts) {
22
+ const maxAttempts = opts?.maxAttempts ?? 5;
23
+ const baseMs = opts?.baseMs ?? 10;
24
+ let lastErr;
25
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
26
+ try {
27
+ return fn();
28
+ }
29
+ catch (err) {
30
+ if (isSqliteBusy(err)) {
31
+ lastErr = err;
32
+ if (attempt < maxAttempts - 1) {
33
+ const delay = baseMs * Math.pow(2, attempt);
34
+ const jitter = delay * Math.random() * 0.5;
35
+ sleepMs(Math.round(delay + jitter));
36
+ }
37
+ continue;
38
+ }
39
+ // Non-SQLITE_BUSY errors propagate immediately — never retry them.
40
+ throw err;
41
+ }
42
+ }
43
+ // Exhausted all attempts — rethrow the last SQLITE_BUSY.
44
+ throw lastErr;
45
+ }
46
+ function isSqliteBusy(err) {
47
+ if (!(err instanceof Error))
48
+ return false;
49
+ const e = err;
50
+ // better-sqlite3 sets .code = 'SQLITE_BUSY' on lock-contention errors.
51
+ return e.code === 'SQLITE_BUSY' || e.message.includes('SQLITE_BUSY');
52
+ }
53
+ /** Synchronous sleep using Atomics.wait — blocks the thread without busy-spinning. */
54
+ export function sleepMs(ms) {
55
+ if (ms <= 0)
56
+ return;
57
+ // Node >= 22 allows Atomics.wait on the main thread; SharedArrayBuffer is
58
+ // available without flags. This is the canonical sync sleep in Node.js.
59
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
60
+ }
@@ -0,0 +1,57 @@
1
+ import { type Database } from 'better-sqlite3';
2
+ /** Per-category half-life in days: hot working categories cool fast, identity-level facts cool slow. */
3
+ export declare const HALF_LIVES: Record<string, number>;
4
+ export declare const DEFAULT_HALF_LIFE_DAYS = 30;
5
+ /**
6
+ * Temperature in (0, 1]: 1.0 at the moment of last touch, 0.5 after one
7
+ * category half-life, decaying exponentially after. `lastTouchIso` is the most
8
+ * recent of last_accessed / updated / created.
9
+ */
10
+ export declare function temperature(lastTouchIso: string, category: string, nowMs: number): number;
11
+ export type Tier = 'Hot' | 'Warm' | 'Cool';
12
+ export declare function tierOf(temp: number): Tier;
13
+ /**
14
+ * Recompute and STORE the salience scalar for every non-archived memory. Called
15
+ * by the consolidation lane (stored + lane-refresh, not computed-on-read).
16
+ * Returns the number of memories recomputed.
17
+ */
18
+ export declare function recomputeSalience(db: Database, nowMs: number): number;
19
+ export interface DigestRow {
20
+ title: string;
21
+ category: string;
22
+ content: string;
23
+ salience: number;
24
+ }
25
+ export interface DigestOptions {
26
+ /** Approximate token budget for the whole digest. */
27
+ tokenBudget?: number;
28
+ /** Rough chars-per-token for the budget estimate. */
29
+ charsPerToken?: number;
30
+ }
31
+ /**
32
+ * Assemble the boot digest from already-recomputed salience: order hottest-first,
33
+ * fill the token budget, group the included atoms under their tier headers. Pure
34
+ * selection + ordering of authored content — no generated prose, no persisted
35
+ * glue. Returns null when there is nothing to show.
36
+ */
37
+ export declare function assembleDigest(rows: DigestRow[], opts?: DigestOptions): string | null;
38
+ /** Lane entry point: recompute + store salience for a context. Returns count (or null if no store). */
39
+ export declare function recomputeSalienceForContext(contextDir: string, nowMs: number): number | null;
40
+ export interface DigestAtom {
41
+ title: string;
42
+ category: string;
43
+ content: string;
44
+ salience: number;
45
+ tier: Tier;
46
+ }
47
+ /**
48
+ * Structured salience digest for a UI widget: the hottest authored atoms,
49
+ * tier-labeled, ordered hot→cold, plus the total memory count. Same
50
+ * assemble-not-generate view as the text digest — just shaped for rendering.
51
+ */
52
+ export declare function digestData(contextDir: string, limit?: number): {
53
+ atoms: DigestAtom[];
54
+ total: number;
55
+ } | null;
56
+ /** Assemble the boot digest for a context from stored salience. Returns null if no store / nothing hot. */
57
+ export declare function digestForContext(contextDir: string, opts?: DigestOptions): string | null;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Salience temperature + the assembled boot digest (c-loom-memory: it-loom-salience).
3
+ *
4
+ * A memory's temperature is a stored scalar that DECAYS by a per-category
5
+ * half-life and REHEATS on access (recall stamps last_accessed; write/update
6
+ * stamps updated) — so a frequently-touched fact stays warm and an untouched one
7
+ * cools. The consolidation lane recomputes the stored field; the digest assembler
8
+ * fills a token budget HOTTEST-FIRST from existing authored atoms, labeled into
9
+ * tiers. It ASSEMBLES a view over authored memories — it never generates new
10
+ * prose (the integrity line: loom holds the pen, unlike Anthropic's native digest
11
+ * which is generated).
12
+ */
13
+ import { existsSync } from 'node:fs';
14
+ import BetterSqlite3 from 'better-sqlite3';
15
+ import { resolveSqliteDbPath } from '../config.js';
16
+ import { runMigrations } from './migrations.js';
17
+ import { EPISODE_CATEGORY } from '../categories.js';
18
+ import { retryWrite } from './retry.js';
19
+ function hasColumn(db, table, column) {
20
+ return db.pragma(`table_info(${table})`).some((c) => c.name === column);
21
+ }
22
+ /** Per-category half-life in days: hot working categories cool fast, identity-level facts cool slow. */
23
+ export const HALF_LIVES = {
24
+ episode: 1,
25
+ pursuit: 7,
26
+ project: 10,
27
+ self: 30,
28
+ feedback: 30,
29
+ reference: 45,
30
+ user: 90,
31
+ };
32
+ export const DEFAULT_HALF_LIFE_DAYS = 30;
33
+ const DAY_MS = 86_400_000;
34
+ /**
35
+ * Temperature in (0, 1]: 1.0 at the moment of last touch, 0.5 after one
36
+ * category half-life, decaying exponentially after. `lastTouchIso` is the most
37
+ * recent of last_accessed / updated / created.
38
+ */
39
+ export function temperature(lastTouchIso, category, nowMs) {
40
+ const ageDays = Math.max(0, (nowMs - Date.parse(lastTouchIso)) / DAY_MS);
41
+ const halfLife = HALF_LIVES[category] ?? DEFAULT_HALF_LIFE_DAYS;
42
+ return Math.pow(2, -ageDays / halfLife);
43
+ }
44
+ export function tierOf(temp) {
45
+ if (temp >= 0.6)
46
+ return 'Hot';
47
+ if (temp >= 0.2)
48
+ return 'Warm';
49
+ return 'Cool';
50
+ }
51
+ /** Most-recent-touch timestamp for a row (recall/write reheat through these). */
52
+ function lastTouch(r) {
53
+ return r.last_accessed ?? r.updated ?? r.created;
54
+ }
55
+ /**
56
+ * Recompute and STORE the salience scalar for every non-archived memory. Called
57
+ * by the consolidation lane (stored + lane-refresh, not computed-on-read).
58
+ * Returns the number of memories recomputed.
59
+ */
60
+ export function recomputeSalience(db, nowMs) {
61
+ const rows = db
62
+ .prepare('SELECT id, category, created, updated, last_accessed FROM memories WHERE archived = 0')
63
+ .all();
64
+ const upd = db.prepare('UPDATE memories SET salience = ? WHERE id = ?');
65
+ const tx = db.transaction(() => {
66
+ for (const r of rows)
67
+ upd.run(temperature(lastTouch(r), r.category, nowMs), r.id);
68
+ });
69
+ // The nightly full-table recompute is the heaviest write in loom; wrap it so
70
+ // a concurrent remember() call during the recompute retries instead of throwing.
71
+ retryWrite(() => tx());
72
+ return rows.length;
73
+ }
74
+ const DEFAULT_TOKEN_BUDGET = 1200;
75
+ const DEFAULT_CHARS_PER_TOKEN = 4;
76
+ /** One line per atom: a trimmed, single-line view — selected, never synthesized. */
77
+ function atomLine(r) {
78
+ const body = r.content.replace(/\s+/g, ' ').trim();
79
+ const clipped = body.length > 200 ? body.slice(0, 197).trimEnd() + '…' : body;
80
+ return `- **${r.title}** (${r.category}) — ${clipped}`;
81
+ }
82
+ /**
83
+ * Assemble the boot digest from already-recomputed salience: order hottest-first,
84
+ * fill the token budget, group the included atoms under their tier headers. Pure
85
+ * selection + ordering of authored content — no generated prose, no persisted
86
+ * glue. Returns null when there is nothing to show.
87
+ */
88
+ export function assembleDigest(rows, opts = {}) {
89
+ const budgetChars = (opts.tokenBudget ?? DEFAULT_TOKEN_BUDGET) * (opts.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN);
90
+ const ordered = [...rows].sort((a, b) => b.salience - a.salience);
91
+ const picked = [];
92
+ let used = 0;
93
+ for (const r of ordered) {
94
+ const line = atomLine(r);
95
+ if (used + line.length > budgetChars && picked.length > 0)
96
+ break; // hottest-first to the cap
97
+ picked.push({ tier: tierOf(r.salience), line });
98
+ used += line.length + 1;
99
+ }
100
+ if (picked.length === 0)
101
+ return null;
102
+ const tierOrder = ['Hot', 'Warm', 'Cool'];
103
+ const tierLabel = {
104
+ Hot: 'Top of mind',
105
+ Warm: 'Recent',
106
+ Cool: 'Background',
107
+ };
108
+ const sections = [];
109
+ for (const tier of tierOrder) {
110
+ const lines = picked.filter((p) => p.tier === tier).map((p) => p.line);
111
+ if (lines.length)
112
+ sections.push(`## ${tierLabel[tier]}\n${lines.join('\n')}`);
113
+ }
114
+ return sections.join('\n\n');
115
+ }
116
+ // ── context-dir wrappers (open memories.db directly; no embedder needed) ──
117
+ /** Open memories.db without the vector/embedder stack — salience is plain SQL. */
118
+ function openMemoriesDb(contextDir, readonly) {
119
+ const dbPath = resolveSqliteDbPath(contextDir);
120
+ if (!existsSync(dbPath))
121
+ return null;
122
+ const db = new BetterSqlite3(dbPath, { readonly });
123
+ db.pragma('busy_timeout = 0');
124
+ return db;
125
+ }
126
+ /** Lane entry point: recompute + store salience for a context. Returns count (or null if no store). */
127
+ export function recomputeSalienceForContext(contextDir, nowMs) {
128
+ const db = openMemoriesDb(contextDir, false);
129
+ if (!db)
130
+ return null;
131
+ try {
132
+ // Self-heal: a store that predates the salience migration gets it here, so
133
+ // the lane works on any store (migrations are idempotent).
134
+ if (!hasColumn(db, 'memories', 'salience'))
135
+ runMigrations(db, { strict: false });
136
+ return recomputeSalience(db, nowMs);
137
+ }
138
+ finally {
139
+ db.close();
140
+ }
141
+ }
142
+ /**
143
+ * Structured salience digest for a UI widget: the hottest authored atoms,
144
+ * tier-labeled, ordered hot→cold, plus the total memory count. Same
145
+ * assemble-not-generate view as the text digest — just shaped for rendering.
146
+ */
147
+ export function digestData(contextDir, limit = 40) {
148
+ const db = openMemoriesDb(contextDir, true);
149
+ if (!db)
150
+ return null;
151
+ try {
152
+ if (!hasColumn(db, 'memories', 'salience'))
153
+ return { atoms: [], total: 0 };
154
+ const total = db.prepare('SELECT COUNT(*) AS n FROM memories WHERE archived = 0').get().n;
155
+ const rows = db
156
+ .prepare('SELECT title, category, content, salience FROM memories WHERE archived = 0 AND category != ? ORDER BY salience DESC LIMIT ?')
157
+ .all(EPISODE_CATEGORY, limit);
158
+ const atoms = rows.map((r) => ({ ...r, tier: tierOf(r.salience) }));
159
+ return { atoms, total };
160
+ }
161
+ finally {
162
+ db.close();
163
+ }
164
+ }
165
+ /** Assemble the boot digest for a context from stored salience. Returns null if no store / nothing hot. */
166
+ export function digestForContext(contextDir, opts = {}) {
167
+ const db = openMemoriesDb(contextDir, true);
168
+ if (!db)
169
+ return null;
170
+ try {
171
+ // A store not yet migrated to salience simply has no digest yet (read-only
172
+ // path can't migrate; the lane / normal backend connect will add the column).
173
+ if (!hasColumn(db, 'memories', 'salience'))
174
+ return null;
175
+ const rows = db
176
+ .prepare(
177
+ // Episodes are the tape, not the digest: a fresh episode is always
178
+ // salience 1.0 and would crowd Top of Mind with what just happened.
179
+ 'SELECT title, category, content, salience FROM memories WHERE archived = 0 AND category != ? ORDER BY salience DESC LIMIT 100')
180
+ .all(EPISODE_CATEGORY);
181
+ return assembleDigest(rows, opts);
182
+ }
183
+ finally {
184
+ db.close();
185
+ }
186
+ }
@@ -0,0 +1,37 @@
1
+ import type { KnowledgeBackend, KnowledgePageInput, KnowledgeCitationInput, KnowledgePageWithCitations, KnowledgeQueryInput, KnowledgeWriteResult, KnowledgeArchiveInput, KnowledgeArchiveResult, KnowledgeRestoreInput, KnowledgeRestoreResult, KnowledgeSupersededInput, KnowledgeSupersededResult, KnowledgeMoveInput, KnowledgeMoveResult, KnowledgeMergeInput, KnowledgeMergeResult, KnowledgePurgeInput, KnowledgePurgeResult, KnowledgeVerifyInput, KnowledgeVerifyResult, KnowledgeRevision, KnowledgeRevisionMeta, KnowledgeRevisionRestoreInput, KnowledgeRevisionRestoreResult } from './types.js';
2
+ export interface SqliteKnowledgeConfig {
3
+ /** Absolute path to the knowledge SQLite database file */
4
+ dbPath: string;
5
+ }
6
+ export declare class SqliteKnowledgeBackend implements KnowledgeBackend {
7
+ private readonly config;
8
+ private db;
9
+ constructor(config: SqliteKnowledgeConfig);
10
+ writePage(input: KnowledgePageInput): Promise<KnowledgeWriteResult>;
11
+ getPage(slug: string, opts?: {
12
+ stampAccess?: boolean;
13
+ }): Promise<KnowledgePageWithCitations | null>;
14
+ listPages(input?: KnowledgeQueryInput): Promise<KnowledgePageWithCitations[]>;
15
+ queryPages(input: KnowledgeQueryInput): Promise<KnowledgePageWithCitations[]>;
16
+ archivePage(input: KnowledgeArchiveInput): Promise<KnowledgeArchiveResult>;
17
+ restorePage(input: KnowledgeRestoreInput): Promise<KnowledgeRestoreResult>;
18
+ supersedePage(input: KnowledgeSupersededInput): Promise<KnowledgeSupersededResult>;
19
+ movePage(input: KnowledgeMoveInput): Promise<KnowledgeMoveResult>;
20
+ mergePages(input: KnowledgeMergeInput): Promise<KnowledgeMergeResult>;
21
+ purgePages(input: KnowledgePurgeInput): Promise<KnowledgePurgeResult>;
22
+ verifyPages(input: KnowledgeVerifyInput): Promise<KnowledgeVerifyResult>;
23
+ listRevisions(slug: string): Promise<KnowledgeRevisionMeta[]>;
24
+ getRevision(revisionId: number): Promise<KnowledgeRevision | null>;
25
+ restoreRevision(input: KnowledgeRevisionRestoreInput): Promise<KnowledgeRevisionRestoreResult>;
26
+ /**
27
+ * Insert a body snapshot and prune beyond the per-page cap. Caller owns the
28
+ * transaction when called from restoreRevision; called bare from writePage.
29
+ * Each statement is wrapped in retryWrite so both call sites are safe.
30
+ */
31
+ private snapshotRevision;
32
+ addCitations(slug: string, citations: KnowledgeCitationInput[]): Promise<number>;
33
+ close(): void;
34
+ private ensureOpen;
35
+ private fetchCitationsForPage;
36
+ private initSchema;
37
+ }