@sigrun/recall 0.0.0-stage → 0.1.1

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.
package/dist/db.js ADDED
@@ -0,0 +1,306 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ const SCHEMA = `
5
+ CREATE TABLE IF NOT EXISTS items (
6
+ id INTEGER PRIMARY KEY,
7
+ conversationId INTEGER NOT NULL,
8
+ owner TEXT,
9
+ kind TEXT NOT NULL CHECK (kind IN ('turn', 'tool', 'artifact')),
10
+ entryId INTEGER,
11
+ messageIndex INTEGER,
12
+ blockIndex INTEGER NOT NULL DEFAULT 0,
13
+ offset INTEGER NOT NULL,
14
+ length INTEGER NOT NULL,
15
+ artifact TEXT,
16
+ job INTEGER NOT NULL CHECK (job IN (0, 1)),
17
+ cls TEXT NOT NULL CHECK (cls IN ('normal', 'local-only')),
18
+ at INTEGER NOT NULL,
19
+ title TEXT
20
+ );
21
+ CREATE UNIQUE INDEX IF NOT EXISTS items_message_key
22
+ ON items (conversationId, kind, entryId, messageIndex, blockIndex, offset)
23
+ WHERE kind IN ('turn', 'tool');
24
+ CREATE UNIQUE INDEX IF NOT EXISTS items_artifact_key
25
+ ON items (artifact, offset)
26
+ WHERE kind = 'artifact';
27
+
28
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_porter
29
+ USING fts5(text, tokenize='porter unicode61', content='');
30
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_trigram
31
+ USING fts5(text, tokenize='trigram', content='');
32
+
33
+ CREATE TABLE IF NOT EXISTS artifacts (
34
+ hash TEXT PRIMARY KEY,
35
+ bytes INTEGER NOT NULL,
36
+ title TEXT,
37
+ url TEXT,
38
+ createdAt INTEGER NOT NULL
39
+ );
40
+ CREATE TABLE IF NOT EXISTS cursors (
41
+ conversationId INTEGER PRIMARY KEY,
42
+ entryId INTEGER NOT NULL
43
+ );
44
+ CREATE TABLE IF NOT EXISTS taint (
45
+ conversationId INTEGER PRIMARY KEY,
46
+ entryId INTEGER NOT NULL
47
+ );
48
+ CREATE TABLE IF NOT EXISTS injections (
49
+ conversationId INTEGER NOT NULL,
50
+ messageKey TEXT NOT NULL,
51
+ sources TEXT NOT NULL DEFAULT '[]',
52
+ block TEXT NOT NULL,
53
+ cls TEXT NOT NULL CHECK (cls IN ('normal', 'local-only')),
54
+ PRIMARY KEY (conversationId, messageKey)
55
+ );
56
+ CREATE TABLE IF NOT EXISTS vectors (
57
+ itemId INTEGER PRIMARY KEY,
58
+ dim INTEGER NOT NULL,
59
+ vec BLOB NOT NULL
60
+ );
61
+ CREATE TABLE IF NOT EXISTS state (
62
+ key TEXT PRIMARY KEY,
63
+ value TEXT NOT NULL
64
+ );
65
+ CREATE TABLE IF NOT EXISTS schema_version (
66
+ version INTEGER NOT NULL
67
+ );
68
+ `;
69
+ /** The extension's private SQLite index. Callers close it during shutdown. */
70
+ export class RecallDb {
71
+ db;
72
+ constructor(db) {
73
+ this.db = db;
74
+ }
75
+ static open(path) {
76
+ if (path !== ":memory:")
77
+ mkdirSync(dirname(path), { recursive: true });
78
+ const db = new DatabaseSync(path);
79
+ try {
80
+ const migrated = new RecallDb(db);
81
+ migrated.migrate();
82
+ return migrated;
83
+ }
84
+ catch (error) {
85
+ db.close();
86
+ throw error;
87
+ }
88
+ }
89
+ migrate() {
90
+ this.db.exec("BEGIN IMMEDIATE");
91
+ try {
92
+ this.db.exec("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)");
93
+ const current = this.db
94
+ .prepare("SELECT version FROM schema_version")
95
+ .get();
96
+ if (current === undefined) {
97
+ this.db.exec(SCHEMA);
98
+ this.db.prepare("INSERT INTO schema_version (version) VALUES (2)").run();
99
+ }
100
+ else if (current.version < 2) {
101
+ const itemColumns = this.db.prepare("PRAGMA table_info(items)").all();
102
+ if (!itemColumns.some(({ name }) => name === "blockIndex")) {
103
+ this.db.exec("ALTER TABLE items ADD COLUMN blockIndex INTEGER NOT NULL DEFAULT 0");
104
+ }
105
+ const injectionColumns = this.db.prepare("PRAGMA table_info(injections)").all();
106
+ if (!injectionColumns.some(({ name }) => name === "sources")) {
107
+ this.db.exec("ALTER TABLE injections ADD COLUMN sources TEXT NOT NULL DEFAULT '[]'");
108
+ }
109
+ this.db.exec("DROP INDEX items_message_key");
110
+ this.db.exec(SCHEMA);
111
+ this.db.prepare("UPDATE schema_version SET version = 2").run();
112
+ }
113
+ this.db.exec("COMMIT");
114
+ }
115
+ catch (error) {
116
+ this.db.exec("ROLLBACK");
117
+ throw error;
118
+ }
119
+ }
120
+ insertItem(row, text) {
121
+ this.db.exec("BEGIN IMMEDIATE");
122
+ try {
123
+ const inserted = this.db
124
+ .prepare(`INSERT INTO items (
125
+ conversationId, owner, kind, entryId, messageIndex, blockIndex, offset,
126
+ length, artifact, job, cls, at, title
127
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
128
+
129
+ ON CONFLICT(conversationId, kind, entryId, messageIndex, blockIndex, offset)
130
+ WHERE kind IN ('turn', 'tool') DO NOTHING
131
+ ON CONFLICT(artifact, offset)
132
+ WHERE kind = 'artifact' DO NOTHING
133
+ RETURNING id`)
134
+ .get(row.conversationId, row.owner, row.kind, row.entryId, row.messageIndex, row.blockIndex, row.offset, row.length, row.artifact, row.job ? 1 : 0, row.cls, row.at, row.title);
135
+ if (inserted === undefined) {
136
+ this.db.exec("COMMIT");
137
+ return undefined;
138
+ }
139
+ this.db
140
+ .prepare("INSERT INTO fts_porter(rowid, text) VALUES (?, ?)")
141
+ .run(inserted.id, text);
142
+ this.db
143
+ .prepare("INSERT INTO fts_trigram(rowid, text) VALUES (?, ?)")
144
+ .run(inserted.id, text);
145
+ this.db.exec("COMMIT");
146
+ return inserted.id;
147
+ }
148
+ catch (error) {
149
+ this.db.exec("ROLLBACK");
150
+ throw error;
151
+ }
152
+ }
153
+ putArtifact(hash, bytes, meta) {
154
+ this.db
155
+ .prepare(`INSERT INTO artifacts (hash, bytes, title, url, createdAt)
156
+ VALUES (?, ?, ?, ?, ?) ON CONFLICT(hash) DO NOTHING`)
157
+ .run(hash, bytes, meta.title ?? null, meta.url ?? null, meta.createdAt);
158
+ }
159
+ item(id) {
160
+ const row = this.db.prepare("SELECT * FROM items WHERE id = ?").get(id);
161
+ return row === undefined ? undefined : { ...row, job: row.job === 1 };
162
+ }
163
+ itemByPassage(conversationId, entryId, messageIndex, blockIndex, offset) {
164
+ const row = this.db
165
+ .prepare(`SELECT * FROM items WHERE conversationId = ? AND entryId = ?
166
+ AND messageIndex = ? AND blockIndex = ? AND offset = ? AND kind IN ('turn', 'tool')`)
167
+ .get(conversationId, entryId, messageIndex, blockIndex, offset);
168
+ return row === undefined ? undefined : { ...row, job: row.job === 1 };
169
+ }
170
+ itemsForEntries(conversationId, entryIds) {
171
+ if (entryIds.length === 0)
172
+ return [];
173
+ const placeholders = entryIds.map(() => "?").join(",");
174
+ // SAFETY: placeholders are generated from array length; values are bound.
175
+ const rows = this.db
176
+ .prepare(`SELECT * FROM items WHERE conversationId = ? AND entryId IN (${placeholders})`)
177
+ .all(conversationId, ...entryIds);
178
+ return rows.map((row) => ({ ...row, job: row.job === 1 }));
179
+ }
180
+ artifactHash(handle) {
181
+ const row = this.db
182
+ .prepare("SELECT hash FROM artifacts WHERE substr(hash, 1, 16) = ?")
183
+ .get(handle.slice(2));
184
+ return row?.hash;
185
+ }
186
+ itemByArtifact(handle) {
187
+ const row = this.db
188
+ .prepare("SELECT * FROM items WHERE artifact = ? ORDER BY id LIMIT 1")
189
+ .get(handle);
190
+ return row === undefined ? undefined : { ...row, job: row.job === 1 };
191
+ }
192
+ searchPorter(query, limit) {
193
+ return this.searchFts("fts_porter", query, limit);
194
+ }
195
+ searchTrigram(query, limit) {
196
+ return this.searchFts("fts_trigram", query, limit);
197
+ }
198
+ searchFts(table, query, limit) {
199
+ // SAFETY: table is an internal two-value union; values are bound parameters.
200
+ // The query selects one integer id and one numeric bm25 rank per row.
201
+ const rows = this.db
202
+ .prepare(`SELECT rowid AS id, bm25(${table}) AS rank FROM ${table}
203
+ WHERE ${table} MATCH ? ORDER BY rank LIMIT ?`)
204
+ .all(query, limit);
205
+ return rows.map(({ id, rank }) => ({ id, bm25: rank }));
206
+ }
207
+ /** Indexed text for one item from the porter FTS table. */
208
+ itemText(id) {
209
+ const row = this.db
210
+ .prepare("SELECT text FROM fts_porter WHERE rowid = ?")
211
+ .get(id);
212
+ return row?.text;
213
+ }
214
+ /** Item ids matching a trigram FTS5 expression, in match order. */
215
+ matchTrigram(expression) {
216
+ // SAFETY: node:sqlite returns plain row objects; this query selects one integer.
217
+ const rows = this.db
218
+ .prepare("SELECT rowid FROM fts_trigram WHERE fts_trigram MATCH ?")
219
+ .all(expression);
220
+ return rows.map((row) => row.rowid);
221
+ }
222
+ upsertVector(itemId, vec) {
223
+ const bytes = Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
224
+ this.db
225
+ .prepare(`INSERT INTO vectors (itemId, dim, vec) VALUES (?, ?, ?)
226
+ ON CONFLICT(itemId) DO UPDATE SET dim = excluded.dim, vec = excluded.vec`)
227
+ .run(itemId, vec.length, bytes);
228
+ }
229
+ listVectorsPage(after, limit) {
230
+ // SAFETY: the vectors schema stores integer item ids and Float32 BLOB bytes.
231
+ return this.db
232
+ .prepare("SELECT itemId, vec FROM vectors WHERE itemId > ? ORDER BY itemId LIMIT ?")
233
+ .all(after, limit);
234
+ }
235
+ state(key) {
236
+ const row = this.db.prepare("SELECT value FROM state WHERE key = ?").get(key);
237
+ return row?.value;
238
+ }
239
+ setState(key, value) {
240
+ this.db
241
+ .prepare("INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
242
+ .run(key, value);
243
+ }
244
+ listUnembeddedItems(limit, includeLocalOnly) {
245
+ // SAFETY: the LEFT JOIN projects the typed items schema only.
246
+ const rows = this.db
247
+ .prepare(`SELECT items.* FROM items LEFT JOIN vectors ON vectors.itemId = items.id
248
+ WHERE vectors.itemId IS NULL AND items.kind IN ('turn', 'tool')
249
+ AND items.entryId IS NOT NULL AND items.messageIndex IS NOT NULL
250
+ AND (? OR items.cls != 'local-only') ORDER BY items.id LIMIT ?`)
251
+ .all(includeLocalOnly ? 1 : 0, limit);
252
+ return rows.map((row) => ({ ...row, job: row.job === 1 }));
253
+ }
254
+ cursor(conversationId) {
255
+ return this.entryId("cursors", conversationId);
256
+ }
257
+ setCursor(conversationId, entryId) {
258
+ this.setEntryId("cursors", conversationId, entryId);
259
+ }
260
+ taintFrom(conversationId) {
261
+ return this.entryId("taint", conversationId);
262
+ }
263
+ setTaint(conversationId, entryId) {
264
+ // The earliest point wins: everything from it onward stays local-only.
265
+ this.db
266
+ .prepare(`INSERT INTO taint (conversationId, entryId) VALUES (?, ?)
267
+ ON CONFLICT(conversationId) DO UPDATE SET entryId = excluded.entryId
268
+ WHERE excluded.entryId < taint.entryId`)
269
+ .run(conversationId, entryId);
270
+ }
271
+ entryId(table, conversationId) {
272
+ const row = this.db
273
+ .prepare(`SELECT entryId FROM ${table} WHERE conversationId = ?`)
274
+ .get(conversationId);
275
+ return row?.entryId;
276
+ }
277
+ setEntryId(table, conversationId, entryId) {
278
+ this.db
279
+ .prepare(`INSERT INTO ${table} (conversationId, entryId) VALUES (?, ?)
280
+ ON CONFLICT(conversationId) DO UPDATE SET entryId = excluded.entryId`)
281
+ .run(conversationId, entryId);
282
+ }
283
+ putInjection(conversationId, messageKey, block, cls, sources = []) {
284
+ this.db
285
+ .prepare(`INSERT INTO injections (conversationId, messageKey, block, cls, sources) VALUES (?, ?, ?, ?, ?)
286
+ ON CONFLICT(conversationId, messageKey) DO UPDATE SET
287
+ block = excluded.block, cls = excluded.cls, sources = excluded.sources`)
288
+ .run(conversationId, messageKey, block, cls, JSON.stringify(sources));
289
+ }
290
+ injections(conversationId) {
291
+ // SAFETY: these rows come from the typed injections schema above.
292
+ const rows = this.db
293
+ .prepare("SELECT messageKey, block, cls, sources FROM injections WHERE conversationId = ?")
294
+ .all(conversationId);
295
+ return new Map(rows.map((row) => {
296
+ const parsed = JSON.parse(row.sources);
297
+ const sources = Array.isArray(parsed)
298
+ ? parsed.filter((id) => Number.isInteger(id) && id > 0)
299
+ : [];
300
+ return [row.messageKey, { block: row.block, cls: row.cls, sources }];
301
+ }));
302
+ }
303
+ close() {
304
+ this.db.close();
305
+ }
306
+ }
@@ -0,0 +1,10 @@
1
+ export interface Embedder {
2
+ embed(texts: readonly string[], signal?: AbortSignal): Promise<Float32Array[]>;
3
+ readonly local: boolean;
4
+ }
5
+ /** Call an [OI]-compatible embeddings endpoint without exposing a key by default. */
6
+ export declare function createEmbedder(cfg: {
7
+ url: string;
8
+ model: string;
9
+ local: boolean;
10
+ }, apiKey: () => Promise<string | undefined>, fetchImpl?: typeof fetch): Embedder;
package/dist/embed.js ADDED
@@ -0,0 +1,22 @@
1
+ /** Call an [OI]-compatible embeddings endpoint without exposing a key by default. */
2
+ export function createEmbedder(cfg, apiKey, fetchImpl = fetch) {
3
+ return {
4
+ local: cfg.local,
5
+ async embed(texts, signal) {
6
+ const key = await apiKey();
7
+ const headers = { "content-type": "application/json" };
8
+ if (typeof key === "string" && key !== "")
9
+ headers.authorization = `Bearer ${key}`;
10
+ const response = await fetchImpl(`${cfg.url}/embeddings`, {
11
+ method: "POST",
12
+ headers,
13
+ body: JSON.stringify({ model: cfg.model, input: texts }),
14
+ signal,
15
+ });
16
+ if (!response.ok)
17
+ throw new Error(`embedding endpoint returned ${response.status}`);
18
+ const body = (await response.json());
19
+ return body.data.map(({ embedding }) => new Float32Array(embedding));
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,11 @@
1
+ import type { SigrunExtensionResult, SigrunHost } from "@sigrun/sigrun/extension";
2
+ import { RecallDb } from "./db.js";
3
+ export interface FactoryDeps {
4
+ openDb?: (path: string) => RecallDb;
5
+ now?: () => number;
6
+ }
7
+ /**
8
+ * The loader injects the host only when factory.length > 0: keep the host
9
+ * bare and the optional test dependencies second, so the arity stays one.
10
+ */
11
+ export default function createRecall(host: SigrunHost, deps?: FactoryDeps): SigrunExtensionResult;
package/dist/index.js ADDED
@@ -0,0 +1,215 @@
1
+ import { join } from "node:path";
2
+ import { AgentDoc, defineExtension, GenerationTask, hook, ToolTask, } from "@earendil-works/pi-durable";
3
+ import { createArtifactStore, externalize } from "./artifacts.js";
4
+ import { BACKFILL_TASK_NAME, createBackfillTask, runSweep } from "./backfill.js";
5
+ import { captureConversation } from "./capture.js";
6
+ import { resolveSettings, resolveUserSettings } from "./config.js";
7
+ import { RecallDb } from "./db.js";
8
+ import { createSearcher } from "./search.js";
9
+ import { createRoutes } from "./route.js";
10
+ import { createTools } from "./tools.js";
11
+ import { createEmbedder } from "./embed.js";
12
+ import { applyRecall } from "./inject.js";
13
+ import { createSqliteVectorIndex, createVectorArm, embedPending } from "./vectors.js";
14
+ /**
15
+ * The loader injects the host only when factory.length > 0: keep the host
16
+ * bare and the optional test dependencies second, so the arity stays one.
17
+ */
18
+ export default function createRecall(host, deps = {}) {
19
+ let database;
20
+ let embedder;
21
+ let embeddingKey;
22
+ let services;
23
+ const pending = new Set();
24
+ const trackEmbedding = (work) => {
25
+ pending.add(work);
26
+ void work.finally(() => pending.delete(work));
27
+ };
28
+ // Capture/search surfaces added by later tasks share this one lazy handle.
29
+ // Core does not create paths.data; RecallDb.open creates its parent.
30
+ function db() {
31
+ database ??= (deps.openDb ?? RecallDb.open)(join(host.stores.paths.data, "recall.db"));
32
+ return database;
33
+ }
34
+ async function semantic() {
35
+ const settings = await resolveSettings(host.stores.settings);
36
+ if (settings.embedding === undefined)
37
+ return {};
38
+ const key = JSON.stringify(settings.embedding);
39
+ if (embedder === undefined || embeddingKey !== key) {
40
+ embedder = createEmbedder(settings.embedding, () => host.stores.secrets.get("embedding-api-key"));
41
+ embeddingKey = key;
42
+ }
43
+ return {
44
+ embedder,
45
+ index: createSqliteVectorIndex(db()),
46
+ timeoutMs: settings.embedding.timeoutMs,
47
+ };
48
+ }
49
+ async function vectors() {
50
+ const semanticDeps = await semantic();
51
+ if (semanticDeps.embedder === undefined ||
52
+ semanticDeps.index === undefined ||
53
+ semanticDeps.timeoutMs === undefined) {
54
+ return undefined;
55
+ }
56
+ return createVectorArm(semanticDeps.embedder, semanticDeps.index, semanticDeps.timeoutMs);
57
+ }
58
+ const artifactStore = () => createArtifactStore(db(), join(host.stores.paths.data, "artifacts"));
59
+ const meta = (id) => host.conversations.meta(id);
60
+ const entry = (conversationId, entryId) => host.conversations.entry(conversationId, entryId);
61
+ const userSettings = async (user) => {
62
+ try {
63
+ return await resolveUserSettings(host.stores.user(user).settings);
64
+ }
65
+ catch {
66
+ return { autoRecall: true, localProviders: [] };
67
+ }
68
+ };
69
+ const localProviders = async (user) => (await userSettings(user)).localProviders;
70
+ const pendingVectors = async () => {
71
+ const semanticDeps = await semantic();
72
+ if (semanticDeps.embedder !== undefined && semanticDeps.index !== undefined) {
73
+ await embedPending(db(), semanticDeps.embedder, semanticDeps.index, entry, 500);
74
+ }
75
+ };
76
+ const backfillDeps = () => ({
77
+ host,
78
+ capture: captureConversation,
79
+ db: db(),
80
+ vectors: pendingVectors,
81
+ now: deps.now ?? Date.now,
82
+ });
83
+ function getServices() {
84
+ if (services !== undefined)
85
+ return services;
86
+ const vectorIndex = createSqliteVectorIndex(db());
87
+ const searcher = createSearcher({
88
+ db: db(),
89
+ meta,
90
+ entry,
91
+ userSettings,
92
+ artifactStore: artifactStore(),
93
+ vectors: {
94
+ query: async (text, limit) => (await vectors())?.query(text, limit) ?? [],
95
+ },
96
+ now: deps.now ?? Date.now,
97
+ });
98
+ services = {
99
+ searcher,
100
+ tools: createTools({
101
+ db: db(),
102
+ searcher,
103
+ artifactStore: artifactStore(),
104
+ meta,
105
+ entries: (conversationId, query, limit, cursor) => host.conversations.entries(conversationId, {
106
+ ...query,
107
+ minEntryId: query.minEntryId,
108
+ maxEntryId: query.maxEntryId,
109
+ }, limit, cursor),
110
+ localProviders,
111
+ }),
112
+ };
113
+ void vectorIndex;
114
+ return services;
115
+ }
116
+ const extension = {
117
+ name: "@sigrun/recall",
118
+ hooks: [
119
+ hook(ToolTask, {
120
+ afterTool: async (call, result, api) => {
121
+ try {
122
+ const meta = await host.conversations.meta(api.conversationId);
123
+ if (meta.internal === true)
124
+ return undefined;
125
+ const settings = await resolveSettings(host.stores.settings);
126
+ return externalize(result, call, {
127
+ store: artifactStore(),
128
+ thresholdBytes: settings.externalizeBytes,
129
+ conversationId: api.conversationId,
130
+ owner: meta.owner ?? null,
131
+ now: (deps.now ?? Date.now)(),
132
+ });
133
+ }
134
+ catch (error) {
135
+ console.error("recall: tool-result externalization failed", error);
136
+ return undefined;
137
+ }
138
+ },
139
+ }),
140
+ hook(GenerationTask, {
141
+ beforeRequest: async (request, api, context) => {
142
+ try {
143
+ const liveMeta = await host.conversations.meta(api.conversationId);
144
+ const settings = liveMeta.owner === undefined
145
+ ? { autoRecall: true, localProviders: [] }
146
+ : await userSettings(liveMeta.owner);
147
+ const agent = await api.snapshot(AgentDoc, api.conversationId, context);
148
+ const messages = await applyRecall(request.messages, {
149
+ conversationId: api.conversationId,
150
+ db: db(),
151
+ searcher: getServices().searcher,
152
+ meta: liveMeta,
153
+ model: agent?.model,
154
+ autoRecall: settings.autoRecall,
155
+ internal: liveMeta.internal === true,
156
+ ownerLocalProviders: settings.localProviders,
157
+ sourceMeta: (id) => meta(id),
158
+ sourceLocalProviders: localProviders,
159
+ });
160
+ return messages === undefined ? undefined : { messages };
161
+ }
162
+ catch (error) {
163
+ console.error("recall: automatic recall failed", error);
164
+ return undefined;
165
+ }
166
+ },
167
+ }),
168
+ ],
169
+ get tools() {
170
+ return getServices().tools;
171
+ },
172
+ get tasks() {
173
+ return [createBackfillTask(backfillDeps)];
174
+ },
175
+ };
176
+ return {
177
+ extension: defineExtension(extension),
178
+ jobs: [
179
+ {
180
+ name: "backfill",
181
+ schedule: { cron: "*/10 * * * *" },
182
+ action: { kind: "task", task: BACKFILL_TASK_NAME },
183
+ },
184
+ ],
185
+ routes: createRoutes({
186
+ searcher: {
187
+ search: (query, context, limit) => getServices().searcher.search(query, context, limit),
188
+ },
189
+ sweep: () => runSweep(backfillDeps()),
190
+ }),
191
+ runSettled: async (event, api, context) => {
192
+ try {
193
+ const semanticDeps = await semantic();
194
+ await captureConversation({
195
+ db: db(),
196
+ meta: (id) => host.conversations.meta(id),
197
+ now: deps.now ?? Date.now,
198
+ ...semanticDeps,
199
+ trackEmbedding,
200
+ }, event.conversationId, (query, limit, cursor) => api.entries({ ...query, minEntryId: query.minEntryId }, limit, cursor, context));
201
+ }
202
+ catch (error) {
203
+ console.error("recall: conversation capture failed", error);
204
+ }
205
+ },
206
+ harnessShutdown: async () => {
207
+ const deadline = Date.now() + 30_000;
208
+ while (pending.size > 0 && Date.now() < deadline) {
209
+ await Promise.allSettled([...pending]);
210
+ }
211
+ database?.close();
212
+ database = undefined;
213
+ },
214
+ };
215
+ }
@@ -0,0 +1,25 @@
1
+ import type { Message, UserMessage } from "@earendil-works/pi-ai";
2
+ import type { SessionMeta } from "@sigrun/shared/session-meta";
3
+ import type { RecallDb } from "./db.js";
4
+ import type { Hit, Searcher } from "./search.js";
5
+ export interface InjectDeps {
6
+ conversationId: number;
7
+ db: RecallDb;
8
+ searcher: Searcher;
9
+ meta: SessionMeta;
10
+ model: {
11
+ provider: string;
12
+ } | undefined;
13
+ autoRecall: boolean;
14
+ internal: boolean;
15
+ ownerLocalProviders: readonly string[];
16
+ /** Live source policy readers; missing readers fail closed for sourced blocks. */
17
+ sourceMeta?: (conversationId: number) => Promise<SessionMeta>;
18
+ sourceLocalProviders?: (user: string) => Promise<readonly string[]>;
19
+ }
20
+ /** Stable identity of a user message for per-message injection reuse. */
21
+ export declare function messageKey(message: UserMessage): string;
22
+ /** Render stable, untrusted reference material within the snippet byte budget. */
23
+ export declare function renderBlock(hits: readonly Hit[]): string;
24
+ /** Search once per newest message and apply all currently readable stored blocks. */
25
+ export declare function applyRecall(messages: readonly Message[], deps: InjectDeps): Promise<readonly Message[] | undefined>;