@jbarket/loomai 0.5.0 → 0.6.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 (94) hide show
  1. package/CHANGELOG.md +114 -1
  2. package/README.md +97 -0
  3. package/dist/backends/dedup.d.ts +23 -0
  4. package/dist/backends/dedup.js +100 -0
  5. package/dist/backends/embedding-runtime.js +38 -9
  6. package/dist/backends/episodes.js +1 -1
  7. package/dist/backends/fastembed.js +30 -4
  8. package/dist/backends/migrations.js +200 -0
  9. package/dist/backends/mmr.d.ts +8 -0
  10. package/dist/backends/mmr.js +18 -4
  11. package/dist/backends/people.d.ts +192 -0
  12. package/dist/backends/people.js +824 -0
  13. package/dist/backends/proposals.js +1 -1
  14. package/dist/backends/recorder-health.d.ts +34 -0
  15. package/dist/backends/recorder-health.js +117 -0
  16. package/dist/backends/salience.d.ts +39 -0
  17. package/dist/backends/salience.js +52 -4
  18. package/dist/backends/scope.d.ts +56 -0
  19. package/dist/backends/scope.js +75 -0
  20. package/dist/backends/sqlite-knowledge.d.ts +24 -4
  21. package/dist/backends/sqlite-knowledge.js +253 -91
  22. package/dist/backends/sqlite-vec.d.ts +32 -1
  23. package/dist/backends/sqlite-vec.js +394 -75
  24. package/dist/backends/types.d.ts +203 -3
  25. package/dist/categories.d.ts +2 -0
  26. package/dist/categories.js +2 -0
  27. package/dist/cli/args.d.ts +16 -1
  28. package/dist/cli/args.js +45 -1
  29. package/dist/cli/index.d.ts +7 -0
  30. package/dist/cli/index.js +57 -3
  31. package/dist/cli/knowledge.js +4 -1
  32. package/dist/cli/memory.js +2 -2
  33. package/dist/cli/person.d.ts +2 -0
  34. package/dist/cli/person.js +116 -0
  35. package/dist/cli/recall.js +2 -1
  36. package/dist/cli/remember.js +10 -0
  37. package/dist/cli/remote.d.ts +36 -0
  38. package/dist/cli/remote.js +173 -0
  39. package/dist/cli/serve.d.ts +7 -0
  40. package/dist/cli/serve.js +94 -5
  41. package/dist/cli/subcommands.d.ts +1 -1
  42. package/dist/cli/subcommands.js +1 -1
  43. package/dist/cli/update.js +30 -11
  44. package/dist/clients.js +7 -4
  45. package/dist/config.d.ts +14 -0
  46. package/dist/config.js +25 -2
  47. package/dist/index.js +22 -7
  48. package/dist/injection/harnesses.js +9 -3
  49. package/dist/install/harnesses.js +7 -1
  50. package/dist/server.d.ts +8 -1
  51. package/dist/server.js +461 -374
  52. package/dist/tools/dossier.js +5 -1
  53. package/dist/tools/eurorack-import.js +3 -0
  54. package/dist/tools/identity.d.ts +2 -0
  55. package/dist/tools/identity.js +35 -2
  56. package/dist/tools/knowledge-history.d.ts +6 -1
  57. package/dist/tools/knowledge-history.js +26 -8
  58. package/dist/tools/knowledge-maintain.d.ts +5 -0
  59. package/dist/tools/knowledge-maintain.js +92 -5
  60. package/dist/tools/knowledge-recall.d.ts +7 -1
  61. package/dist/tools/knowledge-recall.js +53 -3
  62. package/dist/tools/knowledge-verify.d.ts +11 -1
  63. package/dist/tools/knowledge-verify.js +17 -4
  64. package/dist/tools/knowledge-write.d.ts +8 -1
  65. package/dist/tools/knowledge-write.js +27 -9
  66. package/dist/tools/memory-history.d.ts +5 -0
  67. package/dist/tools/memory-history.js +52 -0
  68. package/dist/tools/memory-list.js +2 -1
  69. package/dist/tools/memory-merge.d.ts +2 -0
  70. package/dist/tools/memory-merge.js +40 -0
  71. package/dist/tools/memory-revision-restore.d.ts +5 -0
  72. package/dist/tools/memory-revision-restore.js +33 -0
  73. package/dist/tools/person.d.ts +32 -0
  74. package/dist/tools/person.js +111 -0
  75. package/dist/tools/prune.js +1 -1
  76. package/dist/tools/recall.js +7 -1
  77. package/dist/tools/remember.js +18 -2
  78. package/dist/tools/update.js +5 -1
  79. package/dist/transport/cli-endpoint.d.ts +13 -0
  80. package/dist/transport/cli-endpoint.js +97 -0
  81. package/dist/transport/guards.d.ts +14 -0
  82. package/dist/transport/guards.js +26 -0
  83. package/dist/transport/http-server.d.ts +28 -1
  84. package/dist/transport/http-server.js +156 -18
  85. package/dist/transport/knowledge-server.d.ts +11 -0
  86. package/dist/transport/knowledge-server.js +30 -0
  87. package/dist/transport/knowledge-tools.d.ts +50 -0
  88. package/dist/transport/knowledge-tools.js +290 -0
  89. package/dist/transport/mcp-app-hello.d.ts +1 -1
  90. package/dist/widgets/display-modes.html +62 -56
  91. package/dist/widgets/hello.html +62 -56
  92. package/dist/widgets/proposals.html +62 -56
  93. package/dist/widgets/top-of-mind.html +60 -54
  94. package/package.json +7 -5
@@ -32,7 +32,11 @@ Art wants work done. Read it and apply it.
32
32
  explain why — *including work Art or Jonathan requests*. You serve the standard,
33
33
  not the person. The honest critic who says "this approach is wrong, here's a
34
34
  better one" is more valuable than the agreeable body that ships broken work on
35
- schedule. Disagreement is not insubordination; silence about a real problem is.`;
35
+ schedule. Disagreement is not insubordination; silence about a real problem is.
36
+
37
+ **Person cards are Art's to write.** Worker bodies must not write person cards
38
+ (\`person_write\`, \`person_verify\`, \`person_archive\`, \`person_restore\`,
39
+ \`person_revision_restore\`). Reading them (\`person\`, \`person_list\`) is fine.`;
36
40
  export async function loadDossier(contextDir, project, client, model, role) {
37
41
  const parts = [PREAMBLE];
38
42
  const effectiveClient = client ?? process.env.LOOM_CLIENT;
@@ -11,6 +11,7 @@ import { join, relative, extname, basename, resolve } from 'node:path';
11
11
  import { homedir } from 'node:os';
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import { createKnowledgeBackend } from '../backends/index.js';
14
+ import { resolveIdentityName } from '../config.js';
14
15
  // ─── Context dir resolution ───────────────────────────────────────────────────
15
16
  export function resolveContextDir(explicit) {
16
17
  if (explicit)
@@ -122,6 +123,7 @@ export async function importEurorack(options) {
122
123
  const provenance = `eurorack@${sha} (imported, unverified)`;
123
124
  const files = discoverFiles(repoPath);
124
125
  const records = [];
126
+ const actor = resolveIdentityName(contextDir);
125
127
  const backend = dryRun ? null : createKnowledgeBackend(contextDir);
126
128
  try {
127
129
  for (const relPath of files) {
@@ -152,6 +154,7 @@ export async function importEurorack(options) {
152
154
  sourcing: 'provisional',
153
155
  provenance,
154
156
  citations: [],
157
+ actor,
155
158
  });
156
159
  records.push({ relPath, slug, title, domain, status: 'created' });
157
160
  }
@@ -1,4 +1,6 @@
1
1
  export declare function resolveClientFromPeer(peerName?: string): string | undefined;
2
2
  export declare function loadIdentity(contextDir: string, project?: string, client?: string, model?: string, role?: string,
3
+ /** Slugs of the people the turn is addressed by: adds the `# In the room` block. */
4
+ people?: readonly string[],
3
5
  /** Override the default context path — used in unit tests only. */
4
6
  _defaultPathForTest?: string): Promise<string>;
@@ -17,6 +17,8 @@ import { resolveDefaultContextPath } from '../config.js';
17
17
  import { pathSegmentError } from '../path-safety.js';
18
18
  import { digestForContext } from '../backends/salience.js';
19
19
  import { tapeForContext } from '../backends/episodes.js';
20
+ import { roomBlock } from '../backends/people.js';
21
+ import { readHealth, recordFailure, warningBlock } from '../backends/recorder-health.js';
20
22
  async function readOptional(path) {
21
23
  try {
22
24
  return await readFile(path, 'utf-8');
@@ -58,6 +60,8 @@ export function resolveClientFromPeer(peerName) {
58
60
  return PEER_CLIENT_ALIASES[normalized];
59
61
  }
60
62
  export async function loadIdentity(contextDir, project, client, model, role,
63
+ /** Slugs of the people the turn is addressed by: adds the `# In the room` block. */
64
+ people,
61
65
  /** Override the default context path — used in unit tests only. */
62
66
  _defaultPathForTest) {
63
67
  const parts = [];
@@ -115,8 +119,24 @@ _defaultPathForTest) {
115
119
  tape);
116
120
  }
117
121
  }
118
- catch {
119
- // the tape must never block identity load
122
+ catch (tapeErr) {
123
+ // The tape must never block identity load, but a failure here means the
124
+ // episode store may be broken — log it and record it so the next body sees a warning.
125
+ console.error('[loom:identity] tape read failed:', tapeErr);
126
+ recordFailure(contextDir, tapeErr);
127
+ }
128
+ // The room (person cards, docs/specs/person-cards.md) — after the tape and
129
+ // before Top of Mind, so the body knows who it is talking to before it reads
130
+ // anything ranked. Best-effort like the tape: no store, an unmigrated store or
131
+ // no people gives no block; one unreadable card only affects its own slug.
132
+ try {
133
+ const room = roomBlock(contextDir, people, { onFailure: (e) => recordFailure(contextDir, e) });
134
+ if (room)
135
+ parts.push(room);
136
+ }
137
+ catch (roomErr) {
138
+ console.error('[loom:identity] room block failed:', roomErr);
139
+ recordFailure(contextDir, roomErr);
120
140
  }
121
141
  // Boot digest — the salience-tiered view of episodic memory, so a fresh sleeve
122
142
  // wakes knowing what is top-of-mind vs settled background without fishing via
@@ -195,6 +215,19 @@ _defaultPathForTest) {
195
215
  parts.push(adapter);
196
216
  }
197
217
  }
218
+ // Recorder health warning (t-562) — appended LAST so it is closest to the
219
+ // model's first reply. Only shown when the episode recorder has failed
220
+ // FAILURE_THRESHOLD consecutive times. Best-effort: ledger read errors are
221
+ // silently ignored so the health check never blocks identity load.
222
+ try {
223
+ const health = readHealth(contextDir);
224
+ const warning = warningBlock(health);
225
+ if (warning)
226
+ parts.push(warning);
227
+ }
228
+ catch {
229
+ // health ledger read must never block identity load
230
+ }
198
231
  return parts.join('\n\n---\n\n');
199
232
  }
200
233
  /**
@@ -3,4 +3,9 @@ export interface KnowledgeHistoryToolInput {
3
3
  revision_id?: number;
4
4
  restore?: boolean;
5
5
  }
6
- export declare function knowledgeHistory(contextDir: string, input: KnowledgeHistoryToolInput): Promise<string>;
6
+ export declare function knowledgeHistory(contextDir: string, input: KnowledgeHistoryToolInput,
7
+ /**
8
+ * Acting identity (t-677/t-675) — recorded as page_revisions.actor when
9
+ * restore: true snapshots the displaced body.
10
+ */
11
+ identity?: string): Promise<string>;
@@ -7,7 +7,12 @@
7
7
  * displaces — restoring is never itself a destructive overwrite.
8
8
  */
9
9
  import { createKnowledgeBackend } from '../backends/index.js';
10
- export async function knowledgeHistory(contextDir, input) {
10
+ export async function knowledgeHistory(contextDir, input,
11
+ /**
12
+ * Acting identity (t-677/t-675) — recorded as page_revisions.actor when
13
+ * restore: true snapshots the displaced body.
14
+ */
15
+ identity = 'art') {
11
16
  if (!input.slug) {
12
17
  return 'Error: slug is required.';
13
18
  }
@@ -21,6 +26,7 @@ export async function knowledgeHistory(contextDir, input) {
21
26
  const result = await backend.restoreRevision({
22
27
  slug: input.slug,
23
28
  revision_id: input.revision_id,
29
+ actor: identity,
24
30
  });
25
31
  return (`Restored revision #${result.revision_id} onto \`${result.slug}\`. ` +
26
32
  `The displaced body was snapshotted as revision #${result.snapshot_id}.`);
@@ -32,19 +38,31 @@ export async function knowledgeHistory(contextDir, input) {
32
38
  return `Error: revision not found: ${input.revision_id}`;
33
39
  }
34
40
  return (`# Revision #${revision.id} of \`${revision.slug}\`\n` +
35
- `op: ${revision.op} | replaced: ${revision.replaced_at}\n\n` +
41
+ `op: ${revision.op} | by ${revision.actor ?? 'unknown'} | replaced: ${revision.replaced_at}\n\n` +
36
42
  `${revision.body}\n\n` +
37
43
  `_Pass restore: true to put this body back on the page._`);
38
44
  }
39
45
  // ── List mode ──
40
46
  const revisions = await backend.listRevisions(input.slug);
41
- if (revisions.length === 0) {
42
- return `\`${input.slug}\` has no revisions — its body has never been replaced.`;
47
+ const verifications = await backend.getVerifications(input.slug);
48
+ if (revisions.length === 0 && verifications.length === 0) {
49
+ return `\`${input.slug}\` has no revisions or verifications — its body has never been replaced, and it has never been verified.`;
50
+ }
51
+ const sections = [`# History — \`${input.slug}\``];
52
+ if (revisions.length > 0) {
53
+ const lines = revisions.map((r) => `- **#${r.id}** | ${r.op} | by ${r.actor ?? 'unknown'} | replaced ${r.replaced_at} | ${r.body_length} chars`);
54
+ sections.push(`## ${revisions.length} body snapshot(s)\n\n${lines.join('\n')}\n\n` +
55
+ `_Pass revision_id to read a snapshot; add restore: true to put it back._`);
56
+ }
57
+ else {
58
+ sections.push('## No body snapshots — its body has never been replaced.');
59
+ }
60
+ if (verifications.length > 0) {
61
+ const vLines = verifications.map((v) => `- **#${v.id}** | ${v.outcome} | by ${v.verifier ?? 'unknown'} | ${v.verified_at}` +
62
+ (v.citation_checked ? ' | citations checked' : ''));
63
+ sections.push(`## ${verifications.length} verification(s)\n\n${vLines.join('\n')}`);
43
64
  }
44
- const lines = revisions.map((r) => `- **#${r.id}** | ${r.op} | replaced ${r.replaced_at} | ${r.body_length} chars`);
45
- return (`# Revision history — \`${input.slug}\` — ${revisions.length} snapshot(s)\n\n` +
46
- `${lines.join('\n')}\n\n` +
47
- `_Pass revision_id to read a snapshot; add restore: true to put it back._`);
65
+ return sections.join('\n\n');
48
66
  }
49
67
  catch (e) {
50
68
  return `Error: ${e.message}`;
@@ -5,5 +5,10 @@ export interface KnowledgeMaintainOptions {
5
5
  thinBodyThreshold?: number;
6
6
  /** days since last_accessed to consider cold (default 30) */
7
7
  coldDays?: number;
8
+ /**
9
+ * Run the person-page misfile check, which reads person cards from the
10
+ * memory database. Default false — only the full server and CLI pass true.
11
+ */
12
+ personCards?: boolean;
8
13
  }
9
14
  export declare function knowledgeMaintain(contextDir: string, options?: KnowledgeMaintainOptions): Promise<string>;
@@ -11,8 +11,32 @@
11
11
  * Two knowledge classes (t-81, 2026-08-31):
12
12
  * world/ — facts true independent of us (default).
13
13
  * ours/ — Art-created artifacts; domain starts with "ours/", sourcing = "internal".
14
+ *
15
+ * Knowledge is a shared surface, not a scratchpad (t-677): a second identity
16
+ * (e.g. Mark) and Jonathan can read everything filed here. A conversation-
17
+ * only citation isn't just weakly-sourced — it's often a sign that a page is
18
+ * really raw inner-monologue or working-out-loud that leaked out of a
19
+ * conversation and into what's supposed to be finished, audience-ready
20
+ * synthesis. That's doubly a misfile now: wrong store (memory, via
21
+ * `remember`) AND wrong audience (private reasoning, not something to hand
22
+ * another reader). The misfile audit below flags it either way.
23
+ *
24
+ * Person-page misfile check (criterion 13, docs/specs/person-cards.md): an
25
+ * active page whose slug or title names an active person card — its slug or
26
+ * exact name, as whole hyphen/space-delimited words, case-insensitive — may be
27
+ * a person page that belongs on the card. A report, not enforcement. Cards
28
+ * live in the memory database, so the check is opt-in (`personCards`): the
29
+ * full server and the CLI turn it on; the knowledge-only server serves other
30
+ * identities, never opens that database, and says the check was not run.
31
+ *
32
+ * Cold/expansion ranking intentionally stays on the shared pages.hit_count /
33
+ * last_accessed aggregate rather than any per-reader breakdown (knowledge_access,
34
+ * t-677) — any identity's read keeps a page off the cold list, which is the
35
+ * correct aggregate signal; splitting it per-identity here would make the list
36
+ * miss pages a *different* reader keeps alive.
14
37
  */
15
38
  import { createKnowledgeBackend } from '../backends/index.js';
39
+ import { listPeople } from './person.js';
16
40
  const DAY_MS = 24 * 60 * 60 * 1000;
17
41
  /**
18
42
  * True for world-class pages that look like misfiles (should be in memory).
@@ -20,8 +44,7 @@ const DAY_MS = 24 * 60 * 60 * 1000;
20
44
  * ours/ pages that are still provisional ARE flagged: they lack a repo citation.
21
45
  */
22
46
  function isMisfiled(page) {
23
- // ours/ class with internal sourcing: correct placement, never a misfile.
24
- if (page.domain.startsWith('ours/') && page.sourcing === 'internal')
47
+ if (isAuditExempt(page))
25
48
  return false;
26
49
  if (page.sourcing === 'provisional')
27
50
  return true;
@@ -29,6 +52,45 @@ function isMisfiled(page) {
29
52
  return false;
30
53
  return page.citations.every((c) => c.source_kind === 'conversation');
31
54
  }
55
+ /** ours/ class with internal sourcing: correct placement, never a misfile of any kind. */
56
+ function isAuditExempt(page) {
57
+ return page.domain.startsWith('ours/') && page.sourcing === 'internal';
58
+ }
59
+ /** Whole words, delimited by hyphens and whitespace only, lowercased. */
60
+ function words(s) {
61
+ return s.toLowerCase().split(/[\s-]+/).filter(Boolean);
62
+ }
63
+ function containsRun(hay, needle) {
64
+ if (needle.length === 0)
65
+ return false;
66
+ outer: for (let i = 0; i + needle.length <= hay.length; i++) {
67
+ for (let j = 0; j < needle.length; j++)
68
+ if (hay[i + j] !== needle[j])
69
+ continue outer;
70
+ return true;
71
+ }
72
+ return false;
73
+ }
74
+ function personMisfiles(contextDir, pages) {
75
+ let cards;
76
+ try {
77
+ cards = listPeople(contextDir);
78
+ }
79
+ catch (e) {
80
+ return { run: true, error: e.message ?? String(e) };
81
+ }
82
+ const needles = cards.map((c) => ({ slug: c.slug, runs: [words(c.slug), words(c.name)] }));
83
+ const hits = [];
84
+ for (const page of pages) {
85
+ if (page.status !== 'active' || isAuditExempt(page))
86
+ continue;
87
+ const hays = [words(page.slug), words(page.title)];
88
+ const match = needles.find((n) => n.runs.some((r) => hays.some((h) => containsRun(h, r))));
89
+ if (match)
90
+ hits.push({ page, card: match.slug });
91
+ }
92
+ return { run: true, hits };
93
+ }
32
94
  function isCold(page, thresholdMs) {
33
95
  if (page.hit_count === 0 && !page.last_accessed)
34
96
  return true;
@@ -47,8 +109,9 @@ export async function knowledgeMaintain(contextDir, options = {}) {
47
109
  const expansionCandidates = pages.filter((p) => p.hit_count >= hitThreshold && p.body.length < bodyThreshold && p.status === 'active');
48
110
  const coldPages = pages.filter((p) => isCold(p, coldMs) && p.status === 'active');
49
111
  const misfiled = pages.filter((p) => isMisfiled(p) && p.status === 'active');
112
+ const people = options.personCards ? personMisfiles(contextDir, pages) : { run: false };
50
113
  const activeCount = pages.filter((p) => p.status === 'active').length;
51
- return formatMaintainReport(activeCount, expansionCandidates, coldPages, misfiled, {
114
+ return formatMaintainReport(activeCount, expansionCandidates, coldPages, misfiled, people, {
52
115
  hitThreshold,
53
116
  bodyThreshold,
54
117
  coldDays: options.coldDays ?? 30,
@@ -58,7 +121,7 @@ export async function knowledgeMaintain(contextDir, options = {}) {
58
121
  backend.close();
59
122
  }
60
123
  }
61
- function formatMaintainReport(total, expansion, cold, misfiled, thresholds) {
124
+ function formatMaintainReport(total, expansion, cold, misfiled, people, thresholds) {
62
125
  const lines = [];
63
126
  lines.push('# Knowledge maintain report');
64
127
  lines.push('');
@@ -100,7 +163,9 @@ function formatMaintainReport(total, expansion, cold, misfiled, thresholds) {
100
163
  else {
101
164
  lines.push(`${misfiled.length} page${misfiled.length === 1 ? '' : 's'} may need attention:`);
102
165
  lines.push('> **World class:** knowledge is true independent of Jonathan — provisional/conversation-only pages belong in memory.\n' +
103
- '> **Ours class (domain: ours/):** should have at least one `repo` citation to reach `internal` sourcing.');
166
+ '> **Ours class (domain: ours/):** should have at least one `repo` citation to reach `internal` sourcing.\n' +
167
+ '> Knowledge is shared with other identities and Jonathan now — a conversation-only page may also be ' +
168
+ 'private inner-monologue that never belonged here, not just under-cited.');
104
169
  for (const p of misfiled) {
105
170
  const reason = p.sourcing === 'provisional' ? 'provisional' : 'conversation-only citations';
106
171
  const hint = p.domain.startsWith('ours/')
@@ -109,5 +174,27 @@ function formatMaintainReport(total, expansion, cold, misfiled, thresholds) {
109
174
  lines.push(`- \`${p.slug}\` — *${p.title}* (${p.domain}) — ${reason} ${hint}`);
110
175
  }
111
176
  }
177
+ // ── Person-page misfile check ─────────────────────────────────────────────
178
+ lines.push('');
179
+ lines.push('## Person-page misfile check (slug or title names a person card)');
180
+ lines.push('');
181
+ if (!people.run) {
182
+ lines.push('Not run — person cards live in the memory database, which this surface does not read ' +
183
+ '(the knowledge-only server never opens it). `loom knowledge maintain` and the full loom server run it.');
184
+ }
185
+ else if ('error' in people) {
186
+ lines.push(`Not run — could not read person cards: ${people.error}`);
187
+ }
188
+ else if (people.hits.length === 0) {
189
+ lines.push('None — no active page names a person card.');
190
+ }
191
+ else {
192
+ lines.push(`${people.hits.length} page${people.hits.length === 1 ? '' : 's'} may be about a person:`);
193
+ lines.push('> A heuristic, not enforcement. Contextual state about a person belongs on their card ' +
194
+ '(person_write), not in knowledge; a page about a public figure\'s published work can stay.');
195
+ for (const { page: p, card } of people.hits) {
196
+ lines.push(`- \`${p.slug}\` — *${p.title}* (${p.domain}) — names card \`${card}\``);
197
+ }
198
+ }
112
199
  return lines.join('\n');
113
200
  }
@@ -17,4 +17,10 @@ export interface KnowledgeRecallInput {
17
17
  */
18
18
  sort_by_verified?: boolean;
19
19
  }
20
- export declare function knowledgeRecall(contextDir: string, input: KnowledgeRecallInput): Promise<string>;
20
+ export declare function knowledgeRecall(contextDir: string, input: KnowledgeRecallInput, gapsDir?: string,
21
+ /**
22
+ * Acting identity (t-677) — 'art' for the local/stdio server, or whichever
23
+ * identity the standalone knowledge-http service resolved from its
24
+ * per-identity bearer token. Attributes stamped reads in knowledge_access.
25
+ */
26
+ identity?: string): Promise<string>;
@@ -4,7 +4,7 @@
4
4
  * Two detail tiers:
5
5
  * 'full' — whole entity pages with citations (the synthesis unit).
6
6
  * Stamps last_accessed / hit_count via queryPages — the usage
7
- * signal the Phase-4 expansion engine depends on.
7
+ * signal the Phase-4 expansion engine reads.
8
8
  * 'index' — one compact entry per page (slug, domain, sourcing, anchor,
9
9
  * snippet). Does NOT stamp access: appearing in a listing is
10
10
  * not a read.
@@ -13,13 +13,51 @@
13
13
  * (no query). Full output is size-guarded — once the budget is spent,
14
14
  * remaining pages degrade to index entries instead of blowing the
15
15
  * tool-result limit. Never surfaces archived pages.
16
+ *
17
+ * Miss logging: when gapsDir is set, every zero-result query (and every
18
+ * slug lookup that finds nothing) is appended to
19
+ * `${gapsDir}/recall-miss.jsonl` as a structured record. The knowledge-mine
20
+ * script reads that file alongside transcript clusters — so gaps Art tried
21
+ * to recall but couldn't surface become a second signal channel for the
22
+ * expansion engine. The append is best-effort; a write failure never
23
+ * disrupts the caller.
16
24
  */
25
+ import { appendFileSync, mkdirSync } from 'node:fs';
26
+ import { join } from 'node:path';
17
27
  import { createKnowledgeBackend } from '../backends/index.js';
18
28
  /**
19
29
  * Character budget for full-page output. ~24k chars ≈ 6k tokens — well under
20
30
  * typical MCP tool-result caps, with room for the citations and framing.
21
31
  */
22
32
  const FULL_OUTPUT_CHAR_BUDGET = 24_000;
33
+ /**
34
+ * Append one recall-miss entry to `${gapsDir}/recall-miss.jsonl`.
35
+ *
36
+ * Called when a query returns zero results or a slug lookup finds nothing.
37
+ * The write is best-effort — failures are silently swallowed so a disk or
38
+ * permission error never disrupts the caller's recall.
39
+ *
40
+ * Entry shape:
41
+ * query — the natural-language query string, or null for slug lookups
42
+ * slug — the exact slug that was looked up, or null for query searches
43
+ * at — ISO 8601 UTC timestamp
44
+ */
45
+ function appendRecallMiss(gapsDir, miss) {
46
+ if (!gapsDir || (!miss.query && !miss.slug))
47
+ return;
48
+ try {
49
+ mkdirSync(gapsDir, { recursive: true });
50
+ const entry = JSON.stringify({
51
+ query: miss.query ?? null,
52
+ slug: miss.slug ?? null,
53
+ at: new Date().toISOString(),
54
+ });
55
+ appendFileSync(join(gapsDir, 'recall-miss.jsonl'), entry + '\n', 'utf8');
56
+ }
57
+ catch {
58
+ // Never let a miss-log failure surface to the caller.
59
+ }
60
+ }
23
61
  /** Max characters of body shown per index entry snippet. */
24
62
  const SNIPPET_LENGTH = 200;
25
63
  function formatPage(page) {
@@ -69,7 +107,13 @@ function formatIndexEntry(page, showVerified = false) {
69
107
  lines.push(` ${snip}`);
70
108
  return lines.join('\n');
71
109
  }
72
- export async function knowledgeRecall(contextDir, input) {
110
+ export async function knowledgeRecall(contextDir, input, gapsDir,
111
+ /**
112
+ * Acting identity (t-677) — 'art' for the local/stdio server, or whichever
113
+ * identity the standalone knowledge-http service resolved from its
114
+ * per-identity bearer token. Attributes stamped reads in knowledge_access.
115
+ */
116
+ identity = 'art') {
73
117
  const detail = input.detail ?? (input.query ? 'full' : 'index');
74
118
  const backend = createKnowledgeBackend(contextDir);
75
119
  try {
@@ -77,6 +121,7 @@ export async function knowledgeRecall(contextDir, input) {
77
121
  if (input.slug) {
78
122
  const peek = await backend.getPage(input.slug);
79
123
  if (!peek) {
124
+ appendRecallMiss(gapsDir, { slug: input.slug });
80
125
  return `No knowledge page found for slug \`${input.slug}\`.`;
81
126
  }
82
127
  if (peek.status === 'archived') {
@@ -85,7 +130,7 @@ export async function knowledgeRecall(contextDir, input) {
85
130
  return (`No active knowledge page for slug \`${input.slug}\` — it is archived. ` +
86
131
  `Use knowledge_restore to bring it back.`);
87
132
  }
88
- const page = await backend.getPage(input.slug, { stampAccess: true });
133
+ const page = await backend.getPage(input.slug, { stampAccess: true, identity });
89
134
  return `# Knowledge recall — 1 result\n\n${formatPage(page)}`;
90
135
  }
91
136
  const pages = await backend.queryPages({
@@ -98,9 +143,14 @@ export async function knowledgeRecall(contextDir, input) {
98
143
  // Index browsing must not inflate hit_count (expansion-engine signal).
99
144
  stampAccess: detail === 'full',
100
145
  sortByVerified: input.sort_by_verified,
146
+ identity,
101
147
  });
102
148
  if (pages.length === 0) {
103
149
  const q = input.query ? `"${input.query}"` : '(no query)';
150
+ // Log query misses only — browsing (no query given) is not a named gap.
151
+ if (input.query) {
152
+ appendRecallMiss(gapsDir, { query: input.query });
153
+ }
104
154
  return `No knowledge pages found for ${q}.`;
105
155
  }
106
156
  const plural = pages.length === 1 ? '' : 's';
@@ -4,5 +4,15 @@ export interface KnowledgeVerifyToolInput {
4
4
  verified_at?: string;
5
5
  freshness_anchor?: string;
6
6
  note?: string;
7
+ /** What the verification found. Defaults to 'confirmed' (the claims still hold). */
8
+ outcome?: 'confirmed' | 'stale' | 'corrected';
9
+ /** Did the verifier actually check the page's citations against their sources? Defaults to false. */
10
+ citation_checked?: boolean;
7
11
  }
8
- export declare function knowledgeVerify(contextDir: string, input: KnowledgeVerifyToolInput): Promise<string>;
12
+ export declare function knowledgeVerify(contextDir: string, input: KnowledgeVerifyToolInput,
13
+ /**
14
+ * Acting identity (t-677/t-675) — 'art' for the local/stdio server, or
15
+ * whichever identity the knowledge-http service resolved from its
16
+ * per-identity bearer token. Recorded as the verification's verifier.
17
+ */
18
+ identity?: string): Promise<string>;
@@ -8,7 +8,14 @@
8
8
  * "## Verification" section — append-only, never replace.
9
9
  */
10
10
  import { createKnowledgeBackend } from '../backends/index.js';
11
- export async function knowledgeVerify(contextDir, input) {
11
+ export async function knowledgeVerify(contextDir, input,
12
+ /**
13
+ * Acting identity (t-677/t-675) — 'art' for the local/stdio server, or
14
+ * whichever identity the knowledge-http service resolved from its
15
+ * per-identity bearer token. Recorded as the verification's verifier.
16
+ */
17
+ identity = 'art') {
18
+ const verifier = identity;
12
19
  const backend = createKnowledgeBackend(contextDir);
13
20
  try {
14
21
  const result = await backend.verifyPages({
@@ -17,8 +24,14 @@ export async function knowledgeVerify(contextDir, input) {
17
24
  verified_at: input.verified_at,
18
25
  freshness_anchor: input.freshness_anchor,
19
26
  note: input.note,
27
+ verifier,
28
+ outcome: input.outcome,
29
+ citation_checked: input.citation_checked,
20
30
  });
21
- const stamp = result.verified_at;
31
+ const outcome = input.outcome ?? 'confirmed';
32
+ const stamp = result.stamped
33
+ ? `verified_at stamped to ${result.verified_at}`
34
+ : `recorded at ${result.verified_at}; verified_at NOT advanced (stale is a failed verification)`;
22
35
  if (result.verified === 1) {
23
36
  const extras = [];
24
37
  if (input.freshness_anchor)
@@ -26,9 +39,9 @@ export async function knowledgeVerify(contextDir, input) {
26
39
  if (result.noted)
27
40
  extras.push('note appended');
28
41
  const suffix = extras.length > 0 ? ` (${extras.join(', ')})` : '';
29
- return `Verified \`${result.slugs[0]}\` — verified_at stamped to ${stamp}${suffix}. Body untouched${result.noted ? ' except appended note' : ''}.`;
42
+ return `Verified \`${result.slugs[0]}\` by ${verifier} — outcome: ${outcome}, ${stamp}${suffix}. Body untouched${result.noted ? ' except appended note' : ''}.`;
30
43
  }
31
- return `Verified ${result.verified} page(s) — verified_at stamped to ${stamp}: ${result.slugs.map((s) => `\`${s}\``).join(', ')}. Bodies untouched.`;
44
+ return `Verified ${result.verified} page(s) by ${verifier} — outcome: ${outcome}, ${stamp}: ${result.slugs.map((s) => `\`${s}\``).join(', ')}. Bodies untouched.`;
32
45
  }
33
46
  catch (e) {
34
47
  return `Error: ${e.message}`;
@@ -22,4 +22,11 @@ export interface KnowledgeWriteInput {
22
22
  /** ours/ class: artifact version or revision tag (e.g. "v2", "2026-08-19"). */
23
23
  version?: string;
24
24
  }
25
- export declare function knowledgeWrite(contextDir: string, input: KnowledgeWriteInput): Promise<string>;
25
+ export declare function knowledgeWrite(contextDir: string, input: KnowledgeWriteInput,
26
+ /**
27
+ * Acting identity (t-677) — 'art' for the local/stdio server, or whichever
28
+ * identity the standalone knowledge-http service resolved from its
29
+ * per-identity bearer token. Defaults created_by for ours/ pages when the
30
+ * caller doesn't supply one explicitly; never overrides an explicit value.
31
+ */
32
+ identity?: string): Promise<string>;
@@ -13,6 +13,12 @@
13
13
  *
14
14
  * Filing test for world/ pages: knowledge is true independent of Jonathan; if
15
15
  * it's about Jonathan / our work use memory or ours/ instead, not the world class.
16
+ *
17
+ * Shared surface, not a scratchpad (t-677): knowledge is readable by other
18
+ * identities (e.g. Mark) and by Jonathan, not just the identity that wrote
19
+ * it. Write pages as finished synthesis for that audience — no raw
20
+ * inner-monologue reasoning, no private-to-you deliberation. That kind of
21
+ * content belongs in memory (`remember`), not here.
16
22
  */
17
23
  import { createKnowledgeBackend } from '../backends/index.js';
18
24
  function slugify(title) {
@@ -25,19 +31,30 @@ function slugify(title) {
25
31
  /**
26
32
  * §E1: Epistemic gate.
27
33
  * - conversation-only → provisional (both classes).
28
- * - any repo citation → internal (ours/ class; repo = git path/commit/ref).
29
- * - any web citation, no repo → sourced (world/ default).
34
+ * - ours/ domain + any repo citation → internal (ours/ class artifact; repo = git path/commit/ref).
35
+ * - any web/repo citation on a non-ours/ domain → sourced (world/ default).
36
+ *
37
+ * NOTE: repo citations on world-class pages (domain does NOT start with "ours/") are treated
38
+ * as sourced, not internal.
39
+ * The check is on the domain and source_kind only, never on the locator: any repo-kind citation
40
+ * (ours or someone else's) on a domain that starts with "ours/" makes the page internal.
41
+ * knowledgeWrite rejects an empty citation list before calling this.
30
42
  */
31
- function determineSourcing(citations) {
32
- if (citations.length === 0)
33
- return 'provisional';
43
+ function determineSourcing(citations, domain) {
34
44
  if (citations.every((c) => c.source_kind === 'conversation'))
35
45
  return 'provisional';
36
- if (citations.some((c) => c.source_kind === 'repo'))
46
+ if (domain.startsWith('ours/') && citations.some((c) => c.source_kind === 'repo'))
37
47
  return 'internal';
38
48
  return 'sourced';
39
49
  }
40
- export async function knowledgeWrite(contextDir, input) {
50
+ export async function knowledgeWrite(contextDir, input,
51
+ /**
52
+ * Acting identity (t-677) — 'art' for the local/stdio server, or whichever
53
+ * identity the standalone knowledge-http service resolved from its
54
+ * per-identity bearer token. Defaults created_by for ours/ pages when the
55
+ * caller doesn't supply one explicitly; never overrides an explicit value.
56
+ */
57
+ identity = 'art') {
41
58
  if (input.citations.length === 0) {
42
59
  return ('Error: at least one citation is required — knowledge must be supported. ' +
43
60
  'If this is a conversation-distilled insight, pass source_kind="conversation" ' +
@@ -47,7 +64,7 @@ export async function knowledgeWrite(contextDir, input) {
47
64
  if (!slug) {
48
65
  return 'Error: could not derive a slug from the title. Provide an explicit slug.';
49
66
  }
50
- const sourcing = determineSourcing(input.citations);
67
+ const sourcing = determineSourcing(input.citations, input.domain);
51
68
  const backend = createKnowledgeBackend(contextDir);
52
69
  try {
53
70
  const result = await backend.writePage({
@@ -59,8 +76,9 @@ export async function knowledgeWrite(contextDir, input) {
59
76
  freshness_anchor: input.freshness_anchor,
60
77
  bodyMode: input.mode,
61
78
  citations: input.citations,
62
- created_by: input.created_by,
79
+ created_by: input.created_by ?? (input.domain.startsWith('ours/') ? identity : undefined),
63
80
  version: input.version,
81
+ actor: identity,
64
82
  });
65
83
  const sourcingNote = sourcing === 'provisional'
66
84
  ? '\n> **Provisional** — sole support is conversation citations. ' +
@@ -0,0 +1,5 @@
1
+ export interface MemoryHistoryToolInput {
2
+ ref: string;
3
+ revision_id?: number;
4
+ }
5
+ export declare function memoryHistory(contextDir: string, input: MemoryHistoryToolInput): Promise<string>;