@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
@@ -0,0 +1,52 @@
1
+ /**
2
+ * memory_history tool — body-revision listing and inspection for memories.
3
+ *
4
+ * update() snapshots the displaced body into memory_revisions before
5
+ * overwriting it. This tool is the read surface: list a memory's snapshots
6
+ * (metadata only) or read one snapshot's full content. To put a snapshot
7
+ * back, use memory_revision_restore.
8
+ */
9
+ import { createBackend } from '../backends/index.js';
10
+ export async function memoryHistory(contextDir, input) {
11
+ if (!input.ref) {
12
+ return 'Error: ref is required.';
13
+ }
14
+ const backend = createBackend(contextDir);
15
+ try {
16
+ // ── Read mode ──
17
+ if (input.revision_id !== undefined) {
18
+ const revision = await backend.getRevision(input.revision_id);
19
+ if (!revision) {
20
+ return `Error: revision not found: ${input.revision_id}`;
21
+ }
22
+ // Ownership is memory_id, not the ref recorded at snapshot time:
23
+ // memory_merge re-parents a source's revisions onto the target and
24
+ // keeps the original ref as provenance (PR120 review). So a revision
25
+ // belongs to input.ref iff it is in input.ref's listing.
26
+ const owned = (await backend.listRevisions(input.ref)).some((r) => r.id === revision.id);
27
+ if (!owned) {
28
+ return `Error: revision #${input.revision_id} does not belong to \`${input.ref}\` (recorded on \`${revision.ref}\`).`;
29
+ }
30
+ const provenance = revision.ref !== input.ref ? ` (recorded on \`${revision.ref}\`, merged in)` : '';
31
+ return (`# Revision #${revision.id} of \`${input.ref}\`${provenance}\n` +
32
+ `op: ${revision.op} | replaced: ${revision.replaced_at}\n\n` +
33
+ `${revision.content}\n\n` +
34
+ `_Pass to memory_revision_restore to put this body back._`);
35
+ }
36
+ // ── List mode ──
37
+ const revisions = await backend.listRevisions(input.ref);
38
+ if (revisions.length === 0) {
39
+ return `\`${input.ref}\` has no revisions — its body has never been replaced.`;
40
+ }
41
+ const lines = revisions.map((r) => `- **#${r.id}** | ${r.op} | replaced ${r.replaced_at} | ${r.content_length} chars` +
42
+ (r.ref !== input.ref ? ` | from \`${r.ref}\` (merged in)` : ''));
43
+ return (`# Revision history — \`${input.ref}\` — ${revisions.length} snapshot(s)\n\n` +
44
+ `${lines.join('\n')}\n\n` +
45
+ `_Pass revision_id to read a snapshot; use memory_revision_restore to put one back._`);
46
+ }
47
+ catch (e) {
48
+ return `Error: ${e.message}`;
49
+ }
50
+ // No close(): createBackend hands out the shared, process-cached handle.
51
+ // Closing it here would kill any in-flight call on the same backend.
52
+ }
@@ -8,7 +8,8 @@
8
8
  import { createBackend } from '../backends/index.js';
9
9
  export function formatEntry(e) {
10
10
  const projectTag = e.project ? ` [${e.project}]` : '';
11
- return `- **${e.title}** — ${e.category}${projectTag} (${e.created.slice(0, 10)})\n ref: \`${e.ref}\``;
11
+ const confidenceTag = e.confidence !== undefined ? ` · confidence ${e.confidence.toFixed(2)} (${e.evidenceCount} obs)` : '';
12
+ return `- **${e.title}** — ${e.category}${projectTag} (${e.created.slice(0, 10)})${confidenceTag}\n ref: \`${e.ref}\``;
12
13
  }
13
14
  export function formatEntries(entries) {
14
15
  if (entries.length === 0)
@@ -0,0 +1,2 @@
1
+ import type { MemoryMergeInput } from '../backends/types.js';
2
+ export declare function memoryMerge(contextDir: string, input: MemoryMergeInput): Promise<string>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * memory_merge tool — consolidate N memories into one canonical row.
3
+ *
4
+ * Acts on a memory_audit duplicate-finding: pick a survivor (target_ref) and
5
+ * fold the rest (source_refs) into it. Sources are archived with a
6
+ * tombstone pointing at the target and the supersession edge is recorded in
7
+ * memory_supersessions (same table supersede() writes to) — merge is just
8
+ * supersede() for N sources instead of one, plus times_seen consolidation
9
+ * and (optionally) body concatenation. Distinct from forget/archive, which
10
+ * only discard one side of a duplicate pair rather than consolidating it.
11
+ */
12
+ import { createBackend } from '../backends/index.js';
13
+ export async function memoryMerge(contextDir, input) {
14
+ if (!input.source_refs || input.source_refs.length === 0) {
15
+ return 'Error: source_refs must not be empty.';
16
+ }
17
+ const backend = createBackend(contextDir);
18
+ try {
19
+ const result = await backend.mergeMemories(input);
20
+ const lines = [
21
+ `Merged ${result.sources_merged} memory(ies) into \`${result.target_ref}\`.`,
22
+ `times_seen on the survivor: ${result.times_seen}.`,
23
+ `Losers archived: ${result.losers.map((l) => `\`${l.ref}\``).join(', ')}.`,
24
+ ];
25
+ if (!input.append_loser_bodies && result.losers.length > 0) {
26
+ lines.push('');
27
+ lines.push('Loser bodies (target content NOT modified — review before discarding):');
28
+ for (const loser of result.losers) {
29
+ lines.push(`\n**${loser.ref}** (*${loser.title}*):\n${loser.content}`);
30
+ }
31
+ }
32
+ return lines.join('\n');
33
+ }
34
+ catch (e) {
35
+ return `Error: ${e.message}`;
36
+ }
37
+ // No close(): createBackend hands out the shared, process-cached handle.
38
+ // Closing it here would kill any in-flight call on the same backend
39
+ // (PR120 review — same lifecycle bug fixed in memory_history).
40
+ }
@@ -0,0 +1,5 @@
1
+ export interface MemoryRevisionRestoreToolInput {
2
+ ref: string;
3
+ revision_id: number;
4
+ }
5
+ export declare function memoryRevisionRestore(contextDir: string, input: MemoryRevisionRestoreToolInput): Promise<string>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * memory_revision_restore tool — restore a snapshotted body onto a memory.
3
+ *
4
+ * Snapshots the current body first, so restore is never destructive.
5
+ * Use memory_history to list revision IDs before calling this.
6
+ */
7
+ import { createBackend } from '../backends/index.js';
8
+ export async function memoryRevisionRestore(contextDir, input) {
9
+ if (!input.ref) {
10
+ return 'Error: ref is required.';
11
+ }
12
+ if (input.revision_id === undefined) {
13
+ return 'Error: revision_id is required — list revisions with memory_history first.';
14
+ }
15
+ const backend = createBackend(contextDir);
16
+ try {
17
+ const result = await backend.restoreRevision({
18
+ ref: input.ref,
19
+ revision_id: input.revision_id,
20
+ });
21
+ if (!result.restored) {
22
+ return (`Error: could not restore revision #${input.revision_id} onto \`${input.ref}\`. ` +
23
+ `Check that the ref exists and the revision belongs to it.`);
24
+ }
25
+ return (`Restored revision #${result.revision_id} onto \`${result.ref}\`. ` +
26
+ `The displaced body was snapshotted as revision #${result.snapshot_id}.`);
27
+ }
28
+ catch (e) {
29
+ return `Error: ${e.message}`;
30
+ }
31
+ // No close(): createBackend hands out the shared, process-cached handle.
32
+ // Closing it here would kill any in-flight call on the same backend.
33
+ }
@@ -0,0 +1,32 @@
1
+ import { type PersonWriteInput, type PersonReadResult, type PersonListEntry } from '../backends/people.js';
2
+ export declare function personWrite(contextDir: string, input: PersonWriteInput): Promise<string>;
3
+ export declare function personVerify(contextDir: string, input: {
4
+ slug: string;
5
+ fields: string[];
6
+ verified_at?: string;
7
+ source?: string;
8
+ }): Promise<string>;
9
+ export declare function personArchive(contextDir: string, input: {
10
+ slug: string;
11
+ reason: string;
12
+ }): Promise<string>;
13
+ export declare function personRestore(contextDir: string, input: {
14
+ slug: string;
15
+ }): Promise<string>;
16
+ export declare function personRevisionRestore(contextDir: string, input: {
17
+ slug: string;
18
+ revision_id: number;
19
+ }): Promise<string>;
20
+ /** The `person` read. Throws on an invalid slug or an unreadable row; the MCP wrapper turns that into text. */
21
+ export declare function readPerson(contextDir: string, slug: string, includeArchived?: boolean): PersonReadResult;
22
+ export declare function listPeople(contextDir: string, includeArchived?: boolean): PersonListEntry[];
23
+ export declare function person(contextDir: string, input: {
24
+ slug: string;
25
+ include_archived?: boolean;
26
+ }): Promise<string>;
27
+ export declare function personList(contextDir: string, input: {
28
+ include_archived?: boolean;
29
+ }): Promise<string>;
30
+ export declare function personHistory(contextDir: string, input: {
31
+ slug: string;
32
+ }): Promise<string>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Person-card tools — person_write / person_verify / person_archive /
3
+ * person_restore / person / person_list / person_history /
4
+ * person_revision_restore (docs/specs/person-cards.md).
5
+ *
6
+ * Thin wrappers over backends/people.ts: each opens the store on the memory
7
+ * database, runs one call, closes. Results are JSON text, the shape the spec
8
+ * names; a rejection is `Error: <message naming the path>`, like every other
9
+ * loom tool. The CLI (`loom person`) calls the read functions here too.
10
+ */
11
+ import { resolveIdentityName } from '../config.js';
12
+ import { openPeopleStore, personSlugError, PersonCardError, UnreadableCardError, } from '../backends/people.js';
13
+ const json = (v) => JSON.stringify(v, null, 2);
14
+ /** Slugs validate before anything else, store or no store (criterion 11). */
15
+ function assertSlug(slug) {
16
+ const err = personSlugError(slug);
17
+ if (err)
18
+ throw new PersonCardError(`slug: ${err}`);
19
+ }
20
+ function errorText(e) {
21
+ if (e instanceof PersonCardError || e instanceof UnreadableCardError)
22
+ return `Error: ${e.message}`;
23
+ return `Error: ${e.message ?? String(e)}`;
24
+ }
25
+ function withWritable(contextDir, fn) {
26
+ try {
27
+ const store = openPeopleStore(contextDir);
28
+ try {
29
+ return json(fn(store));
30
+ }
31
+ finally {
32
+ store.close();
33
+ }
34
+ }
35
+ catch (e) {
36
+ return errorText(e);
37
+ }
38
+ }
39
+ export async function personWrite(contextDir, input) {
40
+ return withWritable(contextDir, (s) => s.write(input));
41
+ }
42
+ export async function personVerify(contextDir, input) {
43
+ return withWritable(contextDir, (s) => s.verify(input));
44
+ }
45
+ export async function personArchive(contextDir, input) {
46
+ // The actor is the calling agent's name — the value knowledge_write uses for author.
47
+ return withWritable(contextDir, (s) => s.archive({ ...input, actor: resolveIdentityName(contextDir) }));
48
+ }
49
+ export async function personRestore(contextDir, input) {
50
+ return withWritable(contextDir, (s) => s.restore(input));
51
+ }
52
+ export async function personRevisionRestore(contextDir, input) {
53
+ return withWritable(contextDir, (s) => s.revisionRestore(input));
54
+ }
55
+ /** The `person` read. Throws on an invalid slug or an unreadable row; the MCP wrapper turns that into text. */
56
+ export function readPerson(contextDir, slug, includeArchived = false) {
57
+ assertSlug(slug);
58
+ const store = openPeopleStore(contextDir, { readonly: true });
59
+ if (!store)
60
+ return { found: false };
61
+ try {
62
+ return store.read(slug, includeArchived);
63
+ }
64
+ finally {
65
+ store.close();
66
+ }
67
+ }
68
+ export function listPeople(contextDir, includeArchived = false) {
69
+ const store = openPeopleStore(contextDir, { readonly: true });
70
+ if (!store)
71
+ return [];
72
+ try {
73
+ return store.list(includeArchived);
74
+ }
75
+ finally {
76
+ store.close();
77
+ }
78
+ }
79
+ export async function person(contextDir, input) {
80
+ try {
81
+ return json(readPerson(contextDir, input.slug, input.include_archived ?? false));
82
+ }
83
+ catch (e) {
84
+ return errorText(e);
85
+ }
86
+ }
87
+ export async function personList(contextDir, input) {
88
+ try {
89
+ return json(listPeople(contextDir, input.include_archived ?? false));
90
+ }
91
+ catch (e) {
92
+ return errorText(e);
93
+ }
94
+ }
95
+ export async function personHistory(contextDir, input) {
96
+ try {
97
+ assertSlug(input.slug);
98
+ const store = openPeopleStore(contextDir, { readonly: true });
99
+ if (!store)
100
+ return json([]);
101
+ try {
102
+ return json(store.history(input.slug));
103
+ }
104
+ finally {
105
+ store.close();
106
+ }
107
+ }
108
+ catch (e) {
109
+ return errorText(e);
110
+ }
111
+ }
@@ -9,7 +9,7 @@ export async function prune(contextDir, options) {
9
9
  const result = await backend.prune(options);
10
10
  const lines = [];
11
11
  if (result.expired.length > 0) {
12
- const verb = options?.dryRun ? 'Would expire' : 'Expired';
12
+ const verb = options?.dryRun ? 'Would archive' : 'Archived';
13
13
  lines.push(`**${verb} ${result.expired.length} memories (TTL elapsed):**`);
14
14
  for (const ref of result.expired) {
15
15
  lines.push(`- ${ref}`);
@@ -8,7 +8,13 @@
8
8
  import { createBackend } from '../backends/index.js';
9
9
  export function formatMatchResult(m) {
10
10
  const projectTag = m.project ? ` [${m.project}]` : '';
11
- return `## ${m.title}\n*${m.category}${projectTag} — ${m.created.slice(0, 10)}*\n\n${m.content}`;
11
+ // Show sourcing when set — 'inferred' is the critical signal (AI-generated,
12
+ // not ground truth). Provenance follows in parens when present.
13
+ const sourcingTag = m.sourcing ? ` · ${m.sourcing}` : '';
14
+ const provenanceTag = m.provenance ? ` (${m.provenance})` : '';
15
+ // Decayed confidence, feedback memories only — undefined elsewhere (t-338).
16
+ const confidenceTag = m.confidence !== undefined ? ` · confidence ${m.confidence.toFixed(2)} (${m.evidenceCount} obs)` : '';
17
+ return `## ${m.title}\n*${m.category}${projectTag} — ${m.created.slice(0, 10)}${sourcingTag}${provenanceTag}${confidenceTag}*\n\n${m.content}`;
12
18
  }
13
19
  export function formatResults(matches) {
14
20
  const results = matches.map(formatMatchResult);
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { createBackend } from '../backends/index.js';
9
9
  import { EPISODE_CATEGORY, EPISODE_DEFAULT_TTL } from '../categories.js';
10
+ import { recordSuccess, recordFailure } from '../backends/recorder-health.js';
10
11
  /**
11
12
  * Lint-on-write (c-loom-strictness §lint): validate the record BEFORE it reaches
12
13
  * the backend, so a malformed write is refused with a typed reason and the store
@@ -24,6 +25,9 @@ export function validateMemoryInput(input) {
24
25
  if (typeof input.category !== 'string' || input.category.trim() === '') {
25
26
  return 'memory category is required and cannot be empty';
26
27
  }
28
+ if (input.scope !== undefined && input.scope !== 'project' && input.scope !== 'global') {
29
+ return 'scope must be "project" or "global"';
30
+ }
27
31
  return null;
28
32
  }
29
33
  export async function remember(contextDir, input) {
@@ -36,8 +40,20 @@ export async function remember(contextDir, input) {
36
40
  const backend = createBackend(contextDir);
37
41
  // An episode is short-term by definition: without an explicit ttl it gets the
38
42
  // tier default, so a body can never accidentally leave a permanent episode.
39
- if (input.category === EPISODE_CATEGORY && !input.ttl) {
43
+ const isEpisode = input.category === EPISODE_CATEGORY;
44
+ if (isEpisode && !input.ttl) {
40
45
  input = { ...input, ttl: EPISODE_DEFAULT_TTL };
41
46
  }
42
- return backend.remember(input);
47
+ try {
48
+ const ref = await backend.remember(input);
49
+ // Record health only for episode writes — that's the path identity.ts monitors.
50
+ if (isEpisode)
51
+ recordSuccess(contextDir);
52
+ return ref;
53
+ }
54
+ catch (err) {
55
+ if (isEpisode)
56
+ recordFailure(contextDir, err);
57
+ throw err;
58
+ }
43
59
  }
@@ -12,5 +12,9 @@ export async function update(contextDir, input) {
12
12
  const identifier = input.ref ?? `${input.category}/${input.title}`;
13
13
  return `Memory not found: "${identifier}". Use recall to find the correct reference.`;
14
14
  }
15
- return `Memory updated: ${result.ref}`;
15
+ const snap = result.snapshotId !== undefined ? ` (snapshot #${result.snapshotId})` : '';
16
+ const conf = result.confidence !== undefined
17
+ ? ` — confidence now ${result.confidence.toFixed(2)} (${result.evidenceCount} observations)`
18
+ : '';
19
+ return `Memory updated: ${result.ref}${snap}${conf}`;
16
20
  }
@@ -0,0 +1,13 @@
1
+ import { type CliRequest, type CliResponse } from '../cli/remote.js';
2
+ export type CliOutcome = {
3
+ ok: true;
4
+ response: CliResponse;
5
+ } | {
6
+ ok: false;
7
+ error: string;
8
+ };
9
+ /** Validate a POST /cli body; returns the request or why it's refused. */
10
+ export declare function parseCliRequest(body: unknown): CliRequest | string;
11
+ /** Run a validated request's argv against `contextDir`. */
12
+ export declare function runCliRequest(contextDir: string, request: CliRequest): Promise<CliResponse>;
13
+ export declare function handleCliBody(contextDir: string, body: unknown): Promise<CliOutcome>;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * POST /cli — the server half of the CLI's remote mode (src/cli/remote.ts,
3
+ * t-1472).
4
+ *
5
+ * Runs the real CLI in-process (runCliLocal: remote mode isn't on its path)
6
+ * against THIS server's stack, with captured
7
+ * streams, and returns what it printed and its exit code. It sits behind the
8
+ * same bearer gate and payload cap as /mcp, and grants nothing /mcp doesn't:
9
+ *
10
+ * - only REMOTE_VERBS (the store verbs, all of which have MCP tools),
11
+ * minus the memory subcommands that have none
12
+ * - never --context-dir or --url: the stack is the server's, full stop
13
+ * - a fixed env: the server's HOME and stack, plus the caller's
14
+ * LOOM_CLIENT / LOOM_MODEL hints and nothing else
15
+ * - stdin is never a TTY here, so no verb can reach $EDITOR server-side
16
+ */
17
+ import { Readable } from 'node:stream';
18
+ import { runCliLocal } from '../cli/index.js';
19
+ import { extractGlobalFlags, firstNonGlobalIndex } from '../cli/args.js';
20
+ import { LOCAL_MEMORY_SUBCOMMANDS, REMOTE_VERBS } from '../cli/remote.js';
21
+ const MAX_ARGV = 256;
22
+ function isStringRecord(v) {
23
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
24
+ }
25
+ /** Validate a POST /cli body; returns the request or why it's refused. */
26
+ export function parseCliRequest(body) {
27
+ if (!isStringRecord(body))
28
+ return 'bad-input: expected a JSON object';
29
+ const { argv, stdin, env } = body;
30
+ if (!Array.isArray(argv) || argv.length === 0 || argv.length > MAX_ARGV || !argv.every((a) => typeof a === 'string')) {
31
+ return `bad-input: argv must be 1..${MAX_ARGV} strings`;
32
+ }
33
+ if (stdin !== undefined && typeof stdin !== 'string')
34
+ return 'bad-input: stdin must be a string';
35
+ let hints;
36
+ if (env !== undefined) {
37
+ if (!isStringRecord(env))
38
+ return 'bad-input: env must be an object';
39
+ hints = {};
40
+ for (const [k, v] of Object.entries(env)) {
41
+ if (k !== 'LOOM_CLIENT' && k !== 'LOOM_MODEL')
42
+ return `refused: env may only carry LOOM_CLIENT and LOOM_MODEL, not ${k}`;
43
+ if (typeof v !== 'string')
44
+ return `bad-input: env.${k} must be a string`;
45
+ hints[k] = v;
46
+ }
47
+ }
48
+ const args = argv;
49
+ if (args.some((a) => a === '--context-dir' || a.startsWith('--context-dir=') || a === '--url' || a.startsWith('--url='))) {
50
+ return 'refused: --context-dir and --url name a stack; this server only serves its own';
51
+ }
52
+ const verbIdx = firstNonGlobalIndex(args);
53
+ const verb = args[verbIdx];
54
+ if (verb === undefined || !REMOTE_VERBS.has(verb)) {
55
+ return `refused: ${verb ?? '(no verb)'} doesn't run remotely; remote verbs are ${[...REMOTE_VERBS].join(', ')}`;
56
+ }
57
+ if (verb === 'memory') {
58
+ // memory.ts dispatches on its first token after the globals are pulled out.
59
+ const after = [...args.slice(0, verbIdx), ...args.slice(verbIdx + 1)];
60
+ const sub = extractGlobalFlags(after).rest[0];
61
+ if (sub !== undefined && LOCAL_MEMORY_SUBCOMMANDS.has(sub)) {
62
+ return `refused: memory ${sub} has no MCP tool, so it doesn't run remotely`;
63
+ }
64
+ }
65
+ return { argv: args, stdin: stdin, env: hints };
66
+ }
67
+ /** Run a validated request's argv against `contextDir`. */
68
+ export async function runCliRequest(contextDir, request) {
69
+ let stdout = '';
70
+ let stderr = '';
71
+ const env = { LOOM_CONTEXT_DIR: contextDir, ...request.env };
72
+ if (process.env.HOME)
73
+ env.HOME = process.env.HOME;
74
+ const io = {
75
+ stdout: (s) => { stdout += s; },
76
+ stderr: (s) => { stderr += s; },
77
+ stdin: Readable.from([request.stdin ?? '']),
78
+ stdinIsTTY: false,
79
+ env,
80
+ };
81
+ let exitCode;
82
+ try {
83
+ exitCode = await runCliLocal(request.argv, io);
84
+ }
85
+ catch (err) {
86
+ // The local CLI would crash with a stack trace; a remote one gets the message and exit 1.
87
+ stderr += `${err.message}\n`;
88
+ exitCode = 1;
89
+ }
90
+ return { exitCode, stdout, stderr };
91
+ }
92
+ export async function handleCliBody(contextDir, body) {
93
+ const request = parseCliRequest(body);
94
+ if (typeof request === 'string')
95
+ return { ok: false, error: request };
96
+ return { ok: true, response: await runCliRequest(contextDir, request) };
97
+ }
@@ -31,5 +31,19 @@ export declare function assertSafeBind(host: string): void;
31
31
  * raw Authorization header value (e.g. "Bearer abc"); the scheme is tolerated.
32
32
  */
33
33
  export declare function checkBearer(configured: string | undefined, presented: string | undefined): GuardResult;
34
+ /**
35
+ * Multi-token auth gate (t-677): each configured token maps to one identity,
36
+ * so a single scoped endpoint (the knowledge-only service) can serve several
37
+ * identities while still deriving WHICH one made a given call — the bearer
38
+ * itself is the identity claim, there is no separate login step. Same
39
+ * refusal shape as checkBearer; `identity` is only set on success.
40
+ *
41
+ * Every candidate token is compared with the same timing-safe routine as
42
+ * checkBearer, so this doesn't reintroduce a timing side-channel by doing a
43
+ * plain map lookup instead.
44
+ */
45
+ export declare function checkBearerMulti(configured: Record<string, string> | undefined, presented: string | undefined): GuardResult & {
46
+ identity?: string;
47
+ };
34
48
  /** Oversized guard: refuse a body beyond the cap before the handler runs. */
35
49
  export declare function checkPayloadSize(bytes: number, cap?: number): GuardResult;
@@ -98,6 +98,32 @@ export function checkBearer(configured, presented) {
98
98
  }
99
99
  return { ok: true };
100
100
  }
101
+ /**
102
+ * Multi-token auth gate (t-677): each configured token maps to one identity,
103
+ * so a single scoped endpoint (the knowledge-only service) can serve several
104
+ * identities while still deriving WHICH one made a given call — the bearer
105
+ * itself is the identity claim, there is no separate login step. Same
106
+ * refusal shape as checkBearer; `identity` is only set on success.
107
+ *
108
+ * Every candidate token is compared with the same timing-safe routine as
109
+ * checkBearer, so this doesn't reintroduce a timing side-channel by doing a
110
+ * plain map lookup instead.
111
+ */
112
+ export function checkBearerMulti(configured, presented) {
113
+ const entries = configured ? Object.entries(configured) : [];
114
+ if (entries.length === 0) {
115
+ return { ok: false, error: 'unauthorized: no identities configured for this endpoint' };
116
+ }
117
+ const token = (presented ?? '').replace(/^Bearer\s+/i, '').trim();
118
+ if (token === '')
119
+ return { ok: false, error: 'unauthorized: missing bearer token' };
120
+ for (const [identity, candidate] of entries) {
121
+ if (timingSafeEqual(token, candidate)) {
122
+ return { ok: true, identity };
123
+ }
124
+ }
125
+ return { ok: false, error: 'unauthorized: bearer token mismatch' };
126
+ }
101
127
  /** Oversized guard: refuse a body beyond the cap before the handler runs. */
102
128
  export function checkPayloadSize(bytes, cap = DEFAULT_MAX_BODY_BYTES) {
103
129
  if (bytes > cap) {
@@ -1,12 +1,39 @@
1
+ import type { McpServer } from '@modelcontextprotocol/server';
1
2
  export interface HttpServeOptions {
2
3
  contextDir: string;
3
4
  host: string;
4
5
  port: number;
5
- /** When set, every request must present this bearer token. */
6
+ /** Single-token mode: every request must present this bearer token. */
6
7
  token?: string;
8
+ /**
9
+ * Multi-token mode (t-677): token → identity. Mutually exclusive with
10
+ * `token` — when set, `checkBearerMulti` resolves the caller's identity
11
+ * from whichever configured token it presents instead of a single shared
12
+ * secret. Required for `createServer` factories that need an identity
13
+ * (e.g. the knowledge-only service); refuses every call if empty.
14
+ */
15
+ tokens?: Record<string, string>;
7
16
  maxBytes?: number;
8
17
  /** SSE keep-alive ping interval (ms). Default HEARTBEAT_MS; tests use a small value. */
9
18
  heartbeatMs?: number;
19
+ /**
20
+ * Optional path to the knowledge-gaps directory. Forwarded to the server
21
+ * factory so zero-result knowledge_recall calls can log misses.
22
+ */
23
+ gapsDir?: string;
24
+ /**
25
+ * Server factory for this endpoint. Defaults to the full loom server
26
+ * (createLoomServer, identity 'art') — pass createKnowledgeOnlyServer to
27
+ * scope the endpoint to knowledge tools only. `identity` is resolved from
28
+ * `tokens` per connection; always 'art' in single-token/no-auth mode.
29
+ */
30
+ createServer?: (ctx: {
31
+ contextDir: string;
32
+ gapsDir?: string;
33
+ identity: string;
34
+ }) => {
35
+ server: McpServer;
36
+ };
10
37
  }
11
38
  export interface HttpServeHandle {
12
39
  /** The actually-bound port (resolves a 0 request to the OS-assigned port). */