@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.
- package/CHANGELOG.md +114 -1
- package/README.md +97 -0
- package/dist/backends/dedup.d.ts +23 -0
- package/dist/backends/dedup.js +100 -0
- package/dist/backends/embedding-runtime.js +38 -9
- package/dist/backends/episodes.js +1 -1
- package/dist/backends/fastembed.js +30 -4
- package/dist/backends/migrations.js +200 -0
- package/dist/backends/mmr.d.ts +8 -0
- package/dist/backends/mmr.js +18 -4
- package/dist/backends/people.d.ts +192 -0
- package/dist/backends/people.js +824 -0
- package/dist/backends/proposals.js +1 -1
- package/dist/backends/recorder-health.d.ts +34 -0
- package/dist/backends/recorder-health.js +117 -0
- package/dist/backends/salience.d.ts +39 -0
- package/dist/backends/salience.js +52 -4
- package/dist/backends/scope.d.ts +56 -0
- package/dist/backends/scope.js +75 -0
- package/dist/backends/sqlite-knowledge.d.ts +24 -4
- package/dist/backends/sqlite-knowledge.js +253 -91
- package/dist/backends/sqlite-vec.d.ts +32 -1
- package/dist/backends/sqlite-vec.js +394 -75
- package/dist/backends/types.d.ts +203 -3
- package/dist/categories.d.ts +2 -0
- package/dist/categories.js +2 -0
- package/dist/cli/args.d.ts +16 -1
- package/dist/cli/args.js +45 -1
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.js +57 -3
- package/dist/cli/knowledge.js +4 -1
- package/dist/cli/memory.js +2 -2
- package/dist/cli/person.d.ts +2 -0
- package/dist/cli/person.js +116 -0
- package/dist/cli/recall.js +2 -1
- package/dist/cli/remember.js +10 -0
- package/dist/cli/remote.d.ts +36 -0
- package/dist/cli/remote.js +173 -0
- package/dist/cli/serve.d.ts +7 -0
- package/dist/cli/serve.js +94 -5
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- package/dist/cli/update.js +30 -11
- package/dist/clients.js +7 -4
- package/dist/config.d.ts +14 -0
- package/dist/config.js +25 -2
- package/dist/index.js +22 -7
- package/dist/injection/harnesses.js +9 -3
- package/dist/install/harnesses.js +7 -1
- package/dist/server.d.ts +8 -1
- package/dist/server.js +461 -374
- package/dist/tools/dossier.js +5 -1
- package/dist/tools/eurorack-import.js +3 -0
- package/dist/tools/identity.d.ts +2 -0
- package/dist/tools/identity.js +35 -2
- package/dist/tools/knowledge-history.d.ts +6 -1
- package/dist/tools/knowledge-history.js +26 -8
- package/dist/tools/knowledge-maintain.d.ts +5 -0
- package/dist/tools/knowledge-maintain.js +92 -5
- package/dist/tools/knowledge-recall.d.ts +7 -1
- package/dist/tools/knowledge-recall.js +53 -3
- package/dist/tools/knowledge-verify.d.ts +11 -1
- package/dist/tools/knowledge-verify.js +17 -4
- package/dist/tools/knowledge-write.d.ts +8 -1
- package/dist/tools/knowledge-write.js +27 -9
- package/dist/tools/memory-history.d.ts +5 -0
- package/dist/tools/memory-history.js +52 -0
- package/dist/tools/memory-list.js +2 -1
- package/dist/tools/memory-merge.d.ts +2 -0
- package/dist/tools/memory-merge.js +40 -0
- package/dist/tools/memory-revision-restore.d.ts +5 -0
- package/dist/tools/memory-revision-restore.js +33 -0
- package/dist/tools/person.d.ts +32 -0
- package/dist/tools/person.js +111 -0
- package/dist/tools/prune.js +1 -1
- package/dist/tools/recall.js +7 -1
- package/dist/tools/remember.js +18 -2
- package/dist/tools/update.js +5 -1
- package/dist/transport/cli-endpoint.d.ts +13 -0
- package/dist/transport/cli-endpoint.js +97 -0
- package/dist/transport/guards.d.ts +14 -0
- package/dist/transport/guards.js +26 -0
- package/dist/transport/http-server.d.ts +28 -1
- package/dist/transport/http-server.js +156 -18
- package/dist/transport/knowledge-server.d.ts +11 -0
- package/dist/transport/knowledge-server.js +30 -0
- package/dist/transport/knowledge-tools.d.ts +50 -0
- package/dist/transport/knowledge-tools.js +290 -0
- package/dist/transport/mcp-app-hello.d.ts +1 -1
- package/dist/widgets/display-modes.html +62 -56
- package/dist/widgets/hello.html +62 -56
- package/dist/widgets/proposals.html +62 -56
- package/dist/widgets/top-of-mind.html +60 -54
- package/package.json +7 -5
package/dist/tools/dossier.js
CHANGED
|
@@ -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
|
}
|
package/dist/tools/identity.d.ts
CHANGED
|
@@ -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>;
|
package/dist/tools/identity.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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]}\`
|
|
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)
|
|
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
|
|
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
|
|
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. ' +
|