@sigrun/knowledge 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,585 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mirrorEntities } from "./mirror.js";
3
+ import { retypeEntities } from "./apply.js";
4
+ import { effectiveFields, TYPE_NAME_PATTERN, } from "./schema.js";
5
+ const REINDEX_LIMIT = 50;
6
+ const RETYPE_LIMIT = 50;
7
+ const DECLINED_TTL_MS = 30 * 24 * 60 * 60 * 1000;
8
+ const MIN_GROUP_SIZE = 3;
9
+ const MIN_JACCARD = 0.5;
10
+ function writeVersion(db, changeId, table, rowId, before, after, at) {
11
+ db.raw()
12
+ .prepare('INSERT INTO versions (changeId, "table", rowId, before, after, at) VALUES (?, ?, ?, ?, ?, ?)')
13
+ .run(changeId, table, rowId, before === undefined || before === null ? null : JSON.stringify(before), after === undefined || after === null ? null : JSON.stringify(after), at);
14
+ }
15
+ /** Jaccard similarity of two field-name sets. */
16
+ export function jaccard(a, b) {
17
+ const setA = new Set(a);
18
+ const setB = new Set(b);
19
+ let common = 0;
20
+ for (const name of setA)
21
+ if (setB.has(name))
22
+ common += 1;
23
+ const union = setA.size + setB.size - common;
24
+ return union === 0 ? 0 : common / union;
25
+ }
26
+ function loadLiveTypes(db) {
27
+ return db
28
+ .raw()
29
+ .prepare(`SELECT DISTINCT t.id, t.name, t.description, t.parentId, t.fields, t.archived
30
+ FROM types t
31
+ WHERE t.archived = 0 AND NOT EXISTS (
32
+ SELECT 1 FROM types c
33
+ WHERE c.archived = 0 AND c.parentId = t.id)`)
34
+ // SAFETY: the types table columns are fixed by db.ts's schema; node:sqlite
35
+ // types rows generically.
36
+ .all();
37
+ }
38
+ function entityCount(db, typeId) {
39
+ const row = db
40
+ .raw()
41
+ .prepare("SELECT COUNT(*) AS n FROM entities WHERE typeId = ? AND archived = 0")
42
+ .get(typeId);
43
+ return row.n;
44
+ }
45
+ function entityIdsOf(db, typeId) {
46
+ return db
47
+ .raw()
48
+ .prepare("SELECT id FROM entities WHERE typeId = ? AND archived = 0 ORDER BY id")
49
+ .all(typeId).map((row) => row.id);
50
+ }
51
+ /** The group key for the 30-day declined memory. */
52
+ function groupKey(names) {
53
+ return [...names].sort().join("|");
54
+ }
55
+ function isDeclined(db, key, now) {
56
+ // Expired declines are pruned as they age out.
57
+ db.raw()
58
+ .prepare("DELETE FROM maintenance WHERE kind = 'declined-generalization' AND at < ?")
59
+ .run(now - DECLINED_TTL_MS);
60
+ const rows = db
61
+ .raw()
62
+ .prepare("SELECT payload, at FROM maintenance WHERE kind = 'declined-generalization'")
63
+ .all();
64
+ // The group's most recent decline within the TTL wins; an older
65
+ // expired decline is a re-ask.
66
+ let declinedAt;
67
+ for (const row of rows) {
68
+ const payload = JSON.parse(row.payload);
69
+ if (payload.group === key) {
70
+ declinedAt = declinedAt === undefined ? row.at : Math.max(declinedAt, row.at);
71
+ }
72
+ }
73
+ return declinedAt !== undefined && now - declinedAt <= DECLINED_TTL_MS;
74
+ }
75
+ function rememberDeclined(db, key, now) {
76
+ db.raw()
77
+ .prepare("INSERT INTO maintenance (kind, changeId, payload, cursor, at) VALUES ('declined-generalization', NULL, ?, NULL, ?)")
78
+ .run(JSON.stringify({ group: key }), now);
79
+ }
80
+ /** node:sqlite reports a UNIQUE violation as a message match. */
81
+ function isUniqueKeyViolation(error) {
82
+ return (error instanceof Error &&
83
+ /UNIQUE constraint failed/.test(error.message) &&
84
+ error.message.includes("entity_keys"));
85
+ }
86
+ /**
87
+ * Drop the entity's key rows whose (key, normalized) already exists at
88
+ * the target family's root — the normalized identity is already keyed
89
+ * there, so our row is a duplicate. Each drop is versioned (before,
90
+ * after null) so undo can restore it. Returns true when every
91
+ * conflicting row was dropped.
92
+ */
93
+ function dropConflictingKeys(db, entityId, targetTypeName, changeId, now) {
94
+ // Resolve the target family's root id.
95
+ const rootRow = db
96
+ .raw()
97
+ .prepare("SELECT id, parentId FROM types WHERE name = ? AND archived = 0")
98
+ .get(targetTypeName);
99
+ if (rootRow === undefined)
100
+ return false;
101
+ let rootId = rootRow.id;
102
+ let guard = 0;
103
+ while (guard++ < 32) {
104
+ const next = db
105
+ .raw()
106
+ .prepare("SELECT id, parentId FROM types WHERE id = ?")
107
+ .get(rootId);
108
+ if (next === undefined || next.parentId === null)
109
+ break;
110
+ rootId = next.parentId;
111
+ }
112
+ const mine = db
113
+ .raw()
114
+ .prepare("SELECT id, entityId, typeFamilyRoot, key, normalized FROM entity_keys WHERE entityId = ?")
115
+ .all(entityId);
116
+ for (const keyRow of mine) {
117
+ const collision = db
118
+ .raw()
119
+ .prepare("SELECT id FROM entity_keys WHERE typeFamilyRoot = ? AND key = ? AND normalized = ? AND id != ?")
120
+ .get(rootId, keyRow.key, keyRow.normalized, keyRow.id);
121
+ if (collision === undefined)
122
+ continue;
123
+ writeVersion(db, changeId, "entity_keys", String(keyRow.id), keyRow, null, now);
124
+ db.raw()
125
+ .prepare("DELETE FROM entity_keys WHERE id = ?")
126
+ .run(keyRow.id);
127
+ }
128
+ return true;
129
+ }
130
+ function loadContinuation(db) {
131
+ const row = db
132
+ .raw()
133
+ .prepare("SELECT changeId, payload FROM maintenance WHERE kind = 'retype-continuation' ORDER BY id DESC LIMIT 1")
134
+ .get();
135
+ if (row === undefined || row.changeId === null)
136
+ return undefined;
137
+ const payload = JSON.parse(row.payload);
138
+ if (payload.parentType === undefined)
139
+ return undefined;
140
+ return {
141
+ changeId: row.changeId,
142
+ parentType: payload.parentType,
143
+ remaining: payload.remaining ?? [],
144
+ };
145
+ }
146
+ function saveContinuation(db, changeId, parentType, remaining, now) {
147
+ const existing = db
148
+ .raw()
149
+ .prepare("SELECT id FROM maintenance WHERE kind = 'retype-continuation' ORDER BY id DESC LIMIT 1")
150
+ .get();
151
+ if (existing !== undefined) {
152
+ db.raw()
153
+ .prepare("DELETE FROM maintenance WHERE id = ?")
154
+ .run(existing.id);
155
+ }
156
+ db.raw()
157
+ .prepare("INSERT INTO maintenance (kind, changeId, payload, cursor, at) VALUES ('retype-continuation', ?, ?, NULL, ?)")
158
+ .run(changeId, JSON.stringify({ parentType, remaining }), now);
159
+ }
160
+ function dropContinuation(db) {
161
+ db.raw()
162
+ .prepare("DELETE FROM maintenance WHERE kind = 'retype-continuation'")
163
+ .run();
164
+ }
165
+ function generalizePrompt(types) {
166
+ const lines = types
167
+ .map((type) => `- ${type.name}: fields ${type.fields.map((field) => field.name).join(", ") || "(none)"}`)
168
+ .join("\n");
169
+ return [
170
+ "These knowledge types look like siblings. Decide whether they are kinds of ONE general type.",
171
+ lines,
172
+ "",
173
+ 'Answer ONLY JSON: {"general": true, "name": string, "description": string, "fields": [{"name": string, "type": "string"|"number"|"boolean"|"date"|"url"|"money"|"enum"|"list", "horizonDays"?: number}]} — the parent name (a lowercase singular phrase), description, and the shared fields each child already carries.',
174
+ 'Or {"general": false}.',
175
+ ].join("\n");
176
+ }
177
+ function parseAnswer(text) {
178
+ const start = text.indexOf("{");
179
+ const end = text.lastIndexOf("}");
180
+ if (start === -1 || end === -1 || end < start)
181
+ return undefined;
182
+ let parsed;
183
+ try {
184
+ parsed = JSON.parse(text.slice(start, end + 1));
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ if (typeof parsed !== "object" || parsed === null)
190
+ return undefined;
191
+ const raw = parsed;
192
+ if (raw.general !== true)
193
+ return { general: false };
194
+ if (typeof raw.name !== "string" || raw.name.trim() === "")
195
+ return undefined;
196
+ if (typeof raw.description !== "string")
197
+ return undefined;
198
+ if (!Array.isArray(raw.fields))
199
+ return undefined;
200
+ const fields = [];
201
+ for (const field of raw.fields) {
202
+ if (typeof field.name !== "string" || typeof field.type !== "string") {
203
+ return undefined;
204
+ }
205
+ const def = { name: field.name, type: field.type };
206
+ if (typeof field.horizonDays === "number")
207
+ def.horizonDays = field.horizonDays;
208
+ if (typeof field.description === "string")
209
+ def.description = field.description;
210
+ if (typeof field.identity === "boolean")
211
+ def.identity = field.identity;
212
+ if (field.type === "list" && typeof field.item === "string") {
213
+ def.item = field.item;
214
+ }
215
+ if (Array.isArray(field.values)) {
216
+ def.values = field.values.filter((v) => typeof v === "string");
217
+ }
218
+ fields.push(def);
219
+ }
220
+ return {
221
+ general: true,
222
+ name: raw.name.trim(),
223
+ description: raw.description,
224
+ fields,
225
+ };
226
+ }
227
+ /**
228
+ * The idle pass, in order, stopping at the deadline:
229
+ * 1. re-index up to 50 pending/stale entities (not reported);
230
+ * 2. continue an unfinished re-typing before proposing anything new;
231
+ * 3. propose one generalization via budget.run (needs runs >= 1);
232
+ * 4. apply it as a hierarchy in one change, folding field-less children
233
+ * (<= 50 entities per pass; the rest continue next pass under the
234
+ * same change id, reported with more: true).
235
+ * At most one generalization per pass.
236
+ */
237
+ export async function maintain(user, ctx, budget, deps) {
238
+ void ctx;
239
+ const db = deps.dbForUser(user);
240
+ const changes = [];
241
+ let more = false;
242
+ // 1. Re-index pending entities: indexedAt behind their last version,
243
+ // never indexed, or whose card text changed because a field
244
+ // crossed its horizon since the index stamp.
245
+ const now = deps.now();
246
+ const pending = db
247
+ .raw()
248
+ .prepare(`SELECT e.id, e.typeId, e.fields, e.indexedAt FROM entities e
249
+ WHERE e.archived = 0 AND (
250
+ e.indexedAt IS NULL OR e.indexedAt < COALESCE((
251
+ SELECT MAX(v.at) FROM versions v
252
+ WHERE v."table" = 'entities' AND v.rowId = e.id
253
+ ), 0))
254
+ ORDER BY e.id LIMIT ?`)
255
+ .all(REINDEX_LIMIT);
256
+ const pendingIds = new Set(pending.map((row) => row.id));
257
+ if (pendingIds.size < REINDEX_LIMIT) {
258
+ // Horizon-crossed: a field went stale (or came back) since the
259
+ // index, so the mirrored card text no longer matches.
260
+ const typeRows = db
261
+ .raw()
262
+ .prepare("SELECT id, parentId, fields FROM types")
263
+ // SAFETY: types columns are fixed by db.ts's schema.
264
+ .all();
265
+ const typesById = new Map(typeRows.map((type) => [type.id, type]));
266
+ const candidates = db
267
+ .raw()
268
+ .prepare("SELECT id, typeId, fields, indexedAt FROM entities WHERE archived = 0 AND indexedAt IS NOT NULL ORDER BY id")
269
+ .all();
270
+ for (const candidate of candidates) {
271
+ if (pendingIds.size >= REINDEX_LIMIT)
272
+ break;
273
+ if (pendingIds.has(candidate.id))
274
+ continue;
275
+ const defs = new Map(effectiveFields(typesById, candidate.typeId).map((def) => [
276
+ def.name,
277
+ def,
278
+ ]));
279
+ const fields = JSON.parse(candidate.fields);
280
+ for (const [fieldName, field] of Object.entries(fields)) {
281
+ const def = defs.get(fieldName);
282
+ if (def?.horizonDays === undefined)
283
+ continue;
284
+ const observedMs = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(field.observedAt)
285
+ ? `${field.observedAt}T00:00:00Z`
286
+ : field.observedAt);
287
+ if (Number.isNaN(observedMs))
288
+ continue;
289
+ const staleAt = observedMs + def.horizonDays * 24 * 60 * 60 * 1000;
290
+ // The horizon sits between the index stamp and now: the card
291
+ // gained (or would lose) its stale mark since indexing.
292
+ if (candidate.indexedAt < staleAt && now >= staleAt) {
293
+ pendingIds.add(candidate.id);
294
+ break;
295
+ }
296
+ }
297
+ }
298
+ }
299
+ if (pendingIds.size > 0) {
300
+ await mirrorEntities(deps.host, db, user, [...pendingIds], now);
301
+ }
302
+ if (deps.now() > budget.deadline) {
303
+ return { changes, ...(more ? { more: true } : {}) };
304
+ }
305
+ // 2. Continue an unfinished re-typing before proposing anything new.
306
+ const continuation = loadContinuation(db);
307
+ if (continuation !== undefined) {
308
+ const batch = continuation.remaining.slice(0, RETYPE_LIMIT);
309
+ const rest = continuation.remaining.slice(RETYPE_LIMIT);
310
+ if (batch.length > 0) {
311
+ const typeExists = db
312
+ .raw()
313
+ .prepare("SELECT 1 AS x FROM types WHERE name = ? AND archived = 0")
314
+ .get(continuation.parentType);
315
+ if (typeExists === undefined) {
316
+ // The fold target vanished (archived/renamed mid-fold): the
317
+ // continuation can never finish — drop it rather than throw
318
+ // forever.
319
+ console.error(`knowledge: dropping re-type continuation into "${continuation.parentType}" (type no longer live)`);
320
+ dropContinuation(db);
321
+ return { changes, ...(more ? { more: true } : {}) };
322
+ }
323
+ try {
324
+ retypeEntities(db, batch, continuation.parentType, continuation.changeId, deps.now());
325
+ }
326
+ catch (error) {
327
+ // One bad row (a key collision with the new family root)
328
+ // must not block the other 49: retry per entity.
329
+ console.error("knowledge: continuation re-type batch failed", error);
330
+ for (const entityId of batch) {
331
+ try {
332
+ retypeEntities(db, [entityId], continuation.parentType, continuation.changeId, deps.now());
333
+ }
334
+ catch (single) {
335
+ // The entity must not park the continuation forever.
336
+ // A key collision with a row already at the new root
337
+ // (same normalized identity, e.g. another folded
338
+ // sibling's entity) resolves by dropping OUR duplicate
339
+ // key row (versioned, mirroring the step-4 retyped
340
+ // loop) and retrying. Any other failure removes the
341
+ // entity from the continuation with a log line.
342
+ if (!isUniqueKeyViolation(single) || !dropConflictingKeys(db, entityId, continuation.parentType, continuation.changeId, deps.now())) {
343
+ console.error(`knowledge: re-type of entity ${entityId} failed; removing it from the continuation`, single);
344
+ continue;
345
+ }
346
+ try {
347
+ retypeEntities(db, [entityId], continuation.parentType, continuation.changeId, deps.now());
348
+ }
349
+ catch (retry) {
350
+ console.error(`knowledge: re-type of entity ${entityId} failed after key drop; removing it from the continuation`, retry);
351
+ }
352
+ }
353
+ }
354
+ }
355
+ await mirrorEntities(deps.host, db, user, batch, deps.now());
356
+ }
357
+ const remaining = rest;
358
+ if (remaining.length > 0) {
359
+ saveContinuation(db, continuation.changeId, continuation.parentType, remaining, deps.now());
360
+ more = true;
361
+ }
362
+ else {
363
+ dropContinuation(db);
364
+ changes.push({
365
+ changeId: continuation.changeId,
366
+ summary: `re-typed remaining entities into ${continuation.parentType}`,
367
+ });
368
+ }
369
+ return { changes, ...(more ? { more: true } : {}) };
370
+ }
371
+ // 3. Propose (needs budget).
372
+ if (budget.runs < 1)
373
+ return { changes, ...(more ? { more: true } : {}) };
374
+ if (deps.now() > budget.deadline) {
375
+ return { changes, ...(more ? { more: true } : {}) };
376
+ }
377
+ // Find sibling groups: same parent (or both root), not archived,
378
+ // >= 3 types, pairwise field-name Jaccard >= 0.5. Groups remembered
379
+ // as declined are filtered BEFORE picking the max so a declined best
380
+ // group doesn't starve the others.
381
+ const types = loadLiveTypes(db);
382
+ const siblings = new Map();
383
+ for (const type of types) {
384
+ const key = type.parentId === null ? "root" : String(type.parentId);
385
+ const list = siblings.get(key) ?? [];
386
+ list.push(type);
387
+ siblings.set(key, list);
388
+ }
389
+ let best;
390
+ for (const group of siblings.values()) {
391
+ if (group.length < MIN_GROUP_SIZE)
392
+ continue;
393
+ const names = group.map((type) => JSON.parse(type.fields).map((field) => field.name));
394
+ let ok = true;
395
+ for (let i = 0; i < group.length && ok; i++) {
396
+ for (let j = i + 1; j < group.length; j++) {
397
+ if (jaccard(names[i], names[j]) < MIN_JACCARD) {
398
+ ok = false;
399
+ break;
400
+ }
401
+ }
402
+ }
403
+ if (!ok)
404
+ continue;
405
+ if (isDeclined(db, groupKey(group.map((type) => type.name)), now))
406
+ continue;
407
+ const count = group.reduce((sum, type) => sum + entityCount(db, type.id), 0);
408
+ if (best === undefined || count > best.entities)
409
+ best = { group, entities: count };
410
+ }
411
+ if (best === undefined)
412
+ return { changes, ...(more ? { more: true } : {}) };
413
+ const run = await budget.run(generalizePrompt(best.group.map((type) => ({
414
+ name: type.name,
415
+ fields: JSON.parse(type.fields),
416
+ }))), ["knowledge_find"]);
417
+ // A run that fails to produce a clean answer leaves the schema
418
+ // untouched; a JSON {general:false} or unparseable text is remembered
419
+ // for 30 days so the group isn't re-asked every pass. A dead/failed
420
+ // run is not remembered (transient).
421
+ if (run.status !== "completed") {
422
+ return { changes, ...(more ? { more: true } : {}) };
423
+ }
424
+ const answer = parseAnswer(run.text);
425
+ if (answer === undefined || answer.general === false) {
426
+ rememberDeclined(db, groupKey(best.group.map((type) => type.name)), deps.now());
427
+ return { changes, ...(more ? { more: true } : {}) };
428
+ }
429
+ // The answer must not rename onto ANY types row (the name column is
430
+ // UNIQUE across archived rows too) and must be a valid type name;
431
+ // decline in either case.
432
+ const nameTaken = db
433
+ .raw()
434
+ .prepare("SELECT 1 AS x FROM types WHERE name = ?")
435
+ .get(answer.name);
436
+ if (nameTaken !== undefined || !TYPE_NAME_PATTERN.test(answer.name)) {
437
+ rememberDeclined(db, groupKey(best.group.map((type) => type.name)), deps.now());
438
+ return { changes, ...(more ? { more: true } : {}) };
439
+ }
440
+ // 4. Apply as a hierarchy in ONE change.
441
+ if (deps.now() > budget.deadline) {
442
+ // A deadline is not an answer: keep the group's candidacy (and the
443
+ // paid run) by asking again next pass, without remembering a
444
+ // decline.
445
+ return { changes: [], more: true };
446
+ }
447
+ const changeId = `maintain:${randomUUID()}`;
448
+ const group = best.group;
449
+ const answerFieldNames = new Set(answer.fields.map((field) => field.name));
450
+ const folded = [];
451
+ const retyped = [];
452
+ for (const type of group) {
453
+ const own = JSON.parse(type.fields);
454
+ const distinct = own.filter((field) => !answerFieldNames.has(field.name));
455
+ if (distinct.length === 0)
456
+ folded.push(type);
457
+ else
458
+ retyped.push(type);
459
+ }
460
+ const foldedEntities = [];
461
+ db.transaction(() => {
462
+ // The parent. Shared fields come from the answer.
463
+ const parentDef = {
464
+ name: answer.name,
465
+ description: answer.description,
466
+ fields: answer.fields,
467
+ };
468
+ const inserted = db
469
+ .raw()
470
+ .prepare("INSERT INTO types (name, description, parentId, fields, version) VALUES (?, ?, NULL, ?, 1)")
471
+ .run(parentDef.name, parentDef.description, JSON.stringify(parentDef.fields));
472
+ const parentId = Number(inserted.lastInsertRowid);
473
+ writeVersion(db, changeId, "types", String(parentId), null, {
474
+ id: parentId,
475
+ name: parentDef.name,
476
+ description: parentDef.description,
477
+ parentId: null,
478
+ fields: JSON.stringify(parentDef.fields),
479
+ version: 1,
480
+ archived: 0,
481
+ }, now);
482
+ for (const type of retyped) {
483
+ const own = JSON.parse(type.fields);
484
+ const distinct = own.filter((field) => !answerFieldNames.has(field.name));
485
+ const before = {
486
+ id: type.id,
487
+ name: type.name,
488
+ description: type.description,
489
+ parentId: type.parentId,
490
+ fields: type.fields,
491
+ archived: type.archived,
492
+ };
493
+ const after = {
494
+ ...before,
495
+ parentId,
496
+ fields: JSON.stringify(distinct),
497
+ };
498
+ writeVersion(db, changeId, "types", String(type.id), before, after, now);
499
+ db.raw()
500
+ .prepare("UPDATE types SET parentId = ?, fields = ?, version = version + 1 WHERE id = ?")
501
+ .run(parentId, JSON.stringify(distinct), type.id);
502
+ // Re-parenting changes the child's identity family: its key
503
+ // rows must re-root at the new parent or the next record for
504
+ // the same key would duplicate the entity (apply matches by
505
+ // family root). The rows are selected by the child's ENTITIES —
506
+ // a child type's rows live at its old FAMILY root (the
507
+ // grandparent), not at its own type id, so a typeFamilyRoot
508
+ // select here would miss them. A collision with a key row
509
+ // already at the parent root (a folded sibling's key) resolves
510
+ // by keeping the folded sibling's row and moving ours — both
511
+ // point at the same normalized identity, and the unique index
512
+ // is on (root, key, normalized), so identical normalized values
513
+ // would collide: drop the child's duplicate row instead.
514
+ const keyRows = db
515
+ .raw()
516
+ .prepare("SELECT id, entityId, typeFamilyRoot, key, normalized FROM entity_keys WHERE entityId IN (SELECT id FROM entities WHERE typeId = ?)")
517
+ .all(type.id);
518
+ for (const keyRow of keyRows) {
519
+ const collision = db
520
+ .raw()
521
+ .prepare("SELECT id FROM entity_keys WHERE typeFamilyRoot = ? AND key = ? AND normalized = ? AND id != ?")
522
+ .get(parentId, keyRow.key, keyRow.normalized, keyRow.id);
523
+ if (collision !== undefined) {
524
+ // Same normalized identity already keyed at the new root:
525
+ // the child's row is a duplicate; drop it (versioned).
526
+ // The incumbent keeps its key row — a later record for the
527
+ // shared key merges into the incumbent, never into an
528
+ // entity the fold touched.
529
+ writeVersion(db, changeId, "entity_keys", String(keyRow.id), keyRow, null, now);
530
+ db.raw()
531
+ .prepare("DELETE FROM entity_keys WHERE id = ?")
532
+ .run(keyRow.id);
533
+ continue;
534
+ }
535
+ writeVersion(db, changeId, "entity_keys", String(keyRow.id), keyRow, { ...keyRow, typeFamilyRoot: parentId }, now);
536
+ db.raw()
537
+ .prepare("UPDATE entity_keys SET typeFamilyRoot = ? WHERE id = ?")
538
+ .run(parentId, keyRow.id);
539
+ }
540
+ }
541
+ for (const type of folded) {
542
+ const ids = entityIdsOf(db, type.id);
543
+ foldedEntities.push(...ids);
544
+ const before = {
545
+ id: type.id,
546
+ name: type.name,
547
+ description: type.description,
548
+ parentId: type.parentId,
549
+ fields: type.fields,
550
+ archived: type.archived,
551
+ };
552
+ const after = { ...before, archived: 1 };
553
+ writeVersion(db, changeId, "types", String(type.id), before, after, now);
554
+ db.raw()
555
+ .prepare("UPDATE types SET archived = 1, version = version + 1 WHERE id = ?")
556
+ .run(type.id);
557
+ }
558
+ });
559
+ const summary = `generalized ${group.map((type) => type.name).join(", ")} into ${answer.name}`;
560
+ changes.push({ changeId, summary });
561
+ // Fold: re-type up to 50 entities this pass; the rest continue next
562
+ // pass under the same change id.
563
+ const batch = foldedEntities.slice(0, RETYPE_LIMIT);
564
+ const rest = foldedEntities.slice(RETYPE_LIMIT);
565
+ if (batch.length > 0) {
566
+ try {
567
+ retypeEntities(db, batch, answer.name, changeId, now);
568
+ }
569
+ catch (error) {
570
+ // A re-type can fail when two sibling roots co-held the same
571
+ // identity key (the family-root UNIQUE constraint collides at
572
+ // the new parent). Keep the full remainder for the next pass
573
+ // rather than dropping the hierarchy half-applied.
574
+ console.error("knowledge: fold re-type failed", error);
575
+ saveContinuation(db, changeId, answer.name, foldedEntities, deps.now());
576
+ return { changes, more: true };
577
+ }
578
+ await mirrorEntities(deps.host, db, user, batch, deps.now());
579
+ }
580
+ if (rest.length > 0) {
581
+ saveContinuation(db, changeId, answer.name, rest, deps.now());
582
+ more = true;
583
+ }
584
+ return { changes, ...(more ? { more: true } : {}) };
585
+ }
@@ -0,0 +1,41 @@
1
+ import type { KnowledgeDb } from "./db.js";
2
+ /**
3
+ * The recall payload shapes this extension sends (mirrored locally — the
4
+ * plan's shapes, recall Task 7; knowledge treats recall as optional and
5
+ * never imports it).
6
+ */
7
+ export interface RecallIndexPayload {
8
+ id: string;
9
+ kind: "record";
10
+ title: string;
11
+ text: string;
12
+ date: string;
13
+ sources: string[];
14
+ }
15
+ type HookResult = {
16
+ status: "absent";
17
+ } | {
18
+ status: "ok";
19
+ value?: unknown;
20
+ } | {
21
+ status: "rejected" | "failed";
22
+ [key: string]: unknown;
23
+ };
24
+ export interface MirrorHost {
25
+ publishedHooks: {
26
+ call(name: string, payload: unknown, opts?: {
27
+ user?: string;
28
+ conversationId?: number;
29
+ }): Promise<HookResult>;
30
+ };
31
+ }
32
+ /**
33
+ * Mirror each touched entity into recall: live entities are indexed from
34
+ * their rendered card (kind "record"), archived ones removed. `ok` stores
35
+ * the returned class and stamps indexedAt; `absent` does nothing;
36
+ * `failed`/`rejected` leaves indexedAt behind so the next idle pass
37
+ * retries. Pass `user` explicitly — undo and maintenance have no
38
+ * conversation.
39
+ */
40
+ export declare function mirrorEntities(host: MirrorHost, db: KnowledgeDb, user: string, entityIds: readonly string[], now: number): Promise<void>;
41
+ export {};
package/dist/mirror.js ADDED
@@ -0,0 +1,59 @@
1
+ import { renderCard } from "./card.js";
2
+ /**
3
+ * Mirror each touched entity into recall: live entities are indexed from
4
+ * their rendered card (kind "record"), archived ones removed. `ok` stores
5
+ * the returned class and stamps indexedAt; `absent` does nothing;
6
+ * `failed`/`rejected` leaves indexedAt behind so the next idle pass
7
+ * retries. Pass `user` explicitly — undo and maintenance have no
8
+ * conversation.
9
+ */
10
+ export async function mirrorEntities(host, db, user, entityIds, now) {
11
+ for (const entityId of entityIds) {
12
+ const row = db
13
+ .raw()
14
+ .prepare("SELECT id, archived, indexedAt FROM entities WHERE id = ?")
15
+ .get(entityId);
16
+ if (row === undefined)
17
+ continue;
18
+ if (row.archived === 1) {
19
+ try {
20
+ await host.publishedHooks.call("recall.remove", { id: entityId }, { user });
21
+ }
22
+ catch (error) {
23
+ console.error("knowledge: recall.remove failed", error);
24
+ }
25
+ continue;
26
+ }
27
+ const card = renderCard(db, entityId, now);
28
+ const payload = {
29
+ id: entityId,
30
+ kind: "record",
31
+ title: card.title,
32
+ text: card.text,
33
+ date: card.date,
34
+ sources: card.sources,
35
+ };
36
+ let result;
37
+ try {
38
+ result = await host.publishedHooks.call("recall.index", payload, {
39
+ user,
40
+ });
41
+ }
42
+ catch (error) {
43
+ console.error("knowledge: recall.index failed", error);
44
+ continue;
45
+ }
46
+ if (result.status !== "ok") {
47
+ // A rejected/failed hook leaves indexedAt behind so the idle pass
48
+ // retries — but never silently: name the hook and status so the
49
+ // shape-mismatch class the e2e exposed surfaces immediately.
50
+ console.error(`knowledge: recall.index answered ${result.status} for entity ${entityId}`);
51
+ continue;
52
+ }
53
+ const value = result.value;
54
+ const cls = value?.class === "local-only" ? "local-only" : "normal";
55
+ db.raw()
56
+ .prepare("UPDATE entities SET cls = ?, indexedAt = ? WHERE id = ?")
57
+ .run(cls, now, entityId);
58
+ }
59
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The guidance bullets the curator's review prompt shows for the
3
+ * knowledge target (spec §6.3, plus the freshness ruling: fields that go
4
+ * out of date carry a horizon so staleness is visible).
5
+ */
6
+ export declare function guidance(): readonly string[];