@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,265 @@
1
+ /**
2
+ * Dense ONNX embedding runtime — vendored from the `fastembed` npm package.
3
+ *
4
+ * Why this file exists rather than a dependency: `Anush008/fastembed-js` was
5
+ * archived on GitHub (last push 2025-12-15, latest publish 2.1.0). It pins
6
+ * `tar: ^6.2.0`, and the 6.x line carries twelve unpatched advisories
7
+ * including one critical. There is no upstream release to wait for, and an
8
+ * `overrides` bump to tar 7 is blocked by exactly one line in the package —
9
+ * `import tar from "tar"`, which tar 7 (no default export) turns into a hard
10
+ * SyntaxError at import time.
11
+ *
12
+ * So the ~200 lines loom actually used are inlined here, depending directly on
13
+ * `onnxruntime-node`, `@anush008/tokenizers` and `tar@^7`. Dropped along the
14
+ * way: `progress` (loom always passed showDownloadProgress: false) and
15
+ * `@huggingface/hub` (only the unused sparse/SPLADE path needed it).
16
+ *
17
+ * BEHAVIOUR IS PRESERVED BIT-FOR-BIT ON PURPOSE. Every vector already stored
18
+ * in a loom database came out of the upstream package; a "cleanup" that
19
+ * changed rounding, pooling or the query prefix would silently make old and
20
+ * new vectors incomparable. Two upstream quirks are therefore kept, each
21
+ * marked below:
22
+ * 1. Vectors stay Float32Array end to end (normalisation rounds to f32).
23
+ * 2. queryEmbed prefixes "query: ", an E5 convention that BGE does not
24
+ * actually use. Changing it moves every query in the embedding space.
25
+ * Parity against fastembed@2.1.0 is asserted in embedding-runtime.parity.test.ts.
26
+ */
27
+ import { AddedToken, Tokenizer } from '@anush008/tokenizers';
28
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
29
+ import { get as httpsGet } from 'node:https';
30
+ import { extname, join } from 'node:path';
31
+ import * as ort from 'onnxruntime-node';
32
+ import * as tar from 'tar';
33
+ /** Dense models this runtime knows how to fetch and run. */
34
+ export const EmbeddingModel = {
35
+ AllMiniLML6V2: 'fast-all-MiniLM-L6-v2',
36
+ BGEBaseEN: 'fast-bge-base-en',
37
+ BGEBaseENV15: 'fast-bge-base-en-v1.5',
38
+ BGESmallEN: 'fast-bge-small-en',
39
+ BGESmallENV15: 'fast-bge-small-en-v1.5',
40
+ BGESmallZH: 'fast-bge-small-zh-v1.5',
41
+ MLE5Large: 'fast-multilingual-e5-large',
42
+ };
43
+ /**
44
+ * Where model tarballs come from. The upstream default is a Qdrant-owned GCS
45
+ * bucket whose access has broken at least twice (fastembed-js issues #18, #33)
46
+ * and which nobody maintains any more; the env override exists so a broken
47
+ * bucket is a config change, not a release.
48
+ */
49
+ const DEFAULT_MODEL_BASE_URL = 'https://storage.googleapis.com/qdrant-fastembed';
50
+ /**
51
+ * Loaded dense embedder: a tokenizer plus an ONNX inference session.
52
+ *
53
+ * Named for the upstream class so the diff in fastembed.ts stays reviewable.
54
+ */
55
+ export class FlagEmbedding {
56
+ tokenizer;
57
+ session;
58
+ model;
59
+ constructor(tokenizer, session, model) {
60
+ this.tokenizer = tokenizer;
61
+ this.session = session;
62
+ this.model = model;
63
+ }
64
+ static async init({ model, cacheDir, maxLength = 512, }) {
65
+ const modelDir = await retrieveModel(model, cacheDir);
66
+ const tokenizer = loadTokenizer(modelDir, maxLength);
67
+ // MiniLM and multilingual-e5 ship an unoptimised graph; the BGE family
68
+ // ships an optimised one. Upstream picks by model, so we do too.
69
+ const fileName = model === EmbeddingModel.MLE5Large || model === EmbeddingModel.AllMiniLML6V2
70
+ ? 'model.onnx'
71
+ : 'model_optimized.onnx';
72
+ const modelPath = join(modelDir, fileName);
73
+ if (!existsSync(modelPath)) {
74
+ throw new Error(`Model file not found at ${modelPath}`);
75
+ }
76
+ const session = await ort.InferenceSession.create(modelPath, {
77
+ executionProviders: ['cpu'],
78
+ graphOptimizationLevel: 'all',
79
+ });
80
+ return new FlagEmbedding(tokenizer, session, model);
81
+ }
82
+ async *embed(texts, batchSize = 256) {
83
+ for (let i = 0; i < texts.length; i += batchSize) {
84
+ yield await this.runBatch(texts.slice(i, i + batchSize));
85
+ }
86
+ }
87
+ /**
88
+ * QUIRK PRESERVED: the "query: " prefix is the E5 instruction format. BGE
89
+ * models want "Represent this sentence for searching relevant passages: "
90
+ * instead. Upstream applied E5's prefix to every model and loom's stored
91
+ * vectors were produced under that regime, so it stays until we decide to
92
+ * re-embed. See t-424.
93
+ */
94
+ async queryEmbed(query) {
95
+ const [vector] = await this.runBatch([`query: ${query}`]);
96
+ if (!vector) {
97
+ throw new Error('embedding runtime: queryEmbed produced no vector');
98
+ }
99
+ return vector;
100
+ }
101
+ async runBatch(texts) {
102
+ const encoded = await Promise.all(texts.map((text) => this.tokenizer.encode(text)));
103
+ const ids = [];
104
+ const mask = [];
105
+ const typeIds = [];
106
+ for (const item of encoded) {
107
+ ids.push(item.getIds().map(BigInt));
108
+ mask.push(item.getAttentionMask().map(BigInt));
109
+ typeIds.push(item.getTypeIds().map(BigInt));
110
+ }
111
+ // Padding is fixed-length, so every row in the batch has the same length.
112
+ const width = ids[0]?.length ?? 0;
113
+ const shape = [texts.length, width];
114
+ const inputs = {
115
+ input_ids: new ort.Tensor('int64', ids.flat(), shape),
116
+ attention_mask: new ort.Tensor('int64', mask.flat(), shape),
117
+ token_type_ids: new ort.Tensor('int64', typeIds.flat(), shape),
118
+ };
119
+ // multilingual-e5 has no token_type_ids input in its graph.
120
+ if (this.model === EmbeddingModel.MLE5Large) {
121
+ delete inputs.token_type_ids;
122
+ }
123
+ const output = await this.session.run(inputs);
124
+ const hidden = output.last_hidden_state;
125
+ if (!hidden) {
126
+ throw new Error('embedding runtime: model produced no last_hidden_state output');
127
+ }
128
+ return cls(hidden.data, hidden.dims).map(normalize);
129
+ }
130
+ }
131
+ /**
132
+ * CLS pooling: take the first token's hidden state for each sequence.
133
+ *
134
+ * Mirrors qdrant/fastembed a335c88, which removed attention pooling. Slicing a
135
+ * Float32Array yields a Float32Array — that is load-bearing, see file header.
136
+ */
137
+ function cls(data, dims) {
138
+ const [batch = 0, , hiddenSize = 0] = dims;
139
+ return Array.from({ length: batch }, (_unused, index) => {
140
+ const start = index * dims[1] * hiddenSize;
141
+ return data.slice(start, start + hiddenSize);
142
+ });
143
+ }
144
+ /** L2 normalisation. Stays in Float32Array so results round exactly as before. */
145
+ function normalize(v) {
146
+ let sum = 0;
147
+ for (const val of v)
148
+ sum += val * val;
149
+ const norm = Math.sqrt(sum);
150
+ return v.map((val) => val / Math.max(norm, 1e-12));
151
+ }
152
+ function loadTokenizer(modelDir, maxLength) {
153
+ const config = readJson(modelDir, 'config.json');
154
+ const tokenizerConfig = readJson(modelDir, 'tokenizer_config.json');
155
+ const tokensMap = readJson(modelDir, 'special_tokens_map.json');
156
+ const tokenizerPath = join(modelDir, 'tokenizer.json');
157
+ if (!existsSync(tokenizerPath)) {
158
+ throw new Error(`Tokenizer file not found at ${tokenizerPath}`);
159
+ }
160
+ const tokenizer = Tokenizer.fromFile(tokenizerPath);
161
+ tokenizer.setTruncation(Math.min(maxLength, Number(tokenizerConfig['model_max_length'])));
162
+ tokenizer.setPadding({
163
+ maxLength: Math.min(maxLength, Number(tokenizerConfig['model_max_length'])),
164
+ padId: config['pad_token_id'],
165
+ padToken: tokenizerConfig['pad_token'],
166
+ });
167
+ for (const token of Object.values(tokensMap)) {
168
+ if (typeof token === 'string') {
169
+ tokenizer.addSpecialTokens([token]);
170
+ }
171
+ else if (isAddedTokenMap(token)) {
172
+ tokenizer.addAddedTokens([
173
+ new AddedToken(token['content'], true, {
174
+ singleWord: token['single_word'],
175
+ leftStrip: token['lstrip'],
176
+ rightStrip: token['rstrip'],
177
+ normalized: token['normalized'],
178
+ }),
179
+ ]);
180
+ }
181
+ }
182
+ return tokenizer;
183
+ }
184
+ function readJson(modelDir, fileName) {
185
+ const path = join(modelDir, fileName);
186
+ if (!existsSync(path)) {
187
+ throw new Error(`${fileName} not found at ${path}`);
188
+ }
189
+ return JSON.parse(readFileSync(path, 'utf-8'));
190
+ }
191
+ /**
192
+ * QUIRK PRESERVED: upstream tests for a `token` key that special_tokens_map
193
+ * files do not have (they use `content`), so object-valued entries were always
194
+ * skipped. Every model loom ships uses plain-string entries, making the branch
195
+ * dead either way — but tightening the check would change tokenization for
196
+ * some other model, so it stays as it was.
197
+ */
198
+ function isAddedTokenMap(token) {
199
+ return (typeof token === 'object' &&
200
+ token !== null &&
201
+ 'token' in token &&
202
+ 'single_word' in token &&
203
+ 'rstrip' in token &&
204
+ 'lstrip' in token &&
205
+ 'normalized' in token);
206
+ }
207
+ async function retrieveModel(model, cacheDir) {
208
+ if (!existsSync(cacheDir)) {
209
+ mkdirSync(cacheDir, { recursive: true });
210
+ }
211
+ const modelDir = join(cacheDir, model);
212
+ if (existsSync(modelDir))
213
+ return modelDir;
214
+ const archive = join(cacheDir, `${model}.tar.gz`);
215
+ await download(archive, model);
216
+ try {
217
+ await extract(archive, cacheDir);
218
+ }
219
+ finally {
220
+ if (existsSync(archive))
221
+ unlinkSync(archive);
222
+ }
223
+ return modelDir;
224
+ }
225
+ async function extract(archive, cacheDir) {
226
+ if (extname(archive) !== '.gz') {
227
+ throw new Error(`Unsupported file extension: ${archive}`);
228
+ }
229
+ await tar.x({ file: archive, cwd: cacheDir });
230
+ }
231
+ function download(outputPath, model) {
232
+ if (existsSync(outputPath))
233
+ return Promise.resolve();
234
+ // The MiniLM archive is published under its sentence-transformers name even
235
+ // though its directory inside the tarball is the fast-* one.
236
+ const remoteName = model === EmbeddingModel.AllMiniLML6V2
237
+ ? `sentence-transformers${model.substring(model.indexOf('-'))}`
238
+ : model;
239
+ const base = process.env.LOOM_MODEL_BASE_URL ?? DEFAULT_MODEL_BASE_URL;
240
+ const url = `${base}/${remoteName}.tar.gz`;
241
+ return new Promise((resolve, reject) => {
242
+ const fail = (err) => {
243
+ // A partial archive on disk would be treated as a complete download on
244
+ // the next run, so it must not survive a failure.
245
+ if (existsSync(outputPath))
246
+ unlinkSync(outputPath);
247
+ reject(err);
248
+ };
249
+ const file = createWriteStream(outputPath);
250
+ file.on('error', fail);
251
+ httpsGet(url, { headers: { 'User-Agent': 'loom' } }, (response) => {
252
+ if (response.statusCode !== 200) {
253
+ response.resume();
254
+ fail(new Error(`Model download failed: ${url} returned HTTP ${response.statusCode}. ` +
255
+ 'Set LOOM_MODEL_BASE_URL to fetch models from a different host.'));
256
+ return;
257
+ }
258
+ response.on('error', fail);
259
+ response.pipe(file);
260
+ file.on('finish', () => {
261
+ file.close((err) => (err ? fail(err) : resolve()));
262
+ });
263
+ }).on('error', fail);
264
+ });
265
+ }
@@ -0,0 +1,36 @@
1
+ export interface EpisodeRow {
2
+ title: string;
3
+ content: string;
4
+ created: string;
5
+ project: string | null;
6
+ metadata: string;
7
+ }
8
+ export interface TapeOptions {
9
+ /** Look-back window in hours (default 24). */
10
+ hours?: number;
11
+ /** Approximate token budget for the block (default 1500). Oldest are dropped first. */
12
+ tokenBudget?: number;
13
+ /** Rough chars-per-token for the budget estimate. */
14
+ charsPerToken?: number;
15
+ /** IANA zone for the timestamps (default: TZ env, else America/Chicago). */
16
+ timeZone?: string;
17
+ /** "now" override for tests. */
18
+ nowMs?: number;
19
+ }
20
+ /** "Mon 17:05" in the configured zone — the tape reads as a clock, not ISO. */
21
+ export declare function tapeClock(iso: string, timeZone: string): string;
22
+ /** One line per episode — authored content, trimmed to a single line, never synthesized. */
23
+ export declare function episodeLine(r: EpisodeRow, timeZone: string): string;
24
+ /**
25
+ * Assemble the tape: oldest→newest, within budget, most-recent kept when over.
26
+ * Pure selection + ordering. Returns null when there are no episodes.
27
+ */
28
+ export declare function assembleTape(rows: EpisodeRow[], opts?: TapeOptions): string | null;
29
+ /** Raw episode rows in the window, oldest first. Empty when there is no store. */
30
+ export declare function episodeRows(contextDir: string, opts?: TapeOptions): EpisodeRow[];
31
+ /**
32
+ * The boot tape for a context: "what happened across all of me in the last
33
+ * N hours", time-ordered. Null when nothing happened (or no store) — the
34
+ * identity block is simply omitted then.
35
+ */
36
+ export declare function tapeForContext(contextDir: string, opts?: TapeOptions): string | null;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The episode tape (t-142) — the short-term, cross-body memory tier.
3
+ *
4
+ * Art runs as many sleeves at once (discord bodies, wakes, lanes, the voice
5
+ * brain, terminals). They share the stack (loom) but not the last few hours:
6
+ * what one body did is invisible to the next unless someone judged it worth a
7
+ * month. Episodes fill that gap. Each body writes a short note — where it was,
8
+ * what was said or decided, what shipped, what's open — as category `episode`
9
+ * with a 48h TTL. At boot every body receives the last 24h of episodes as a
10
+ * plain TIME-ORDERED tape, injected right after identity.
11
+ *
12
+ * Deliberately NOT salience-ranked: salience reheats on recall, so a ranked
13
+ * view surfaces what the last body *searched for*, not what last *happened*.
14
+ * The tape is the wrong place for cleverness — it is just the tape.
15
+ *
16
+ * Like salience, this opens memories.db directly (plain SQL, no embedder) so
17
+ * identity load stays cheap.
18
+ */
19
+ import { existsSync } from 'node:fs';
20
+ import BetterSqlite3 from 'better-sqlite3';
21
+ import { resolveSqliteDbPath } from '../config.js';
22
+ import { EPISODE_CATEGORY } from '../categories.js';
23
+ const DEFAULT_HOURS = 24;
24
+ const DEFAULT_TOKEN_BUDGET = 1500;
25
+ const DEFAULT_CHARS_PER_TOKEN = 4;
26
+ const HOUR_MS = 3_600_000;
27
+ function zone(opts) {
28
+ return opts.timeZone ?? process.env.LOOM_TIMEZONE ?? process.env.TZ ?? 'America/Chicago';
29
+ }
30
+ /** "Mon 17:05" in the configured zone — the tape reads as a clock, not ISO. */
31
+ export function tapeClock(iso, timeZone) {
32
+ const d = new Date(iso);
33
+ if (Number.isNaN(d.getTime()))
34
+ return iso;
35
+ try {
36
+ return new Intl.DateTimeFormat('en-US', {
37
+ timeZone,
38
+ weekday: 'short',
39
+ hour: '2-digit',
40
+ minute: '2-digit',
41
+ hour12: false,
42
+ }).format(d).replace(',', '');
43
+ }
44
+ catch {
45
+ return iso;
46
+ }
47
+ }
48
+ /** Where the body was: metadata.where wins, then project, else unknown. */
49
+ function whereOf(r) {
50
+ try {
51
+ const meta = JSON.parse(r.metadata || '{}');
52
+ if (typeof meta.where === 'string' && meta.where.trim())
53
+ return meta.where.trim();
54
+ }
55
+ catch {
56
+ /* fall through */
57
+ }
58
+ return r.project?.trim() || '?';
59
+ }
60
+ /** One line per episode — authored content, trimmed to a single line, never synthesized. */
61
+ export function episodeLine(r, timeZone) {
62
+ const body = r.content.replace(/\s+/g, ' ').trim();
63
+ const clipped = body.length > 600 ? body.slice(0, 597).trimEnd() + '…' : body;
64
+ return `- **${tapeClock(r.created, timeZone)}** [${whereOf(r)}] ${r.title} — ${clipped}`;
65
+ }
66
+ /**
67
+ * Assemble the tape: oldest→newest, within budget, most-recent kept when over.
68
+ * Pure selection + ordering. Returns null when there are no episodes.
69
+ */
70
+ export function assembleTape(rows, opts = {}) {
71
+ if (rows.length === 0)
72
+ return null;
73
+ const tz = zone(opts);
74
+ const budgetChars = (opts.tokenBudget ?? DEFAULT_TOKEN_BUDGET) * (opts.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN);
75
+ const ordered = [...rows].sort((a, b) => Date.parse(a.created) - Date.parse(b.created));
76
+ const lines = ordered.map((r) => episodeLine(r, tz));
77
+ // Fill from the newest end backwards so the budget drops the OLDEST first.
78
+ const kept = [];
79
+ let used = 0;
80
+ for (let i = lines.length - 1; i >= 0; i--) {
81
+ const line = lines[i];
82
+ if (used + line.length > budgetChars && kept.length > 0)
83
+ break;
84
+ kept.unshift(line);
85
+ used += line.length + 1;
86
+ }
87
+ const dropped = lines.length - kept.length;
88
+ const head = dropped > 0 ? `_(${dropped} older episode${dropped === 1 ? '' : 's'} omitted for budget)_\n` : '';
89
+ return head + kept.join('\n');
90
+ }
91
+ function openMemoriesDb(contextDir) {
92
+ const dbPath = resolveSqliteDbPath(contextDir);
93
+ if (!existsSync(dbPath))
94
+ return null;
95
+ const db = new BetterSqlite3(dbPath, { readonly: true });
96
+ db.pragma('busy_timeout = 0');
97
+ return db;
98
+ }
99
+ /** Raw episode rows in the window, oldest first. Empty when there is no store. */
100
+ export function episodeRows(contextDir, opts = {}) {
101
+ const db = openMemoriesDb(contextDir);
102
+ if (!db)
103
+ return [];
104
+ try {
105
+ const now = opts.nowMs ?? Date.now();
106
+ const since = new Date(now - (opts.hours ?? DEFAULT_HOURS) * HOUR_MS).toISOString();
107
+ const nowIso = new Date(now).toISOString();
108
+ return db
109
+ .prepare(`SELECT title, content, created, project, metadata FROM memories
110
+ WHERE category = ? AND archived = 0 AND created >= ?
111
+ AND (expires_at IS NULL OR expires_at > ?)
112
+ ORDER BY created ASC`)
113
+ .all(EPISODE_CATEGORY, since, nowIso);
114
+ }
115
+ finally {
116
+ db.close();
117
+ }
118
+ }
119
+ /**
120
+ * The boot tape for a context: "what happened across all of me in the last
121
+ * N hours", time-ordered. Null when nothing happened (or no store) — the
122
+ * identity block is simply omitted then.
123
+ */
124
+ export function tapeForContext(contextDir, opts = {}) {
125
+ return assembleTape(episodeRows(contextDir, opts), opts);
126
+ }
@@ -0,0 +1,18 @@
1
+ import type { EmbeddingProvider } from './types.js';
2
+ export interface FastEmbedConfig {
3
+ /** Model identifier, e.g. 'fast-bge-small-en-v1.5' */
4
+ model: string;
5
+ /** Directory to cache downloaded ONNX models */
6
+ cacheDir?: string;
7
+ }
8
+ export declare class FastEmbedProvider implements EmbeddingProvider {
9
+ private readonly config;
10
+ readonly dimensions: number;
11
+ private embedder;
12
+ private initPromise;
13
+ constructor(config: FastEmbedConfig);
14
+ embed(text: string): Promise<number[]>;
15
+ embedBatch(texts: string[]): Promise<number[][]>;
16
+ embedQuery(text: string): Promise<number[]>;
17
+ private ensureEmbedder;
18
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * FastEmbed provider — runs ONNX embedding models in-process.
3
+ *
4
+ * No external service required. First call downloads the model to
5
+ * cacheDir (~30MB for BGE-small-en-v1.5). Subsequent calls use the
6
+ * cached model. CPU-only by default; portable to any Node machine.
7
+ *
8
+ * BGE-family models distinguish query vs passage embeddings. Remember()
9
+ * paths call embed() (passage). Recall() calls embedQuery() (query).
10
+ */
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { mkdirSync } from 'node:fs';
14
+ import { FlagEmbedding } from './embedding-runtime.js';
15
+ /** Known model → dimension mapping. */
16
+ const MODEL_DIMENSIONS = {
17
+ 'fast-all-MiniLM-L6-v2': 384,
18
+ 'fast-bge-base-en': 768,
19
+ 'fast-bge-base-en-v1.5': 768,
20
+ 'fast-bge-small-en': 384,
21
+ 'fast-bge-small-en-v1.5': 384,
22
+ 'fast-bge-small-zh-v1.5': 512,
23
+ 'fast-multilingual-e5-large': 1024,
24
+ };
25
+ export class FastEmbedProvider {
26
+ config;
27
+ dimensions;
28
+ embedder = null;
29
+ initPromise = null;
30
+ constructor(config) {
31
+ this.config = config;
32
+ const dims = MODEL_DIMENSIONS[config.model];
33
+ if (dims === undefined) {
34
+ throw new Error(`Unknown fastembed model: "${config.model}". Known: ${Object.keys(MODEL_DIMENSIONS).join(', ')}`);
35
+ }
36
+ this.dimensions = dims;
37
+ }
38
+ async embed(text) {
39
+ const embedder = await this.ensureEmbedder();
40
+ const vectors = await collectBatches(embedder.embed([text], 1));
41
+ const vector = vectors[0];
42
+ if (!vector || vector.length === 0) {
43
+ throw new Error(`fastembed: embed() produced no vector for a ${text.length}-char input (model ${this.config.model})`);
44
+ }
45
+ return vector;
46
+ }
47
+ async embedBatch(texts) {
48
+ if (texts.length === 0)
49
+ return [];
50
+ const embedder = await this.ensureEmbedder();
51
+ const vectors = await collectBatches(embedder.embed(texts, 32));
52
+ if (vectors.length !== texts.length) {
53
+ throw new Error(`fastembed: embedBatch() produced ${vectors.length} vectors for ${texts.length} inputs (model ${this.config.model})`);
54
+ }
55
+ return vectors;
56
+ }
57
+ async embedQuery(text) {
58
+ const embedder = await this.ensureEmbedder();
59
+ const vector = await embedder.queryEmbed(text);
60
+ if (!vector || vector.length === 0) {
61
+ throw new Error(`fastembed: embedQuery() produced no vector for a ${text.length}-char input (model ${this.config.model})`);
62
+ }
63
+ return Array.from(vector);
64
+ }
65
+ ensureEmbedder() {
66
+ if (this.embedder)
67
+ return Promise.resolve(this.embedder);
68
+ if (!this.initPromise) {
69
+ const cacheDir = this.config.cacheDir ?? join(homedir(), '.cache', 'loom', 'fastembed');
70
+ mkdirSync(cacheDir, { recursive: true });
71
+ this.initPromise = FlagEmbedding.init({
72
+ model: this.config.model,
73
+ cacheDir,
74
+ }).then((e) => {
75
+ this.embedder = e;
76
+ return e;
77
+ }, (err) => {
78
+ // Transient failure (e.g. network hiccup during first model
79
+ // download) must not poison the singleton: clear the cached
80
+ // promise so the next call retries init from scratch.
81
+ this.initPromise = null;
82
+ throw err;
83
+ });
84
+ }
85
+ return this.initPromise;
86
+ }
87
+ }
88
+ async function collectBatches(gen) {
89
+ const out = [];
90
+ for await (const batch of gen) {
91
+ for (const vector of batch)
92
+ out.push(Array.from(vector));
93
+ }
94
+ return out;
95
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Convert a glob pattern (with `*` wildcards) into a predicate function.
3
+ * Case-insensitive. `*` matches any sequence of characters.
4
+ *
5
+ * Examples:
6
+ * "Forgejo sweep*" → matches "Forgejo sweep — 2026-04-01 (sixty-first)"
7
+ * "*loom*" → matches "loom PR #48 — stall detection"
8
+ * "exact title" → matches only "exact title"
9
+ */
10
+ export declare function globToMatcher(pattern: string): (value: string) => boolean;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Convert a glob pattern (with `*` wildcards) into a predicate function.
3
+ * Case-insensitive. `*` matches any sequence of characters.
4
+ *
5
+ * Examples:
6
+ * "Forgejo sweep*" → matches "Forgejo sweep — 2026-04-01 (sixty-first)"
7
+ * "*loom*" → matches "loom PR #48 — stall detection"
8
+ * "exact title" → matches only "exact title"
9
+ */
10
+ export function globToMatcher(pattern) {
11
+ const escaped = pattern
12
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
13
+ .replace(/\*/g, '.*');
14
+ const regex = new RegExp(`^${escaped}$`, 'i');
15
+ return (value) => regex.test(value);
16
+ }
@@ -0,0 +1,6 @@
1
+ import type { MemoryBackend, EmbeddingProvider, KnowledgeBackend } from './types.js';
2
+ export declare function createEmbeddingProvider(): EmbeddingProvider;
3
+ export declare function createBackend(contextDir: string): MemoryBackend;
4
+ /** Close and evict every cached memory backend. For tests and shutdown. */
5
+ export declare function closeAllBackends(): void;
6
+ export declare function createKnowledgeBackend(contextDir: string): KnowledgeBackend;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Memory backend factory.
3
+ *
4
+ * loom ships a single opinionated stack: SqliteVecBackend
5
+ * (sqlite + sqlite-vec virtual table) backed by FastEmbedProvider
6
+ * (BGE-small-en-v1.5 ONNX, CPU-only). Zero external services. One DB
7
+ * file per agent under the context dir.
8
+ *
9
+ * If you ever need a different stack — different embedding model,
10
+ * remote vector DB, etc — implement the MemoryBackend / EmbeddingProvider
11
+ * interfaces in types.ts and swap the concrete classes here. There is
12
+ * deliberately no env-driven backend selector anymore: opinionated.
13
+ */
14
+ import { resolveSqliteDbPath, resolveKnowledgeDbPath, assertKnowledgePathsNotCoLocated, resolveFastEmbedModel, resolveFastEmbedCacheDir, } from '../config.js';
15
+ import { SqliteVecBackend } from './sqlite-vec.js';
16
+ import { appendRecallObservation } from './recall-log.js';
17
+ import { SqliteKnowledgeBackend } from './sqlite-knowledge.js';
18
+ import { FastEmbedProvider } from './fastembed.js';
19
+ /**
20
+ * Process-lifetime caches (store-convergence §Lifecycle).
21
+ *
22
+ * Tools call createBackend on every invocation; before these caches that
23
+ * meant a fresh better-sqlite3 handle (leaked — no tool closes) and a fresh
24
+ * FastEmbedProvider (full ONNX model init) per call. Backends are now cached
25
+ * per resolved db path and the embedding provider is a singleton per
26
+ * model+cacheDir. close() on a cached backend evicts it, so explicit close
27
+ * (e.g. eurorack-import) stays correct — the next createBackend reopens.
28
+ */
29
+ const backendCache = new Map();
30
+ const embedderCache = new Map();
31
+ export function createEmbeddingProvider() {
32
+ const model = resolveFastEmbedModel();
33
+ const cacheDir = resolveFastEmbedCacheDir();
34
+ const key = `${model}\u0000${cacheDir}`;
35
+ let provider = embedderCache.get(key);
36
+ if (!provider) {
37
+ provider = new FastEmbedProvider({ model, cacheDir });
38
+ embedderCache.set(key, provider);
39
+ }
40
+ return provider;
41
+ }
42
+ export function createBackend(contextDir) {
43
+ const dbPath = resolveSqliteDbPath(contextDir);
44
+ const cached = backendCache.get(dbPath);
45
+ if (cached)
46
+ return cached;
47
+ const backend = new SqliteVecBackend({
48
+ dbPath,
49
+ // Recall observation log: local-only, <contextDir>/telemetry/recall.jsonl.
50
+ onRecall: (obs) => appendRecallObservation(contextDir, obs),
51
+ }, createEmbeddingProvider());
52
+ // Evict on close so an explicitly closed backend is never handed out again.
53
+ const originalClose = backend.close.bind(backend);
54
+ backend.close = () => {
55
+ backendCache.delete(dbPath);
56
+ originalClose();
57
+ };
58
+ backendCache.set(dbPath, backend);
59
+ return backend;
60
+ }
61
+ /** Close and evict every cached memory backend. For tests and shutdown. */
62
+ export function closeAllBackends() {
63
+ for (const backend of [...backendCache.values()]) {
64
+ try {
65
+ backend.close(); // evicts itself via the wrapped close
66
+ }
67
+ catch {
68
+ // best-effort shutdown
69
+ }
70
+ }
71
+ backendCache.clear();
72
+ }
73
+ export function createKnowledgeBackend(contextDir) {
74
+ assertKnowledgePathsNotCoLocated(contextDir);
75
+ return new SqliteKnowledgeBackend({
76
+ dbPath: resolveKnowledgeDbPath(contextDir),
77
+ });
78
+ }