@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/tools.js ADDED
@@ -0,0 +1,174 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool } from "@earendil-works/pi-durable";
3
+ import { audienceCovers } from "./audience.js";
4
+ import { mayRead } from "./policy.js";
5
+ import { redact } from "./redact.js";
6
+ const OPEN_LIMIT = 16_384;
7
+ function output(text) {
8
+ return { output: [{ type: "text", text }] };
9
+ }
10
+ function date(value) {
11
+ if (value === undefined)
12
+ return undefined;
13
+ const parsed = Date.parse(value);
14
+ return Number.isNaN(parsed) ? undefined : parsed;
15
+ }
16
+ function taint(db, conversationId, item) {
17
+ if (item.cls === "local-only") {
18
+ db.setTaint(conversationId, (db.cursor(conversationId) ?? 0) + 1);
19
+ }
20
+ }
21
+ async function permitted(deps, current, item, provider) {
22
+ if (!audienceCovers(current, { owner: item.owner ?? undefined }))
23
+ return undefined;
24
+ const meta = await deps.meta(item.conversationId).catch(() => undefined);
25
+ if (meta === undefined || meta.internal === true || !audienceCovers(current, meta)) {
26
+ return undefined;
27
+ }
28
+ const providers = item.owner === null ? [] : await deps.localProviders(item.owner).catch(() => []);
29
+ if (!mayRead(item.cls, provider === undefined ? undefined : { provider }, providers)) {
30
+ return undefined;
31
+ }
32
+ return meta;
33
+ }
34
+ function renderEntries(entries) {
35
+ const lines = [];
36
+ for (const entry of entries) {
37
+ for (const message of entry.model ?? []) {
38
+ if (message.role !== "user" && message.role !== "assistant" && message.role !== "toolResult") {
39
+ continue;
40
+ }
41
+ const text = typeof message.content === "string"
42
+ ? message.content
43
+ : message.content
44
+ .filter((part) => part.type === "text")
45
+ .map((part) => part.text)
46
+ .join("\n");
47
+ if (text !== "")
48
+ lines.push(`${message.role}: ${redact(text)}`);
49
+ }
50
+ }
51
+ let bytes = 0;
52
+ const bounded = [];
53
+ for (const line of lines) {
54
+ if (bytes >= OPEN_LIMIT)
55
+ break;
56
+ const remaining = OPEN_LIMIT - bytes;
57
+ const buffer = Buffer.from(line, "utf8");
58
+ bounded.push(buffer.subarray(0, remaining).toString("utf8"));
59
+ bytes += Math.min(buffer.length, remaining);
60
+ }
61
+ return bounded.join("\n");
62
+ }
63
+ /** Build the read-only model-facing recall tools. */
64
+ export function createTools(deps) {
65
+ const search = defineTool({
66
+ name: "recall_search",
67
+ description: "Search past conversations and stored tool results for relevant material.",
68
+ parameters: Type.Object({
69
+ query: Type.String(),
70
+ since: Type.Optional(Type.String()),
71
+ until: Type.Optional(Type.String()),
72
+ kind: Type.Optional(Type.Union([Type.Literal("conversation"), Type.Literal("page")])),
73
+ conversation: Type.Optional(Type.Number()),
74
+ }),
75
+ replay: "safe",
76
+ async execute(args, api, context) {
77
+ const conversationId = Number(api.conversationId);
78
+ const current = await deps.meta(conversationId);
79
+ const model = (await api.agent(context)).model;
80
+ const kinds = args.kind === "conversation"
81
+ ? ["turn", "tool"]
82
+ : args.kind === "page"
83
+ ? ["artifact"]
84
+ : undefined;
85
+ const hits = await deps.searcher.search(args.query, {
86
+ current: {
87
+ conversationId,
88
+ owner: current.owner,
89
+ sharing: current.sharing,
90
+ },
91
+ model: model === undefined ? undefined : { provider: model.provider },
92
+ includeJobs: true,
93
+ since: date(args.since),
94
+ until: date(args.until),
95
+ kinds,
96
+ }, args.conversation === undefined ? 10 : 200);
97
+ const selected = hits
98
+ .filter((hit) => args.conversation === undefined || hit.item.conversationId === args.conversation)
99
+ .slice(0, 10);
100
+ for (const hit of selected)
101
+ taint(deps.db, conversationId, hit.item);
102
+ if (selected.length === 0)
103
+ return output("No matching past material.");
104
+ // Stable score order within first-seen conversation groups.
105
+ const groups = new Map();
106
+ for (const hit of selected) {
107
+ const group = groups.get(hit.item.conversationId) ?? [];
108
+ group.push(hit);
109
+ groups.set(hit.item.conversationId, group);
110
+ }
111
+ const lines = [...groups.values()].flatMap((group) => group.map((hit) => {
112
+ const day = new Date(hit.item.at).toISOString().slice(0, 10);
113
+ const title = hit.conversationTitle ?? `conversation ${hit.item.conversationId}`;
114
+ return `[${day}] ${title} — ${hit.snippet} (${hit.handle})`;
115
+ }));
116
+ return output(lines.join("\n"));
117
+ },
118
+ });
119
+ const open = defineTool({
120
+ name: "recall_open",
121
+ description: "Open a recalled passage or read a slice of a stored artifact.",
122
+ parameters: Type.Object({
123
+ handle: Type.String(),
124
+ offset: Type.Optional(Type.Number()),
125
+ length: Type.Optional(Type.Number({ maximum: OPEN_LIMIT })),
126
+ }),
127
+ replay: "safe",
128
+ async execute(args, api, context) {
129
+ const conversationId = Number(api.conversationId);
130
+ const current = await deps.meta(conversationId);
131
+ const model = (await api.agent(context)).model;
132
+ const provider = model?.provider;
133
+ const item = await itemForHandle(deps.db, args.handle);
134
+ if (item === undefined || (await permitted(deps, current, item, provider)) === undefined) {
135
+ return output("Not available here.");
136
+ }
137
+ taint(deps.db, conversationId, item);
138
+ if (args.handle.startsWith("a:")) {
139
+ const length = Math.min(args.length ?? OPEN_LIMIT, OPEN_LIMIT);
140
+ const text = deps.artifactStore.read(args.handle, args.offset ?? 0, length);
141
+ return text === undefined ? output("Not available here.") : output(text);
142
+ }
143
+ if (item.entryId === null)
144
+ return output("Not available here.");
145
+ const page = await deps
146
+ .entries(item.conversationId, {
147
+ minEntryId: Math.max(1, item.entryId - 3),
148
+ maxEntryId: item.entryId + 3,
149
+ order: "ascending",
150
+ }, 10, undefined)
151
+ .catch(() => undefined);
152
+ if (page === undefined)
153
+ return output("Not available here.");
154
+ const nearby = deps.db.itemsForEntries(item.conversationId, page.items.map((entry) => entry.id));
155
+ for (const candidate of nearby) {
156
+ if ((await permitted(deps, current, candidate, provider)) === undefined) {
157
+ return output("Not available here.");
158
+ }
159
+ }
160
+ for (const candidate of nearby)
161
+ taint(deps.db, conversationId, candidate);
162
+ const text = renderEntries(page.items);
163
+ return output(text === "" ? "Not available here." : text);
164
+ },
165
+ });
166
+ return [search, open];
167
+ }
168
+ async function itemForHandle(db, handle) {
169
+ if (handle.startsWith("r:"))
170
+ return db.item(Number(handle.slice(2)));
171
+ if (handle.startsWith("a:"))
172
+ return db.itemByArtifact(handle);
173
+ return undefined;
174
+ }
@@ -0,0 +1,17 @@
1
+ import type { Embedder } from "./embed.js";
2
+ import type { RecallDb } from "./db.js";
3
+ import type { EntryRecord } from "@earendil-works/pi-durable";
4
+ import type { VectorArm } from "./search.js";
5
+ export interface VectorIndex {
6
+ upsert(itemId: number, vec: Float32Array): void;
7
+ query(vec: Float32Array, k: number, signal?: AbortSignal): {
8
+ itemId: number;
9
+ score: number;
10
+ }[];
11
+ }
12
+ /** Store vectors in SQLite and scan bounded pages for cosine nearest neighbours. */
13
+ export declare function createSqliteVectorIndex(db: RecallDb, pageSize?: number): VectorIndex;
14
+ /** Embed up to `limit` unembedded conversation passages from their source entries. */
15
+ export declare function embedPending(db: RecallDb, embedder: Embedder, index: VectorIndex, entry: (conversationId: number, entryId: number) => Promise<EntryRecord | undefined>, limit?: number): Promise<void>;
16
+ /** Add a semantic arm that degrades to lexical search on embedding failure. */
17
+ export declare function createVectorArm(embedder: Embedder, index: VectorIndex, timeoutMs: number): VectorArm;
@@ -0,0 +1,105 @@
1
+ import { passagesOf } from "./passages.js";
2
+ import { redact } from "./redact.js";
3
+ /** Store vectors in SQLite and scan bounded pages for cosine nearest neighbours. */
4
+ export function createSqliteVectorIndex(db, pageSize = 2000) {
5
+ return {
6
+ upsert(itemId, vec) {
7
+ db.upsertVector(itemId, vec);
8
+ },
9
+ query(vec, k, signal) {
10
+ if (k <= 0)
11
+ return [];
12
+ const best = [];
13
+ let after = 0;
14
+ for (let page = 0; page < 50; page += 1) {
15
+ if (signal?.aborted === true)
16
+ break;
17
+ const rows = db.listVectorsPage(after, pageSize);
18
+ for (const row of rows) {
19
+ const score = cosine(vec, row.vec);
20
+ best.push({ itemId: row.itemId, score });
21
+ best.sort((a, b) => b.score - a.score);
22
+ if (best.length > k)
23
+ best.pop();
24
+ }
25
+ if (rows.length < pageSize)
26
+ break;
27
+ after = rows.at(-1).itemId;
28
+ }
29
+ return best;
30
+ },
31
+ };
32
+ }
33
+ function cosine(left, right) {
34
+ const rightVector = new Float32Array(right.buffer.slice(right.byteOffset, right.byteOffset + right.byteLength));
35
+ if (left.length !== rightVector.length)
36
+ return -1;
37
+ let dot = 0;
38
+ let leftNorm = 0;
39
+ let rightNorm = 0;
40
+ for (let index = 0; index < left.length; index += 1) {
41
+ dot += left[index] * rightVector[index];
42
+ leftNorm += left[index] ** 2;
43
+ rightNorm += rightVector[index] ** 2;
44
+ }
45
+ if (leftNorm === 0 || rightNorm === 0)
46
+ return 0;
47
+ return dot / Math.sqrt(leftNorm * rightNorm);
48
+ }
49
+ /** Embed up to `limit` unembedded conversation passages from their source entries. */
50
+ export async function embedPending(db, embedder, index, entry, limit = 500) {
51
+ const candidates = [];
52
+ for (const item of db.listUnembeddedItems(limit, embedder.local)) {
53
+ if (item.entryId === null ||
54
+ item.messageIndex === null) {
55
+ continue;
56
+ }
57
+ const record = await entry(item.conversationId, item.entryId);
58
+ if (record === undefined)
59
+ continue;
60
+ const passage = passagesOf(record).find((candidate) => candidate.messageIndex === item.messageIndex && candidate.offset === item.offset);
61
+ if (passage !== undefined)
62
+ candidates.push({ id: item.id, text: redact(passage.text) });
63
+ }
64
+ for (let offset = 0; offset < candidates.length; offset += 64) {
65
+ const batch = candidates.slice(offset, offset + 64);
66
+ try {
67
+ const vectors = await embedder.embed(batch.map(({ text }) => text));
68
+ for (const [position, vector] of vectors.entries()) {
69
+ const candidate = batch[position];
70
+ if (candidate !== undefined)
71
+ index.upsert(candidate.id, vector);
72
+ }
73
+ }
74
+ catch (error) {
75
+ console.error("recall: pending passage embedding failed", error);
76
+ }
77
+ }
78
+ }
79
+ /** Add a semantic arm that degrades to lexical search on embedding failure. */
80
+ export function createVectorArm(embedder, index, timeoutMs) {
81
+ let lastWarning = -Infinity;
82
+ return {
83
+ async query(text, candidateLimit) {
84
+ const signal = AbortSignal.timeout(timeoutMs);
85
+ try {
86
+ const vectors = await embedder.embed([text], signal);
87
+ const vector = vectors[0];
88
+ if (vector === undefined)
89
+ return [];
90
+ return index.query(vector, candidateLimit, signal).map(({ itemId, score }) => ({
91
+ itemId,
92
+ rank: score,
93
+ }));
94
+ }
95
+ catch (error) {
96
+ const now = Date.now();
97
+ if (now - lastWarning >= 60_000) {
98
+ console.warn("recall: semantic search unavailable; using lexical results", error);
99
+ lastWarning = now;
100
+ }
101
+ return [];
102
+ }
103
+ },
104
+ };
105
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
- "name": "@sigrun/recall",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@sigrun/recall",
3
+ "version": "0.1.1",
4
+ "description": "Cross-conversation recall extension for sigrun (pi-durable)",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "scripts": {
22
+ "test": "node scripts/check-manifest.mjs --expect-name @sigrun/recall && vitest run",
23
+ "typecheck": "tsc --noEmit",
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "prepare": "npm run build"
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/chord": "1.4.0",
29
+ "@earendil-works/pi-ai": "1.4.0",
30
+ "@earendil-works/pi-durable": "1.4.0",
31
+ "@sigrun/shared": "0.5.0"
32
+ },
33
+ "devDependencies": {
34
+ "@earendil-works/chord": "1.4.0",
35
+ "@earendil-works/pi-ai": "1.4.0",
36
+ "@earendil-works/pi-durable": "1.4.0",
37
+ "@sigrun/sigrun": "file:../sigrun",
38
+ "@types/node": "^26.6.4",
39
+ "tsx": "^4.23.15",
40
+ "typescript": "^7.0.2",
41
+ "vitest": "^5.0.3",
42
+ "@sigrun/shared": "file:../sigrun/shared"
43
+ },
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "github",
47
+ "url": "git+https://github.com/sigrun-ai/sigrun-recall.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/sigrun-ai/sigrun-recall/issues"
51
+ },
52
+ "homepage": "https://github.com/sigrun-ai/sigrun-recall#readme",
53
+ "publishConfig": {
54
+ "access": "public",
55
+ "provenance": true
56
+ }
57
+ }