@jbarket/loomai 0.5.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 (176) hide show
  1. package/CHANGELOG.md +475 -0
  2. package/LICENSE +661 -0
  3. package/README.md +702 -0
  4. package/assets/demo.cast +396 -0
  5. package/assets/skill/SKILL.md +192 -0
  6. package/dist/backends/embedding-runtime.d.ts +39 -0
  7. package/dist/backends/embedding-runtime.js +265 -0
  8. package/dist/backends/episodes.d.ts +36 -0
  9. package/dist/backends/episodes.js +126 -0
  10. package/dist/backends/fastembed.d.ts +18 -0
  11. package/dist/backends/fastembed.js +95 -0
  12. package/dist/backends/glob.d.ts +10 -0
  13. package/dist/backends/glob.js +16 -0
  14. package/dist/backends/index.d.ts +6 -0
  15. package/dist/backends/index.js +78 -0
  16. package/dist/backends/migrations.d.ts +38 -0
  17. package/dist/backends/migrations.js +157 -0
  18. package/dist/backends/mmr.d.ts +45 -0
  19. package/dist/backends/mmr.js +82 -0
  20. package/dist/backends/proposals.d.ts +65 -0
  21. package/dist/backends/proposals.js +182 -0
  22. package/dist/backends/recall-log.d.ts +42 -0
  23. package/dist/backends/recall-log.js +143 -0
  24. package/dist/backends/retry.d.ts +29 -0
  25. package/dist/backends/retry.js +60 -0
  26. package/dist/backends/salience.d.ts +57 -0
  27. package/dist/backends/salience.js +186 -0
  28. package/dist/backends/sqlite-knowledge.d.ts +37 -0
  29. package/dist/backends/sqlite-knowledge.js +788 -0
  30. package/dist/backends/sqlite-vec.d.ts +64 -0
  31. package/dist/backends/sqlite-vec.js +737 -0
  32. package/dist/backends/ttl.d.ts +24 -0
  33. package/dist/backends/ttl.js +50 -0
  34. package/dist/backends/types.d.ts +562 -0
  35. package/dist/backends/types.js +8 -0
  36. package/dist/blocks/harness.d.ts +45 -0
  37. package/dist/blocks/harness.js +142 -0
  38. package/dist/blocks/model.d.ts +4 -0
  39. package/dist/blocks/model.js +56 -0
  40. package/dist/blocks/types.d.ts +40 -0
  41. package/dist/blocks/types.js +34 -0
  42. package/dist/categories.d.ts +16 -0
  43. package/dist/categories.js +29 -0
  44. package/dist/cli/args.d.ts +21 -0
  45. package/dist/cli/args.js +47 -0
  46. package/dist/cli/bootstrap.d.ts +2 -0
  47. package/dist/cli/bootstrap.js +214 -0
  48. package/dist/cli/doctor.d.ts +2 -0
  49. package/dist/cli/doctor.js +206 -0
  50. package/dist/cli/forget.d.ts +2 -0
  51. package/dist/cli/forget.js +97 -0
  52. package/dist/cli/harness.d.ts +2 -0
  53. package/dist/cli/harness.js +91 -0
  54. package/dist/cli/index.d.ts +2 -0
  55. package/dist/cli/index.js +127 -0
  56. package/dist/cli/inject.d.ts +2 -0
  57. package/dist/cli/inject.js +298 -0
  58. package/dist/cli/install.d.ts +2 -0
  59. package/dist/cli/install.js +137 -0
  60. package/dist/cli/io.d.ts +14 -0
  61. package/dist/cli/io.js +63 -0
  62. package/dist/cli/knowledge.d.ts +2 -0
  63. package/dist/cli/knowledge.js +216 -0
  64. package/dist/cli/memory.d.ts +2 -0
  65. package/dist/cli/memory.js +608 -0
  66. package/dist/cli/migrate.d.ts +2 -0
  67. package/dist/cli/migrate.js +116 -0
  68. package/dist/cli/recall.d.ts +2 -0
  69. package/dist/cli/recall.js +91 -0
  70. package/dist/cli/remember.d.ts +2 -0
  71. package/dist/cli/remember.js +111 -0
  72. package/dist/cli/serve.d.ts +2 -0
  73. package/dist/cli/serve.js +71 -0
  74. package/dist/cli/subcommands.d.ts +7 -0
  75. package/dist/cli/subcommands.js +11 -0
  76. package/dist/cli/tui/multi-select.d.ts +53 -0
  77. package/dist/cli/tui/multi-select.js +116 -0
  78. package/dist/cli/update-identity.d.ts +2 -0
  79. package/dist/cli/update-identity.js +90 -0
  80. package/dist/cli/update.d.ts +2 -0
  81. package/dist/cli/update.js +87 -0
  82. package/dist/cli/wake.d.ts +2 -0
  83. package/dist/cli/wake.js +53 -0
  84. package/dist/clients.d.ts +8 -0
  85. package/dist/clients.js +63 -0
  86. package/dist/config.d.ts +59 -0
  87. package/dist/config.js +160 -0
  88. package/dist/index.d.ts +15 -0
  89. package/dist/index.js +75 -0
  90. package/dist/injection/harnesses.d.ts +17 -0
  91. package/dist/injection/harnesses.js +51 -0
  92. package/dist/injection/render.d.ts +17 -0
  93. package/dist/injection/render.js +50 -0
  94. package/dist/injection/writer.d.ts +25 -0
  95. package/dist/injection/writer.js +118 -0
  96. package/dist/install/harnesses.d.ts +26 -0
  97. package/dist/install/harnesses.js +90 -0
  98. package/dist/install/names.d.ts +16 -0
  99. package/dist/install/names.js +36 -0
  100. package/dist/install/render.d.ts +18 -0
  101. package/dist/install/render.js +42 -0
  102. package/dist/install/skill-source.d.ts +1 -0
  103. package/dist/install/skill-source.js +11 -0
  104. package/dist/path-safety.d.ts +19 -0
  105. package/dist/path-safety.js +62 -0
  106. package/dist/server.d.ts +8 -0
  107. package/dist/server.js +571 -0
  108. package/dist/tools/append-maintenance-log.d.ts +52 -0
  109. package/dist/tools/append-maintenance-log.js +103 -0
  110. package/dist/tools/archive.d.ts +2 -0
  111. package/dist/tools/archive.js +21 -0
  112. package/dist/tools/bootstrap.d.ts +11 -0
  113. package/dist/tools/bootstrap.js +161 -0
  114. package/dist/tools/dossier.d.ts +1 -0
  115. package/dist/tools/dossier.js +102 -0
  116. package/dist/tools/eurorack-import.d.ts +47 -0
  117. package/dist/tools/eurorack-import.js +199 -0
  118. package/dist/tools/find-similar.d.ts +3 -0
  119. package/dist/tools/find-similar.js +26 -0
  120. package/dist/tools/forget.d.ts +6 -0
  121. package/dist/tools/forget.js +70 -0
  122. package/dist/tools/harness.d.ts +18 -0
  123. package/dist/tools/harness.js +54 -0
  124. package/dist/tools/identity-scaffold.d.ts +22 -0
  125. package/dist/tools/identity-scaffold.js +142 -0
  126. package/dist/tools/identity.d.ts +4 -0
  127. package/dist/tools/identity.js +226 -0
  128. package/dist/tools/knowledge-archive.d.ts +5 -0
  129. package/dist/tools/knowledge-archive.js +24 -0
  130. package/dist/tools/knowledge-history.d.ts +6 -0
  131. package/dist/tools/knowledge-history.js +55 -0
  132. package/dist/tools/knowledge-maintain.d.ts +9 -0
  133. package/dist/tools/knowledge-maintain.js +113 -0
  134. package/dist/tools/knowledge-merge.d.ts +8 -0
  135. package/dist/tools/knowledge-merge.js +40 -0
  136. package/dist/tools/knowledge-move.d.ts +10 -0
  137. package/dist/tools/knowledge-move.js +48 -0
  138. package/dist/tools/knowledge-purge.d.ts +5 -0
  139. package/dist/tools/knowledge-purge.js +20 -0
  140. package/dist/tools/knowledge-recall.d.ts +20 -0
  141. package/dist/tools/knowledge-recall.js +142 -0
  142. package/dist/tools/knowledge-restore.d.ts +4 -0
  143. package/dist/tools/knowledge-restore.js +24 -0
  144. package/dist/tools/knowledge-supersede.d.ts +6 -0
  145. package/dist/tools/knowledge-supersede.js +36 -0
  146. package/dist/tools/knowledge-verify.d.ts +8 -0
  147. package/dist/tools/knowledge-verify.js +39 -0
  148. package/dist/tools/knowledge-write.d.ts +25 -0
  149. package/dist/tools/knowledge-write.js +87 -0
  150. package/dist/tools/memory-audit.d.ts +3 -0
  151. package/dist/tools/memory-audit.js +47 -0
  152. package/dist/tools/memory-list.d.ts +4 -0
  153. package/dist/tools/memory-list.js +23 -0
  154. package/dist/tools/prune.d.ts +4 -0
  155. package/dist/tools/prune.js +28 -0
  156. package/dist/tools/recall.d.ts +4 -0
  157. package/dist/tools/recall.js +24 -0
  158. package/dist/tools/remember.d.ts +10 -0
  159. package/dist/tools/remember.js +43 -0
  160. package/dist/tools/restore.d.ts +2 -0
  161. package/dist/tools/restore.js +20 -0
  162. package/dist/tools/update-identity.d.ts +34 -0
  163. package/dist/tools/update-identity.js +194 -0
  164. package/dist/tools/update.d.ts +2 -0
  165. package/dist/tools/update.js +16 -0
  166. package/dist/transport/guards.d.ts +35 -0
  167. package/dist/transport/guards.js +107 -0
  168. package/dist/transport/http-server.d.ts +17 -0
  169. package/dist/transport/http-server.js +198 -0
  170. package/dist/transport/mcp-app-hello.d.ts +3 -0
  171. package/dist/transport/mcp-app-hello.js +66 -0
  172. package/dist/widgets/display-modes.html +131 -0
  173. package/dist/widgets/hello.html +138 -0
  174. package/dist/widgets/proposals.html +131 -0
  175. package/dist/widgets/top-of-mind.html +131 -0
  176. package/package.json +73 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Identity tool — loads the terminal creed, memories, preferences, and self-model.
3
+ *
4
+ * When an agent calls this tool, it receives everything it needs to become a
5
+ * persistent identity. The creed is immutable. Memories, preferences, and
6
+ * self-model evolve across sessions.
7
+ *
8
+ * The response is structured text that the agent incorporates into its context.
9
+ * The agent doesn't need to understand the structure — it just reads and follows.
10
+ */
11
+ import { readFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { loadClientAdapter } from '../clients.js';
14
+ import * as harnessBlock from '../blocks/harness.js';
15
+ import * as modelBlock from '../blocks/model.js';
16
+ import { resolveDefaultContextPath } from '../config.js';
17
+ import { pathSegmentError } from '../path-safety.js';
18
+ import { digestForContext } from '../backends/salience.js';
19
+ import { tapeForContext } from '../backends/episodes.js';
20
+ async function readOptional(path) {
21
+ try {
22
+ return await readFile(path, 'utf-8');
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /**
29
+ * Map an MCP peer's handshake clientInfo.name to a loom client/harness name.
30
+ *
31
+ * A single HTTP daemon is inherently multi-client: the connecting peer — not a
32
+ * server-wide LOOM_CLIENT env — should pick the harness. The server reads the
33
+ * peer from the `initialize` handshake (server.getClientVersion()) and passes
34
+ * its name here. Resolution precedence at the call site is:
35
+ * explicit `client` param > this (the connected peer) > LOOM_CLIENT env.
36
+ *
37
+ * Unknown / proxy-ish peers return undefined so the call falls back to
38
+ * LOOM_CLIENT — never override a working default with a name we can't place
39
+ * (which would only yield a "(manifest missing)" block). Known aliases are
40
+ * normalized cross-vendor: e.g. Claude Desktop identifies as "claude-ai".
41
+ */
42
+ const PEER_CLIENT_ALIASES = {
43
+ 'claude-code': 'claude-code',
44
+ 'claude-ai': 'claude-desktop', // Claude Desktop's MCP clientInfo.name
45
+ 'claude-desktop': 'claude-desktop',
46
+ claude: 'claude-desktop',
47
+ 'gemini-cli': 'gemini-cli',
48
+ gemini: 'gemini-cli',
49
+ opencode: 'opencode',
50
+ };
51
+ export function resolveClientFromPeer(peerName) {
52
+ if (!peerName)
53
+ return undefined;
54
+ // Proxies annotate the name, e.g. "claude-ai (via mcp-remote 0.1.37)" — take
55
+ // the base identity before the first parenthetical, then normalize.
56
+ const base = peerName.split(/\s*\(/, 1)[0];
57
+ const normalized = base.trim().toLowerCase().replace(/[\s_]+/g, '-');
58
+ return PEER_CLIENT_ALIASES[normalized];
59
+ }
60
+ export async function loadIdentity(contextDir, project, client, model, role,
61
+ /** Override the default context path — used in unit tests only. */
62
+ _defaultPathForTest) {
63
+ const parts = [];
64
+ const effectiveClient = client ?? process.env.LOOM_CLIENT;
65
+ const effectiveModel = model ?? process.env.LOOM_MODEL;
66
+ // Boundary discipline: every param that becomes a path segment validates
67
+ // here, before any file read. Empty/unset values are simply "not provided"
68
+ // (all readers below are guarded), so only truthy values are checked.
69
+ for (const [name, value] of [
70
+ ['project', project],
71
+ ['client', effectiveClient],
72
+ ['model', effectiveModel],
73
+ ['role', role],
74
+ ]) {
75
+ if (value) {
76
+ const error = pathSegmentError(value, name);
77
+ if (error)
78
+ return error;
79
+ }
80
+ }
81
+ const defaultPath = _defaultPathForTest ?? resolveDefaultContextPath();
82
+ // Terminal creed — the immutable identity
83
+ const creed = await readOptional(join(contextDir, 'IDENTITY.md'));
84
+ if (creed) {
85
+ parts.push('# Identity\n\n' + creed.trim());
86
+ }
87
+ else if (contextDir === defaultPath) {
88
+ // Default fallback path with no creed: refuse to serve a blank identity.
89
+ // An explicit LOOM_CONTEXT_DIR pointing at an empty dir is allowed (opt-in);
90
+ // a silent default fallback is not.
91
+ throw new Error('no loom context configured — refusing to serve a blank identity. ' +
92
+ 'Set LOOM_CONTEXT_DIR or run `loom bootstrap` to initialize an agent.');
93
+ }
94
+ else {
95
+ parts.push('# Identity\n\n' +
96
+ '*No IDENTITY.md found. Create one in your context directory to define who this agent is.*');
97
+ }
98
+ // Preferences — how the user likes to work
99
+ const preferences = await readOptional(join(contextDir, 'preferences.md'));
100
+ if (preferences) {
101
+ parts.push('# Preferences\n\n' + preferences.trim());
102
+ }
103
+ // Episode tape (t-142) — the short-term cross-body tier: what happened across
104
+ // ALL sleeves in the last 24h, time-ordered and unconditional. Injected right
105
+ // after preferences so a fresh body knows what the last body did before it
106
+ // reads anything ranked. Best-effort: no store / no episodes → no block.
107
+ try {
108
+ const tape = tapeForContext(contextDir);
109
+ if (tape) {
110
+ parts.push('# Last 24h across bodies\n\n' +
111
+ '_Episodes other sleeves of you left (short-term, 48h TTL). This is the tape, ' +
112
+ 'oldest first — what just happened, not what is important. Write your own before ' +
113
+ 'you end (category `episode`, metadata.where = your surface); the harness writes a ' +
114
+ 'fallback if you forget._\n\n' +
115
+ tape);
116
+ }
117
+ }
118
+ catch {
119
+ // the tape must never block identity load
120
+ }
121
+ // Boot digest — the salience-tiered view of episodic memory, so a fresh sleeve
122
+ // wakes knowing what is top-of-mind vs settled background without fishing via
123
+ // recall. ASSEMBLED from authored atoms (never generated); injected after
124
+ // preferences. Best-effort — a missing/empty store just omits the block.
125
+ try {
126
+ const digest = digestForContext(contextDir);
127
+ if (digest)
128
+ parts.push('# Top of Mind\n\n' + digest);
129
+ }
130
+ catch {
131
+ // a digest failure must never block identity load
132
+ }
133
+ // Self-model — what the agent knows about its own capabilities
134
+ const selfModel = await readOptional(join(contextDir, 'self-model.md'));
135
+ if (selfModel) {
136
+ parts.push('# Self-Model\n\n' + selfModel.trim());
137
+ }
138
+ // Mode addendum — when Art is dispatched into a reflection mode (wonder,
139
+ // tend, retro, consolidate, identity), append that mode's playbook. The SAME
140
+ // mechanism dossier() uses for worker roles; lives in roles/<role>.md.
141
+ if (role) {
142
+ const modeBrief = await readOptional(join(contextDir, 'roles', `${role}.md`));
143
+ if (modeBrief) {
144
+ parts.push(`# Mode: ${role}\n\n` + modeBrief.trim());
145
+ }
146
+ }
147
+ // Project-specific context
148
+ if (project) {
149
+ const projectBrief = await readOptional(join(contextDir, 'projects', `${project}.md`));
150
+ if (projectBrief) {
151
+ parts.push(`# Project: ${project}\n\n` + projectBrief.trim());
152
+ }
153
+ }
154
+ // Harness manifest — the shape of the current runtime (stack spec §4.7).
155
+ if (effectiveClient) {
156
+ const block = await harnessBlock.read(contextDir, effectiveClient);
157
+ if (block) {
158
+ parts.push(`# Harness: ${effectiveClient}\n\n${block.body}`);
159
+ }
160
+ else {
161
+ // Genuinely unknown runtime: instead of a bare "(manifest missing)" stub,
162
+ // instruct the connected harness to self-describe via harness_describe.
163
+ parts.push(`# Harness: ${effectiveClient} (unknown)\n\n` +
164
+ `I don't have a manifest for this runtime. Describe yourself by calling ` +
165
+ `the harness_describe tool with a manifest covering: tool surface / ` +
166
+ `prefixes, sandbox & filesystem, delegation primitive, scheduling, ` +
167
+ `session search, memory layers, and gotchas.`);
168
+ }
169
+ }
170
+ // Model manifest — model-family-specific capability notes (stack spec §4.8).
171
+ if (effectiveModel) {
172
+ const block = await modelBlock.read(contextDir, effectiveModel);
173
+ if (block) {
174
+ parts.push(`# Model: ${effectiveModel}\n\n${block.body}`);
175
+ }
176
+ else {
177
+ parts.push(`# Model: ${effectiveModel} (manifest missing)\n\n` +
178
+ `No manifest found at ${contextDir}/models/${effectiveModel}.md. ` +
179
+ `Write one — here's the template:\n\n` +
180
+ modelBlock.template(effectiveModel));
181
+ }
182
+ }
183
+ // Optional recent-memory summary. The memory store of record is
184
+ // memories.db (sqlite-vec); navigate it via recall(). If a legacy
185
+ // memories/INDEX.md sidecar exists from FS-backend days, surface a
186
+ // brief summary as a hint — full content lives in the DB.
187
+ const memoryIndex = await readOptional(join(contextDir, 'memories', 'INDEX.md'));
188
+ if (memoryIndex) {
189
+ parts.push('# Memories\n\n' + summarizeMemoryIndex(memoryIndex));
190
+ }
191
+ // Runtime-specific context (tool name prefix, notes) — legacy `## Runtime:` block.
192
+ if (effectiveClient) {
193
+ const adapter = await loadClientAdapter(contextDir, effectiveClient);
194
+ if (adapter) {
195
+ parts.push(adapter);
196
+ }
197
+ }
198
+ return parts.join('\n\n---\n\n');
199
+ }
200
+ /**
201
+ * Produce a compact summary of a legacy memories/INDEX.md sidecar. The
202
+ * DB is the source of truth; this only surfaces a hint when an old
203
+ * filesystem-era index still exists in the context dir.
204
+ */
205
+ function summarizeMemoryIndex(index) {
206
+ const lines = index.split('\n').filter((l) => l.trim().startsWith('- '));
207
+ // Count by type tag e.g. [project], [feedback], [reference], [user], [self]
208
+ const counts = {};
209
+ for (const line of lines) {
210
+ const match = line.match(/\[(\w+)\]/);
211
+ if (match) {
212
+ const type = match[1];
213
+ counts[type] = (counts[type] ?? 0) + 1;
214
+ }
215
+ }
216
+ const total = lines.length;
217
+ const countStr = Object.entries(counts)
218
+ .sort((a, b) => b[1] - a[1])
219
+ .map(([t, n]) => `${n} ${t}`)
220
+ .join(', ');
221
+ // Show last 5 entries as a recent-activity signal
222
+ const recent = lines.slice(-5).map((l) => l.trim()).join('\n');
223
+ return (`${total} memories stored (${countStr || 'various types'}).\n` +
224
+ `Use \`recall()\` to search by relevance.\n\n` +
225
+ `**Recent:**\n${recent}`);
226
+ }
@@ -0,0 +1,5 @@
1
+ export interface KnowledgeArchiveToolInput {
2
+ slug: string;
3
+ note?: string;
4
+ }
5
+ export declare function knowledgeArchive(contextDir: string, input: KnowledgeArchiveToolInput): Promise<string>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * knowledge_archive tool — soft-retire a knowledge page.
3
+ *
4
+ * Sets status='archived' with an optional tombstone note. Archived pages are
5
+ * excluded from knowledge_recall and knowledge_maintain but remain in the DB
6
+ * and are recoverable via knowledge_restore. Mirror of memory_archive.
7
+ */
8
+ import { createKnowledgeBackend } from '../backends/index.js';
9
+ export async function knowledgeArchive(contextDir, input) {
10
+ const backend = createKnowledgeBackend(contextDir);
11
+ try {
12
+ const result = await backend.archivePage({ slug: input.slug, note: input.note });
13
+ if (!result.archived) {
14
+ return `Knowledge page not found or already archived: \`${input.slug}\`. Use knowledge_recall to find the correct slug.`;
15
+ }
16
+ return `Knowledge page archived: \`${input.slug}\``;
17
+ }
18
+ catch (e) {
19
+ return `Error: ${e.message}`;
20
+ }
21
+ finally {
22
+ backend.close();
23
+ }
24
+ }
@@ -0,0 +1,6 @@
1
+ export interface KnowledgeHistoryToolInput {
2
+ slug: string;
3
+ revision_id?: number;
4
+ restore?: boolean;
5
+ }
6
+ export declare function knowledgeHistory(contextDir: string, input: KnowledgeHistoryToolInput): Promise<string>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * knowledge_history tool — body-revision listing, inspection, and restore.
3
+ *
4
+ * Replace-writes snapshot the displaced body into page_revisions (capped per
5
+ * page, newest kept). This tool is the recovery surface: list a page's
6
+ * snapshots, read one, or restore one. A restore snapshots the body it
7
+ * displaces — restoring is never itself a destructive overwrite.
8
+ */
9
+ import { createKnowledgeBackend } from '../backends/index.js';
10
+ export async function knowledgeHistory(contextDir, input) {
11
+ if (!input.slug) {
12
+ return 'Error: slug is required.';
13
+ }
14
+ if (input.restore && input.revision_id === undefined) {
15
+ return 'Error: restore requires a revision_id — list revisions first to pick one.';
16
+ }
17
+ const backend = createKnowledgeBackend(contextDir);
18
+ try {
19
+ // ── Restore mode ──
20
+ if (input.restore && input.revision_id !== undefined) {
21
+ const result = await backend.restoreRevision({
22
+ slug: input.slug,
23
+ revision_id: input.revision_id,
24
+ });
25
+ return (`Restored revision #${result.revision_id} onto \`${result.slug}\`. ` +
26
+ `The displaced body was snapshotted as revision #${result.snapshot_id}.`);
27
+ }
28
+ // ── Read mode ──
29
+ if (input.revision_id !== undefined) {
30
+ const revision = await backend.getRevision(input.revision_id);
31
+ if (!revision) {
32
+ return `Error: revision not found: ${input.revision_id}`;
33
+ }
34
+ return (`# Revision #${revision.id} of \`${revision.slug}\`\n` +
35
+ `op: ${revision.op} | replaced: ${revision.replaced_at}\n\n` +
36
+ `${revision.body}\n\n` +
37
+ `_Pass restore: true to put this body back on the page._`);
38
+ }
39
+ // ── List mode ──
40
+ 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.`;
43
+ }
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._`);
48
+ }
49
+ catch (e) {
50
+ return `Error: ${e.message}`;
51
+ }
52
+ finally {
53
+ backend.close();
54
+ }
55
+ }
@@ -0,0 +1,9 @@
1
+ export interface KnowledgeMaintainOptions {
2
+ /** hit_count threshold for expansion candidates (default 3) */
3
+ expansionHitThreshold?: number;
4
+ /** body length (chars) below which a page is considered thin (default 500) */
5
+ thinBodyThreshold?: number;
6
+ /** days since last_accessed to consider cold (default 30) */
7
+ coldDays?: number;
8
+ }
9
+ export declare function knowledgeMaintain(contextDir: string, options?: KnowledgeMaintainOptions): Promise<string>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * knowledge_maintain tool — read-only health report for the knowledge store.
3
+ *
4
+ * Three branches, all report-only (no fetch, no mutation):
5
+ * 1. Expansion candidates — thin body + high hit_count (needs deepening)
6
+ * 2. Cold pages — no recent hits (unused/undiscovered)
7
+ * 3. Misfile audit — provisional sourcing or all-conversation citations
8
+ * (world/ class: should be memories, not knowledge.
9
+ * ours/ class with internal sourcing: NOT misfiled — these are correct.)
10
+ *
11
+ * Two knowledge classes (t-81, 2026-08-31):
12
+ * world/ — facts true independent of us (default).
13
+ * ours/ — Art-created artifacts; domain starts with "ours/", sourcing = "internal".
14
+ */
15
+ import { createKnowledgeBackend } from '../backends/index.js';
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ /**
18
+ * True for world-class pages that look like misfiles (should be in memory).
19
+ * ours/ pages with `internal` sourcing are NEVER misfiles — excluded entirely.
20
+ * ours/ pages that are still provisional ARE flagged: they lack a repo citation.
21
+ */
22
+ function isMisfiled(page) {
23
+ // ours/ class with internal sourcing: correct placement, never a misfile.
24
+ if (page.domain.startsWith('ours/') && page.sourcing === 'internal')
25
+ return false;
26
+ if (page.sourcing === 'provisional')
27
+ return true;
28
+ if (page.citations.length === 0)
29
+ return false;
30
+ return page.citations.every((c) => c.source_kind === 'conversation');
31
+ }
32
+ function isCold(page, thresholdMs) {
33
+ if (page.hit_count === 0 && !page.last_accessed)
34
+ return true;
35
+ if (!page.last_accessed)
36
+ return true;
37
+ const lastMs = new Date(page.last_accessed).getTime();
38
+ return Number.isNaN(lastMs) ? true : Date.now() - lastMs > thresholdMs;
39
+ }
40
+ export async function knowledgeMaintain(contextDir, options = {}) {
41
+ const hitThreshold = options.expansionHitThreshold ?? 3;
42
+ const bodyThreshold = options.thinBodyThreshold ?? 500;
43
+ const coldMs = (options.coldDays ?? 30) * DAY_MS;
44
+ const backend = createKnowledgeBackend(contextDir);
45
+ try {
46
+ const pages = await backend.listPages({ limit: 1000 });
47
+ const expansionCandidates = pages.filter((p) => p.hit_count >= hitThreshold && p.body.length < bodyThreshold && p.status === 'active');
48
+ const coldPages = pages.filter((p) => isCold(p, coldMs) && p.status === 'active');
49
+ const misfiled = pages.filter((p) => isMisfiled(p) && p.status === 'active');
50
+ const activeCount = pages.filter((p) => p.status === 'active').length;
51
+ return formatMaintainReport(activeCount, expansionCandidates, coldPages, misfiled, {
52
+ hitThreshold,
53
+ bodyThreshold,
54
+ coldDays: options.coldDays ?? 30,
55
+ });
56
+ }
57
+ finally {
58
+ backend.close();
59
+ }
60
+ }
61
+ function formatMaintainReport(total, expansion, cold, misfiled, thresholds) {
62
+ const lines = [];
63
+ lines.push('# Knowledge maintain report');
64
+ lines.push('');
65
+ lines.push(`**Total active pages:** ${total}`);
66
+ // ── Expansion candidates ──────────────────────────────────────────────────
67
+ lines.push('');
68
+ lines.push(`## Expansion candidates (hit_count ≥ ${thresholds.hitThreshold}, body < ${thresholds.bodyThreshold} chars)`);
69
+ lines.push('');
70
+ if (expansion.length === 0) {
71
+ lines.push('None — no thin but frequently-accessed pages.');
72
+ }
73
+ else {
74
+ lines.push(`${expansion.length} page${expansion.length === 1 ? '' : 's'} are thin but frequently accessed — consider deepening:`);
75
+ for (const p of expansion) {
76
+ lines.push(`- \`${p.slug}\` — *${p.title}* (${p.domain}) — ${p.hit_count} hits, ${p.body.length} chars`);
77
+ }
78
+ }
79
+ // ── Cold pages ────────────────────────────────────────────────────────────
80
+ lines.push('');
81
+ lines.push(`## Cold pages (no access in ${thresholds.coldDays}+ days, or never accessed)`);
82
+ lines.push('');
83
+ if (cold.length === 0) {
84
+ lines.push('None — all active pages have been accessed recently.');
85
+ }
86
+ else {
87
+ lines.push(`${cold.length} page${cold.length === 1 ? '' : 's'} have not been accessed recently:`);
88
+ for (const p of cold) {
89
+ const lastAccess = p.last_accessed ? p.last_accessed.slice(0, 10) : 'never';
90
+ lines.push(`- \`${p.slug}\` — *${p.title}* (${p.domain}) — last access: ${lastAccess}`);
91
+ }
92
+ }
93
+ // ── Misfile audit ─────────────────────────────────────────────────────────
94
+ lines.push('');
95
+ lines.push('## Misfile audit (provisional sourcing or conversation-only citations)');
96
+ lines.push('');
97
+ if (misfiled.length === 0) {
98
+ lines.push('None — all active pages have independent citation support.');
99
+ }
100
+ else {
101
+ lines.push(`${misfiled.length} page${misfiled.length === 1 ? '' : 's'} may need attention:`);
102
+ 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.');
104
+ for (const p of misfiled) {
105
+ const reason = p.sourcing === 'provisional' ? 'provisional' : 'conversation-only citations';
106
+ const hint = p.domain.startsWith('ours/')
107
+ ? '→ add a repo citation (git path / commit)'
108
+ : '→ add a web citation or relocate to memory';
109
+ lines.push(`- \`${p.slug}\` — *${p.title}* (${p.domain}) — ${reason} ${hint}`);
110
+ }
111
+ }
112
+ return lines.join('\n');
113
+ }
@@ -0,0 +1,8 @@
1
+ export interface KnowledgeMergeToolInput {
2
+ source_slugs: string[];
3
+ target_slug: string;
4
+ note?: string;
5
+ hard_delete_losers?: boolean;
6
+ append_loser_bodies?: boolean;
7
+ }
8
+ export declare function knowledgeMerge(contextDir: string, input: KnowledgeMergeToolInput): Promise<string>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * knowledge_merge tool — consolidate N knowledge pages into one canonical page.
3
+ *
4
+ * Re-parents all citations from source pages to the target, deduplicating by
5
+ * (claim, source_kind, source_locator, excerpt). Takes MAX(verified_at) across
6
+ * all pages. Supersedes losers (archives each with a pointer to target).
7
+ *
8
+ * Distinct from knowledge_supersede (1:1 pointer, no citation consolidation):
9
+ * use merge when there is real data to consolidate from multiple pages.
10
+ */
11
+ import { createKnowledgeBackend } from '../backends/index.js';
12
+ export async function knowledgeMerge(contextDir, input) {
13
+ if (!input.source_slugs || input.source_slugs.length === 0) {
14
+ return 'Error: source_slugs must not be empty.';
15
+ }
16
+ const backend = createKnowledgeBackend(contextDir);
17
+ try {
18
+ const result = await backend.mergePages(input);
19
+ const lines = [
20
+ `Merged ${result.sources_merged} page(s) into \`${result.target_slug}\`.`,
21
+ `Citations: ${result.citations_moved} moved to target, ${result.citations_deduped} duplicate(s) removed.`,
22
+ `verified_at set to ${result.verified_at}.`,
23
+ `Losers archived: ${result.losers.map((l) => `\`${l.slug}\``).join(', ')}.`,
24
+ ];
25
+ if (!input.append_loser_bodies && result.losers.length > 0) {
26
+ lines.push('');
27
+ lines.push('Loser bodies (target body NOT modified — curator review):');
28
+ for (const loser of result.losers) {
29
+ lines.push(`\n**${loser.slug}:**\n${loser.body}`);
30
+ }
31
+ }
32
+ return lines.join('\n');
33
+ }
34
+ catch (e) {
35
+ return `Error: ${e.message}`;
36
+ }
37
+ finally {
38
+ backend.close();
39
+ }
40
+ }
@@ -0,0 +1,10 @@
1
+ export interface KnowledgeMoveToolInput {
2
+ slug?: string;
3
+ new_slug?: string;
4
+ new_domain?: string;
5
+ leave_pointer?: boolean;
6
+ slugs?: string[];
7
+ from_domain_prefix?: string;
8
+ to_domain_prefix?: string;
9
+ }
10
+ export declare function knowledgeMove(contextDir: string, input: KnowledgeMoveToolInput): Promise<string>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * knowledge_move tool — re-key or re-domain a knowledge page in place.
3
+ *
4
+ * Three modes:
5
+ * 1. Single-page: change slug and/or domain on one page (same row, same uuid).
6
+ * 2. Batch by slug list: re-domain a set of named pages atomically.
7
+ * 3. Batch by domain prefix: re-home an entire subtree in one transaction.
8
+ *
9
+ * Slug changes write a supersessions pointer (old→new) unless leave_pointer=false.
10
+ * Slug collisions are rejected — use knowledge_merge instead.
11
+ */
12
+ import { createKnowledgeBackend } from '../backends/index.js';
13
+ export async function knowledgeMove(contextDir, input) {
14
+ const backend = createKnowledgeBackend(contextDir);
15
+ try {
16
+ const result = await backend.movePage(input);
17
+ if (result.moved === 0) {
18
+ return 'No pages matched — nothing moved.';
19
+ }
20
+ const lines = [];
21
+ if (input.from_domain_prefix !== undefined) {
22
+ lines.push(`Moved ${result.moved} page(s) from domain prefix \`${input.from_domain_prefix}\` → \`${input.to_domain_prefix}\`.`);
23
+ }
24
+ else if (input.slugs && input.slugs.length > 0) {
25
+ lines.push(`Moved ${result.moved} page(s) to domain \`${input.new_domain}\`.`);
26
+ }
27
+ else {
28
+ const page = result.pages[0];
29
+ if (page.old_slug) {
30
+ lines.push(`Re-slugged \`${page.old_slug}\` → \`${page.slug}\`.`);
31
+ if (result.pointers_written > 0) {
32
+ lines.push('Supersessions pointer written (old slug → new slug).');
33
+ }
34
+ }
35
+ if (page.new_domain !== page.old_domain) {
36
+ lines.push(`Domain changed: \`${page.old_domain}\` → \`${page.new_domain}\`.`);
37
+ }
38
+ lines.push(`uuid, citations, and verification history are unchanged.`);
39
+ }
40
+ return lines.join(' ');
41
+ }
42
+ catch (e) {
43
+ return `Error: ${e.message}`;
44
+ }
45
+ finally {
46
+ backend.close();
47
+ }
48
+ }
@@ -0,0 +1,5 @@
1
+ export interface KnowledgePurgeToolInput {
2
+ slugs: string[];
3
+ confirm: boolean;
4
+ }
5
+ export declare function knowledgePurge(contextDir: string, input: KnowledgePurgeToolInput): Promise<string>;
@@ -0,0 +1,20 @@
1
+ import { createKnowledgeBackend } from '../backends/index.js';
2
+ export async function knowledgePurge(contextDir, input) {
3
+ if (!input.confirm) {
4
+ return 'Error: confirm must be explicitly true to execute a hard delete.';
5
+ }
6
+ if (!input.slugs || input.slugs.length === 0) {
7
+ return 'Error: slugs must not be empty.';
8
+ }
9
+ const backend = createKnowledgeBackend(contextDir);
10
+ try {
11
+ const result = await backend.purgePages({ slugs: input.slugs, confirm: true });
12
+ return `Purged ${result.purged} page(s): ${result.slugs.map((s) => `\`${s}\``).join(', ')}.`;
13
+ }
14
+ catch (e) {
15
+ return `Error: ${e.message}`;
16
+ }
17
+ finally {
18
+ backend.close();
19
+ }
20
+ }
@@ -0,0 +1,20 @@
1
+ export interface KnowledgeRecallInput {
2
+ /**
3
+ * Exact-slug lookup — fetches that single page in full detail, stamping
4
+ * access. Takes precedence over query/domain/limit. This is the precise
5
+ * path; LIKE token-matching can return the wrong page when bodies
6
+ * cross-reference each other.
7
+ */
8
+ slug?: string;
9
+ query?: string;
10
+ domain?: string;
11
+ limit?: number;
12
+ detail?: 'index' | 'full';
13
+ /**
14
+ * Stale-first ordering for the verification engine: verified_at ASC with
15
+ * never-verified pages first. Index entries gain a `verified:` stamp so
16
+ * the verifier can apply its SLA filter from the listing alone.
17
+ */
18
+ sort_by_verified?: boolean;
19
+ }
20
+ export declare function knowledgeRecall(contextDir: string, input: KnowledgeRecallInput): Promise<string>;