@sigrun/knowledge 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,357 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool, } from "@earendil-works/pi-durable";
3
+ import { audienceCovers } from "./audience.js";
4
+ import { applyRecord } from "./apply.js";
5
+ import { TypeDefSchema } from "./schema.js";
6
+ import { effectiveFields } from "./schema.js";
7
+ import { mirrorEntities } from "./mirror.js";
8
+ const MAX_QUERIES = 10;
9
+ const DAY_MS = 24 * 60 * 60 * 1000;
10
+ function output(text) {
11
+ return { output: [{ type: "text", text }] };
12
+ }
13
+ function error(text) {
14
+ return { isError: true, output: [{ type: "text", text }] };
15
+ }
16
+ /** The fork-ancestry lookup: page the fork's entries newest-first until
17
+ * an entry stamped with a DIFFERENT conversation id appears — that is the
18
+ * reviewed parent. None (a plain conversation) → the id itself. */
19
+ async function parentConversation(host, conversationId) {
20
+ let cursor;
21
+ for (;;) {
22
+ const page = await host.conversations.entries(conversationId, { order: "descending" }, 50, cursor);
23
+ for (const entry of page.items) {
24
+ if (entry.conversationId !== conversationId) {
25
+ return entry.conversationId;
26
+ }
27
+ }
28
+ if (page.next === undefined)
29
+ return conversationId;
30
+ cursor = page.next;
31
+ }
32
+ }
33
+ function loadEntities(db) {
34
+ return db
35
+ .raw()
36
+ .prepare(`SELECT e.id, e.typeId, e.name, e.keys, e.fields, e.attributes, e.cls, e.indexedAt, e.archived,
37
+ t.name AS typeName, t.parentId AS typeParentId, t.fields AS typeFields
38
+ FROM entities e JOIN types t ON t.id = e.typeId
39
+ WHERE e.archived = 0`)
40
+ // SAFETY: the SELECT column list is fixed by the SQL literal above and
41
+ // matches StoredEntity's fields exactly; node:sqlite types rows
42
+ // generically.
43
+ .all();
44
+ }
45
+ /** All type ids in the family rooted-or-descended from `typeName`
46
+ * (type includes child types). */
47
+ function familyTypeIds(db, typeName) {
48
+ const types = db
49
+ .raw()
50
+ .prepare("SELECT id, parentId FROM types WHERE archived = 0")
51
+ .all();
52
+ const root = db
53
+ .raw()
54
+ .prepare("SELECT id FROM types WHERE name = ? AND archived = 0")
55
+ .get(typeName);
56
+ if (root === undefined)
57
+ return new Set();
58
+ const result = new Set([root.id]);
59
+ for (;;) {
60
+ let grew = false;
61
+ for (const type of types) {
62
+ if (type.parentId !== null &&
63
+ result.has(type.parentId) &&
64
+ !result.has(type.id)) {
65
+ result.add(type.id);
66
+ grew = true;
67
+ }
68
+ }
69
+ if (!grew)
70
+ return result;
71
+ }
72
+ }
73
+ function formatValue(value) {
74
+ if (typeof value === "object" &&
75
+ value !== null &&
76
+ !Array.isArray(value) &&
77
+ typeof value.amount === "number") {
78
+ const money = value;
79
+ return `${money.amount} ${money.currency}`;
80
+ }
81
+ if (Array.isArray(value))
82
+ return value.map(formatValue).join(", ");
83
+ return String(value);
84
+ }
85
+ function compareField(field, op, want) {
86
+ if (field === undefined)
87
+ return false;
88
+ const value = field.value;
89
+ // Money compares amounts within one currency only: a different
90
+ // currency never satisfies lt/gt/eq (a 30 USD fee is not under 50 CAD).
91
+ const isMoney = (v) => typeof v === "object" &&
92
+ v !== null &&
93
+ !Array.isArray(v) &&
94
+ typeof v.amount === "number";
95
+ if (isMoney(value) || isMoney(want)) {
96
+ if (!isMoney(value) || !isMoney(want))
97
+ return false;
98
+ if (value.currency.toUpperCase() !== want.currency.toUpperCase())
99
+ return false;
100
+ switch (op) {
101
+ case "eq":
102
+ return value.amount === want.amount;
103
+ case "lt":
104
+ return value.amount < want.amount;
105
+ case "gt":
106
+ return value.amount > want.amount;
107
+ case "contains":
108
+ return false;
109
+ }
110
+ }
111
+ switch (op) {
112
+ case "eq":
113
+ return JSON.stringify(value) === JSON.stringify(want);
114
+ case "lt":
115
+ return (typeof value === "number" &&
116
+ typeof want === "number" &&
117
+ value < want);
118
+ case "gt":
119
+ return (typeof value === "number" &&
120
+ typeof want === "number" &&
121
+ value > want);
122
+ case "contains": {
123
+ if (Array.isArray(value)) {
124
+ return value.some((item) => JSON.stringify(item) === JSON.stringify(want));
125
+ }
126
+ if (typeof value === "string" && typeof want === "string") {
127
+ return value.toLowerCase().includes(want.toLowerCase());
128
+ }
129
+ return false;
130
+ }
131
+ }
132
+ }
133
+ function likeFallback(db, text) {
134
+ const pattern = `%${text.replace(/[%_]/g, (c) => `\\${c}`)}%`;
135
+ const rows = db
136
+ .raw()
137
+ .prepare(`SELECT e.id FROM entities e
138
+ WHERE e.archived = 0 AND (
139
+ e.name LIKE ? ESCAPE '\\' OR
140
+ e.keys LIKE ? ESCAPE '\\' OR
141
+ e.fields LIKE ? ESCAPE '\\')`)
142
+ .all(pattern, pattern, pattern);
143
+ return new Set(rows.map((row) => row.id));
144
+ }
145
+ function renderEntity(db, row, now) {
146
+ // Inherited fields carry their defs (horizonDays) down the hierarchy.
147
+ const typeRows = db
148
+ .raw()
149
+ .prepare("SELECT id, parentId, fields FROM types")
150
+ // SAFETY: the types table columns are fixed by db.ts's schema;
151
+ // node:sqlite types rows generically.
152
+ .all();
153
+ const typeFields = effectiveFields(new Map(typeRows.map((type) => [type.id, type])), row.typeId);
154
+ const fields = JSON.parse(row.fields);
155
+ const lines = [`${row.typeName} — ${row.name}`];
156
+ for (const [name, field] of Object.entries(fields)) {
157
+ const def = typeFields.find((candidate) => candidate.name === name);
158
+ const observedDate = field.observedAt.slice(0, 10);
159
+ const observedMs = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(field.observedAt)
160
+ ? `${field.observedAt}T00:00:00Z`
161
+ : field.observedAt);
162
+ let observed = `observed ${observedDate}`;
163
+ if (def?.horizonDays !== undefined &&
164
+ !Number.isNaN(observedMs) &&
165
+ now - observedMs > def.horizonDays * DAY_MS) {
166
+ observed += ", stale";
167
+ }
168
+ lines.push(` ${name}: ${formatValue(field.value)} (${observed}, ${field.source})`);
169
+ }
170
+ const roles = db
171
+ .raw()
172
+ .prepare(`SELECT i.question AS question, r.role AS role, r.reason AS reason
173
+ FROM roles r JOIN inquiries i ON i.id = r.inquiryId
174
+ WHERE r.entityId = ? AND i.archived = 0
175
+ ORDER BY i.question`)
176
+ .all(row.id);
177
+ for (const role of roles) {
178
+ const reason = role.reason === null ? "" : ` — ${role.reason}`;
179
+ lines.push(` ${role.question}: ${role.role}${reason}`);
180
+ }
181
+ return lines.join("\n");
182
+ }
183
+ export function createTools(deps) {
184
+ const record = defineTool({
185
+ name: "knowledge_record",
186
+ description: "Record a curated knowledge update: an inquiry, any new or extended types, and the entities with their roles and cited fields, applied atomically.",
187
+ parameters: Type.Object({
188
+ inquiry: Type.Optional(Type.Object({
189
+ id: Type.Optional(Type.String()),
190
+ question: Type.String(),
191
+ why: Type.Optional(Type.String()),
192
+ outcome: Type.Optional(Type.Object({
193
+ status: Type.Union([
194
+ Type.Literal("open"),
195
+ Type.Literal("decided"),
196
+ Type.Literal("abandoned"),
197
+ ]),
198
+ what: Type.Optional(Type.String()),
199
+ })),
200
+ })),
201
+ types: Type.Optional(Type.Array(TypeDefSchema, { maxItems: 10 })),
202
+ entities: Type.Array(Type.Object({
203
+ id: Type.Optional(Type.String()),
204
+ type: Type.String(),
205
+ name: Type.String(),
206
+ keys: Type.Record(Type.String(), Type.String()),
207
+ fields: Type.Record(Type.String(), Type.Object({
208
+ value: Type.Unknown(),
209
+ source: Type.String(),
210
+ observedAt: Type.String(),
211
+ })),
212
+ attributes: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
213
+ role: Type.Optional(Type.Union([
214
+ Type.Literal("candidate"),
215
+ Type.Literal("shortlisted"),
216
+ Type.Literal("rejected"),
217
+ Type.Literal("chosen"),
218
+ ])),
219
+ reason: Type.Optional(Type.String()),
220
+ }), { maxItems: 50 }),
221
+ }),
222
+ replay: "safe",
223
+ async execute(args, api) {
224
+ const conversationId = Number(api.conversationId);
225
+ const user = await deps.host.identity.actingUser(conversationId);
226
+ if (user === undefined) {
227
+ return error("knowledge_record needs a user (the conversation owner).");
228
+ }
229
+ const parentId = await parentConversation(deps.host, conversationId);
230
+ const db = deps.dbForUser(user);
231
+ // Replay: applyRecord caches by callId; a replayed result must NOT
232
+ // re-mirror (idempotent end to end, plan Review Focus 5).
233
+ const replayed = db
234
+ .raw()
235
+ .prepare("SELECT result FROM applied WHERE callId = ?")
236
+ // SAFETY: `applied.result` is a TEXT column written as JSON by
237
+ // applyRecord; node:sqlite types rows generically.
238
+ .get(api.callId);
239
+ const result = applyRecord(db, args, {
240
+ conversationId: parentId,
241
+ callId: api.callId,
242
+ now: deps.now(),
243
+ });
244
+ if (result.errors.length > 0) {
245
+ return error(result.errors.join("\n"));
246
+ }
247
+ if (replayed === undefined) {
248
+ await mirrorEntities(deps.host, db, user, result.touched, deps.now());
249
+ }
250
+ return {
251
+ output: [{ type: "text", text: result.summary }],
252
+ details: {
253
+ curation: {
254
+ changeId: result.changeId,
255
+ summary: result.summary,
256
+ },
257
+ },
258
+ };
259
+ },
260
+ });
261
+ const find = defineTool({
262
+ name: "knowledge_find",
263
+ description: "Search curated knowledge: types, entities (with their cited fields, roles and inquiries) by type, field comparison, inquiry question or text.",
264
+ parameters: Type.Object({
265
+ queries: Type.Array(Type.Object({
266
+ text: Type.Optional(Type.String()),
267
+ type: Type.Optional(Type.String()),
268
+ field: Type.Optional(Type.Object({
269
+ name: Type.String(),
270
+ op: Type.Union([
271
+ Type.Literal("eq"),
272
+ Type.Literal("lt"),
273
+ Type.Literal("gt"),
274
+ Type.Literal("contains"),
275
+ ]),
276
+ value: Type.Unknown(),
277
+ })),
278
+ inquiry: Type.Optional(Type.String()),
279
+ }), { minItems: 1, maxItems: MAX_QUERIES }),
280
+ }),
281
+ replay: "safe",
282
+ async execute(args, api, context) {
283
+ const conversationId = Number(api.conversationId);
284
+ const meta = await deps.host.conversations.meta(conversationId);
285
+ const user = await deps.host.identity.actingUser(conversationId);
286
+ if (user === undefined || !audienceCovers(meta, { owner: user })) {
287
+ return output("Not available here.");
288
+ }
289
+ const db = deps.dbForUser(user);
290
+ const all = loadEntities(db);
291
+ const lines = [];
292
+ for (const query of args.queries) {
293
+ let candidates = all.filter((row) => row.cls !== "local-only");
294
+ if (query.type !== undefined) {
295
+ const family = familyTypeIds(db, query.type);
296
+ candidates = candidates.filter((row) => family.has(row.typeId));
297
+ }
298
+ if (query.field !== undefined) {
299
+ const wanted = query.field;
300
+ candidates = candidates.filter((row) => {
301
+ const fields = JSON.parse(row.fields);
302
+ return compareField(fields[wanted.name], wanted.op, wanted.value);
303
+ });
304
+ }
305
+ if (query.inquiry !== undefined) {
306
+ const needle = query.inquiry.toLowerCase();
307
+ const ids = new Set(db
308
+ .raw()
309
+ .prepare(`SELECT r.entityId FROM roles r JOIN inquiries i ON i.id = r.inquiryId
310
+ WHERE i.archived = 0 AND lower(i.question) LIKE ?`)
311
+ .all(`%${needle.replace(/[%_]/g, (c) => `\\${c}`)}%`).map((row) => row.entityId));
312
+ candidates = candidates.filter((row) => ids.has(row.id));
313
+ }
314
+ if (query.text !== undefined) {
315
+ let ids;
316
+ const model = (await api.agent(context)).model;
317
+ let searched = false;
318
+ try {
319
+ const result = await deps.host.publishedHooks.call("recall.search", {
320
+ query: query.text,
321
+ kinds: ["doc"],
322
+ model: model === undefined
323
+ ? undefined
324
+ : { provider: model.provider, modelId: model.modelId },
325
+ }, { user, conversationId });
326
+ if (result.status === "ok") {
327
+ searched = true;
328
+ const hits = result.value
329
+ ?.hits ?? [];
330
+ ids = new Set(hits
331
+ .filter((hit) => hit.provider === "knowledge")
332
+ .map((hit) => hit.id)
333
+ .filter((id) => typeof id === "string"));
334
+ }
335
+ }
336
+ catch (error) {
337
+ console.error("knowledge: recall.search failed", error);
338
+ }
339
+ if (!searched) {
340
+ ids = likeFallback(db, query.text);
341
+ }
342
+ candidates = candidates.filter((row) => ids.has(row.id));
343
+ }
344
+ if (candidates.length === 0) {
345
+ lines.push("No matching knowledge.");
346
+ }
347
+ else {
348
+ for (const row of candidates) {
349
+ lines.push(renderEntity(db, row, deps.now()));
350
+ }
351
+ }
352
+ }
353
+ return output(lines.join("\n\n"));
354
+ },
355
+ });
356
+ return [record, find];
357
+ }
package/dist/undo.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { KnowledgeDb } from "./db.js";
2
+ export interface UndoResult {
3
+ restored: number;
4
+ /** Human-readable lines for rows whose current state no longer matches. */
5
+ skipped: string[];
6
+ /** Entity ids the undo touched (restored or archived). */
7
+ touched: string[];
8
+ }
9
+ /**
10
+ * Undo a change: walk its versions newest-first and restore each row's
11
+ * `before` state — but only while the row still equals the change's
12
+ * `after`. A row a later change edited is skipped, so undo restores only
13
+ * what the change last wrote. Every restore is itself recorded as a
14
+ * version under `undo:<changeId>`, so nothing is lost.
15
+ */
16
+ export declare function undoChange(db: KnowledgeDb, changeId: string, now: number): UndoResult;