@jbarket/loomai 0.5.0 → 0.7.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 (96) hide show
  1. package/CHANGELOG.md +129 -1
  2. package/README.md +115 -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 +81 -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/pieces.d.ts +24 -0
  36. package/dist/cli/pieces.js +139 -0
  37. package/dist/cli/recall.js +2 -1
  38. package/dist/cli/remember.js +10 -0
  39. package/dist/cli/remote.d.ts +36 -0
  40. package/dist/cli/remote.js +173 -0
  41. package/dist/cli/serve.d.ts +7 -0
  42. package/dist/cli/serve.js +94 -5
  43. package/dist/cli/subcommands.d.ts +1 -1
  44. package/dist/cli/subcommands.js +2 -2
  45. package/dist/cli/update.js +30 -11
  46. package/dist/clients.js +7 -4
  47. package/dist/config.d.ts +14 -0
  48. package/dist/config.js +25 -2
  49. package/dist/index.js +28 -8
  50. package/dist/injection/harnesses.js +9 -3
  51. package/dist/install/harnesses.js +7 -1
  52. package/dist/server.d.ts +8 -1
  53. package/dist/server.js +461 -374
  54. package/dist/tools/dossier.js +5 -1
  55. package/dist/tools/eurorack-import.js +3 -0
  56. package/dist/tools/identity.d.ts +2 -0
  57. package/dist/tools/identity.js +35 -2
  58. package/dist/tools/knowledge-history.d.ts +6 -1
  59. package/dist/tools/knowledge-history.js +26 -8
  60. package/dist/tools/knowledge-maintain.d.ts +5 -0
  61. package/dist/tools/knowledge-maintain.js +92 -5
  62. package/dist/tools/knowledge-recall.d.ts +7 -1
  63. package/dist/tools/knowledge-recall.js +53 -3
  64. package/dist/tools/knowledge-verify.d.ts +11 -1
  65. package/dist/tools/knowledge-verify.js +17 -4
  66. package/dist/tools/knowledge-write.d.ts +8 -1
  67. package/dist/tools/knowledge-write.js +27 -9
  68. package/dist/tools/memory-history.d.ts +5 -0
  69. package/dist/tools/memory-history.js +52 -0
  70. package/dist/tools/memory-list.js +2 -1
  71. package/dist/tools/memory-merge.d.ts +2 -0
  72. package/dist/tools/memory-merge.js +40 -0
  73. package/dist/tools/memory-revision-restore.d.ts +5 -0
  74. package/dist/tools/memory-revision-restore.js +33 -0
  75. package/dist/tools/person.d.ts +32 -0
  76. package/dist/tools/person.js +111 -0
  77. package/dist/tools/prune.js +1 -1
  78. package/dist/tools/recall.js +7 -1
  79. package/dist/tools/remember.js +18 -2
  80. package/dist/tools/update.js +5 -1
  81. package/dist/transport/cli-endpoint.d.ts +13 -0
  82. package/dist/transport/cli-endpoint.js +97 -0
  83. package/dist/transport/guards.d.ts +14 -0
  84. package/dist/transport/guards.js +26 -0
  85. package/dist/transport/http-server.d.ts +28 -1
  86. package/dist/transport/http-server.js +156 -18
  87. package/dist/transport/knowledge-server.d.ts +11 -0
  88. package/dist/transport/knowledge-server.js +30 -0
  89. package/dist/transport/knowledge-tools.d.ts +50 -0
  90. package/dist/transport/knowledge-tools.js +290 -0
  91. package/dist/transport/mcp-app-hello.d.ts +1 -1
  92. package/dist/widgets/display-modes.html +62 -56
  93. package/dist/widgets/hello.html +62 -56
  94. package/dist/widgets/proposals.html +62 -56
  95. package/dist/widgets/top-of-mind.html +60 -54
  96. package/package.json +7 -5
@@ -0,0 +1,116 @@
1
+ /**
2
+ * loom person — read person cards (docs/specs/person-cards.md).
3
+ *
4
+ * loom person show <slug> [--include-archived] [--json]
5
+ * loom person list [--include-archived] [--json]
6
+ *
7
+ * Read-only: cards are written through the MCP tools. Exit codes for show:
8
+ * 0 found, 3 no such card, 4 archived (without --include-archived), 1 error.
9
+ * A bridge can splice `show` output into a prompt and treat any non-zero
10
+ * exit as "no card".
11
+ */
12
+ import { parseArgs } from 'node:util';
13
+ import { readPerson, listPeople } from '../tools/person.js';
14
+ import { assertStackVersionCompatible } from '../config.js';
15
+ import { extractGlobalFlags, resolveEnv } from './args.js';
16
+ import { renderJson } from './io.js';
17
+ const USAGE = `Usage: loom person <subcommand> [options]
18
+
19
+ Subcommands:
20
+ show <slug> Print the rendered card (or the person result with --json)
21
+ list List cards: slug, name, freshness counts, memories since synthesis
22
+
23
+ Options:
24
+ --include-archived Show/list archived cards too
25
+ --json Machine-readable output
26
+
27
+ Exit codes (show): 0 found, 3 no such card, 4 archived, 1 error.
28
+ `;
29
+ export async function run(argv, io) {
30
+ const { flags: global, rest } = extractGlobalFlags(argv);
31
+ const sub = rest[0];
32
+ if (!sub || sub === '--help' || sub === '-h') {
33
+ io.stdout(USAGE);
34
+ return sub ? 0 : 2;
35
+ }
36
+ if (sub !== 'show' && sub !== 'list') {
37
+ io.stderr(`Unknown person subcommand: ${sub}\n${USAGE}`);
38
+ return 2;
39
+ }
40
+ let parsed;
41
+ try {
42
+ parsed = parseArgs({
43
+ args: rest.slice(1),
44
+ options: { 'include-archived': { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
45
+ allowPositionals: true,
46
+ });
47
+ }
48
+ catch (err) {
49
+ io.stderr(`${err.message}\n${USAGE}`);
50
+ return 2;
51
+ }
52
+ if (parsed.values.help) {
53
+ io.stdout(USAGE);
54
+ return 0;
55
+ }
56
+ const env = resolveEnv(global, io.env);
57
+ try {
58
+ assertStackVersionCompatible(env.contextDir);
59
+ }
60
+ catch (err) {
61
+ io.stderr(`${err.message}\n`);
62
+ return 1;
63
+ }
64
+ const includeArchived = Boolean(parsed.values['include-archived']);
65
+ if (sub === 'list') {
66
+ if (parsed.positionals.length) {
67
+ io.stderr(`person list takes no arguments.\n${USAGE}`);
68
+ return 2;
69
+ }
70
+ const entries = listPeople(env.contextDir, includeArchived);
71
+ if (env.json) {
72
+ renderJson(io, entries);
73
+ return 0;
74
+ }
75
+ io.stdout(entries.length === 0
76
+ ? 'no person cards\n'
77
+ : entries
78
+ .map((e) => `${e.slug}\t${e.name}${e.archived ? ' [archived]' : ''}\tupdated ${e.updated}\t` +
79
+ `synthesized ${e.synthesized_at ?? 'never'}\tstale ${e.stale}\tunverified ${e.unverified}\t` +
80
+ `memories since synthesis ${e.memories_since_synthesis}`)
81
+ .join('\n') + '\n');
82
+ return 0;
83
+ }
84
+ const slug = parsed.positionals[0];
85
+ if (!slug || parsed.positionals.length > 1) {
86
+ io.stderr(`person show takes one slug.\n${USAGE}`);
87
+ return 2;
88
+ }
89
+ let result;
90
+ try {
91
+ result = readPerson(env.contextDir, slug, includeArchived);
92
+ }
93
+ catch (err) {
94
+ io.stderr(`${err.message}\n`);
95
+ return 1;
96
+ }
97
+ if (!result.found) {
98
+ if (env.json)
99
+ renderJson(io, result);
100
+ else
101
+ io.stderr(`no card for ${slug}\n`);
102
+ return 3;
103
+ }
104
+ if (!('rendered' in result)) {
105
+ if (env.json)
106
+ renderJson(io, result);
107
+ else
108
+ io.stderr(`card for ${slug} is archived (pass --include-archived)\n`);
109
+ return 4;
110
+ }
111
+ if (env.json)
112
+ renderJson(io, result);
113
+ else
114
+ io.stdout(result.rendered + '\n');
115
+ return 0;
116
+ }
@@ -0,0 +1,24 @@
1
+ import type { IOStreams } from './io.js';
2
+ /** A piece name: lowercase, starts with a letter, no dots or slashes. */
3
+ export declare const PIECE_NAME: RegExp;
4
+ /** Whether `name` could be a piece (valid name, not a core subcommand). */
5
+ export declare function isPieceName(name: string | undefined): name is string;
6
+ /** The executable that answers `loom <name>`: the first `loom-<name>` on PATH. */
7
+ export declare function findPiece(name: string, env: NodeJS.ProcessEnv): string | undefined;
8
+ export interface InstalledPiece {
9
+ name: string;
10
+ path: string;
11
+ }
12
+ /** Every installed piece, first on PATH wins (the one `loom <name>` would run). */
13
+ export declare function listPieces(env: NodeJS.ProcessEnv): InstalledPiece[];
14
+ /**
15
+ * Run a piece with `args`, passing loom's env through unchanged. With the
16
+ * process's own streams the piece inherits the terminal (so it can prompt,
17
+ * e.g. for a secret's value); with captured streams (tests) its output is
18
+ * forwarded. Exit code: the piece's own, 128+signal if it was killed, 127 if
19
+ * it couldn't start.
20
+ */
21
+ export declare function runPiece(path: string, args: string[], io: IOStreams): Promise<number>;
22
+ /** `loom pieces`: list what's installed. */
23
+ export declare function runPiecesCommand(argv: string[], io: IOStreams): number;
24
+ export declare const PIECES_USAGE = "Usage: loom pieces [--json]\n\nList the installed pieces: every executable named loom-<name> on PATH, which\nanswers `loom <name> ...`. The first one on PATH wins; core commands can't be\nshadowed. See docs/extensions.md.\n";
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Pieces — `loom <piece> ...` runs the installed extension `loom-<piece>`
3
+ * (docs/extensions.md, t-1466).
4
+ *
5
+ * Git-style dispatch: an executable named `loom-tasks` on PATH answers
6
+ * `loom tasks list`. loom doesn't know what a piece does and never links its
7
+ * code: the piece is its own program, reached as its own process, so it can't
8
+ * reach into loom's stores and can be swapped for anything else that installs
9
+ * the same name and honors that piece's contract.
10
+ *
11
+ * Core subcommands always win (a `loom-recall` on PATH is never consulted),
12
+ * and a name must match PIECE_NAME before PATH is searched at all.
13
+ */
14
+ import { accessSync, constants, readdirSync, statSync } from 'node:fs';
15
+ import { delimiter, join } from 'node:path';
16
+ import { spawn } from 'node:child_process';
17
+ import { constants as osConstants } from 'node:os';
18
+ import { SUBCOMMANDS } from './subcommands.js';
19
+ /** A piece name: lowercase, starts with a letter, no dots or slashes. */
20
+ export const PIECE_NAME = /^[a-z][a-z0-9-]{0,39}$/;
21
+ const PREFIX = 'loom-';
22
+ const CORE = new Set(SUBCOMMANDS);
23
+ function isExecutableFile(path) {
24
+ try {
25
+ if (!statSync(path).isFile())
26
+ return false;
27
+ accessSync(path, constants.X_OK);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function pathDirs(env) {
35
+ return (env.PATH ?? '').split(delimiter).filter(Boolean);
36
+ }
37
+ /** Whether `name` could be a piece (valid name, not a core subcommand). */
38
+ export function isPieceName(name) {
39
+ return name !== undefined && PIECE_NAME.test(name) && !CORE.has(name);
40
+ }
41
+ /** The executable that answers `loom <name>`: the first `loom-<name>` on PATH. */
42
+ export function findPiece(name, env) {
43
+ if (!isPieceName(name))
44
+ return undefined;
45
+ for (const dir of pathDirs(env)) {
46
+ const candidate = join(dir, PREFIX + name);
47
+ if (isExecutableFile(candidate))
48
+ return candidate;
49
+ }
50
+ return undefined;
51
+ }
52
+ /** Every installed piece, first on PATH wins (the one `loom <name>` would run). */
53
+ export function listPieces(env) {
54
+ const seen = new Map();
55
+ for (const dir of pathDirs(env)) {
56
+ let entries;
57
+ try {
58
+ entries = readdirSync(dir);
59
+ }
60
+ catch {
61
+ continue;
62
+ }
63
+ for (const entry of entries.sort()) {
64
+ if (!entry.startsWith(PREFIX))
65
+ continue;
66
+ const name = entry.slice(PREFIX.length);
67
+ if (seen.has(name) || !isPieceName(name))
68
+ continue;
69
+ const path = join(dir, entry);
70
+ if (isExecutableFile(path))
71
+ seen.set(name, path);
72
+ }
73
+ }
74
+ return [...seen].map(([name, path]) => ({ name, path })).sort((a, b) => a.name.localeCompare(b.name));
75
+ }
76
+ /**
77
+ * Run a piece with `args`, passing loom's env through unchanged. With the
78
+ * process's own streams the piece inherits the terminal (so it can prompt,
79
+ * e.g. for a secret's value); with captured streams (tests) its output is
80
+ * forwarded. Exit code: the piece's own, 128+signal if it was killed, 127 if
81
+ * it couldn't start.
82
+ */
83
+ export async function runPiece(path, args, io) {
84
+ const inherit = io.stdin === process.stdin;
85
+ const child = spawn(path, args, {
86
+ env: io.env,
87
+ stdio: inherit ? 'inherit' : ['pipe', 'pipe', 'pipe'],
88
+ });
89
+ if (!inherit) {
90
+ child.stdout?.on('data', (d) => io.stdout(d.toString()));
91
+ child.stderr?.on('data', (d) => io.stderr(d.toString()));
92
+ if (child.stdin) {
93
+ if (io.stdinIsTTY)
94
+ child.stdin.end();
95
+ else
96
+ io.stdin.pipe(child.stdin);
97
+ }
98
+ }
99
+ return new Promise((resolve) => {
100
+ child.on('error', (err) => {
101
+ io.stderr(`loom: couldn't run ${path}: ${err.message}\n`);
102
+ resolve(127);
103
+ });
104
+ child.on('close', (code, signal) => {
105
+ if (signal)
106
+ resolve(128 + (osConstants.signals[signal] ?? 0));
107
+ else
108
+ resolve(code ?? 1);
109
+ });
110
+ });
111
+ }
112
+ /** `loom pieces`: list what's installed. */
113
+ export function runPiecesCommand(argv, io) {
114
+ const json = argv.includes('--json');
115
+ const rest = argv.filter((a) => a !== '--json');
116
+ if (rest.includes('--help') || rest.includes('-h')) {
117
+ io.stdout(PIECES_USAGE);
118
+ return 0;
119
+ }
120
+ if (rest.length) {
121
+ io.stderr(`loom pieces takes no arguments.\n${PIECES_USAGE}`);
122
+ return 2;
123
+ }
124
+ const pieces = listPieces(io.env);
125
+ if (json) {
126
+ io.stdout(JSON.stringify(pieces, null, 2) + '\n');
127
+ return 0;
128
+ }
129
+ io.stdout(pieces.length === 0
130
+ ? 'no pieces installed (a piece is an executable named loom-<name> on PATH)\n'
131
+ : pieces.map((p) => `${p.name}\t${p.path}`).join('\n') + '\n');
132
+ return 0;
133
+ }
134
+ export const PIECES_USAGE = `Usage: loom pieces [--json]
135
+
136
+ List the installed pieces: every executable named loom-<name> on PATH, which
137
+ answers \`loom <name> ...\`. The first one on PATH wins; core commands can't be
138
+ shadowed. See docs/extensions.md.
139
+ `;
@@ -13,7 +13,8 @@ Search memories semantically.
13
13
 
14
14
  Options:
15
15
  --category <name> Filter by category
16
- --project <name> Filter by project
16
+ --project <name> Scope to a project — excludes other projects' project-scoped
17
+ memories (t-337); global-scoped memories still come through
17
18
  --limit <n> Max results (default backend-specific)
18
19
  --diversity <f> MMR diversity 0..1 (default 0.3); 0 = pure relevance order
19
20
  --json Emit MemoryMatch[] as JSON
@@ -14,6 +14,9 @@ Body is read from stdin (when piped) or $EDITOR (when interactive).
14
14
  Options:
15
15
  --category <name> Category: user|project|self|feedback|reference|pursuit (default: reference)
16
16
  --project <name> Project tag
17
+ --scope <project|global> Override recall visibility (default: category default — see
18
+ backends/scope.ts; "global" for a fact that applies regardless
19
+ of which project it was learned on)
17
20
  --ttl <dur> TTL like "7d", "30d", or "permanent"
18
21
  --refs <csv> Comma-separated reference refs stored in metadata
19
22
  --meta <json> JSON object merged into metadata (e.g. '{"where":"voice"}')
@@ -30,6 +33,7 @@ export async function run(argv, io) {
30
33
  options: {
31
34
  category: { type: 'string' },
32
35
  project: { type: 'string' },
36
+ scope: { type: 'string' },
33
37
  ttl: { type: 'string' },
34
38
  refs: { type: 'string' },
35
39
  meta: { type: 'string' },
@@ -57,6 +61,11 @@ export async function run(argv, io) {
57
61
  io.stderr(`Unknown category "${category}". Valid categories: ${MEMORY_CATEGORIES.join(', ')}.\n`);
58
62
  return 2;
59
63
  }
64
+ const scope = parsed.values.scope;
65
+ if (scope !== undefined && scope !== 'project' && scope !== 'global') {
66
+ io.stderr(`--scope must be "project" or "global".\n`);
67
+ return 2;
68
+ }
60
69
  const env = resolveEnv(global, io.env);
61
70
  try {
62
71
  assertStackVersionCompatible(env.contextDir);
@@ -99,6 +108,7 @@ export async function run(argv, io) {
99
108
  title,
100
109
  content: body,
101
110
  project: parsed.values.project,
111
+ scope,
102
112
  ttl: parsed.values.ttl,
103
113
  metadata,
104
114
  });
@@ -0,0 +1,36 @@
1
+ import type { IOStreams } from './io.js';
2
+ export declare const REMOTE_VERBS: ReadonlySet<string>;
3
+ /**
4
+ * `memory` subcommands with no MCP equivalent: recompute-salience rewrites
5
+ * every row's salience, and recall-stats returns other callers' query text.
6
+ * The consolidation lane runs them where the stack is.
7
+ */
8
+ export declare const LOCAL_MEMORY_SUBCOMMANDS: ReadonlySet<string>;
9
+ /** POST /cli request body. */
10
+ export interface CliRequest {
11
+ argv: string[];
12
+ /** The body for verbs that read one (remember, update). */
13
+ stdin?: string;
14
+ /** Harness hints only; the server owns everything else in its env. */
15
+ env?: {
16
+ LOOM_CLIENT?: string;
17
+ LOOM_MODEL?: string;
18
+ };
19
+ }
20
+ /** POST /cli 200 response body. */
21
+ export interface CliResponse {
22
+ exitCode: number;
23
+ stdout: string;
24
+ stderr: string;
25
+ }
26
+ /**
27
+ * The /cli endpoint beside the MCP URL: http://h:8787/mcp → http://h:8787/cli,
28
+ * and a proxy prefix is kept (https://h/loom/mcp → https://h/loom/cli). A URL
29
+ * without /mcp is taken as the server's base (http://h:8787 → /cli).
30
+ */
31
+ export declare function cliEndpoint(url: string): URL;
32
+ /**
33
+ * Run `sub` with `rest` (the argv minus the subcommand token, globals
34
+ * included) on the loom-http at `url`.
35
+ */
36
+ export declare function runRemote(sub: string, rest: string[], url: string, io: IOStreams): Promise<number>;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Remote mode — run a CLI verb against a loom-http server instead of a
3
+ * local stack (t-1472).
4
+ *
5
+ * A body whose loom lives elsewhere (a container, another host) still has
6
+ * hooks and scripts that shell out to `loom recall`, `loom remember --json`,
7
+ * `loom person show`. With `--url` or LOOM_URL set, those verbs ship their
8
+ * argv (and stdin body) to loom-http's POST /cli, which runs the SAME CLI
9
+ * server-side against its own stack and returns stdout, stderr and the exit
10
+ * code. Output, --json shapes and exit codes match the local CLI by
11
+ * construction, not by a second implementation that could drift.
12
+ *
13
+ * The server's half is src/transport/cli-endpoint.ts. Only REMOTE_VERBS
14
+ * travel: the ones that read or write the stores, every one of which the
15
+ * MCP tools already expose under the same bearer token, so /cli grants
16
+ * nothing /mcp doesn't. Two `memory` subcommands have no MCP tool and stay
17
+ * local too (LOCAL_MEMORY_SUBCOMMANDS). Verbs about the local machine (inject, install,
18
+ * doctor, serve, bootstrap, migrate, harness, update-identity) always run
19
+ * locally.
20
+ *
21
+ * The bearer token comes from a file (LOOM_TOKEN_FILE), never argv, so it
22
+ * stays out of `ps` and shell history.
23
+ */
24
+ import { readFile } from 'node:fs/promises';
25
+ import { readBody } from './io.js';
26
+ export const REMOTE_VERBS = new Set([
27
+ 'wake', 'recall', 'remember', 'update', 'forget', 'memory', 'knowledge', 'person',
28
+ ]);
29
+ /**
30
+ * `memory` subcommands with no MCP equivalent: recompute-salience rewrites
31
+ * every row's salience, and recall-stats returns other callers' query text.
32
+ * The consolidation lane runs them where the stack is.
33
+ */
34
+ export const LOCAL_MEMORY_SUBCOMMANDS = new Set(['recompute-salience', 'recall-stats']);
35
+ const REMOTE_TIMEOUT_MS = 300_000;
36
+ /** `argv` with every `--url <value>` pair removed. */
37
+ function withoutUrl(argv) {
38
+ const out = [];
39
+ for (let i = 0; i < argv.length; i++) {
40
+ if (argv[i] === '--url') {
41
+ i++;
42
+ continue;
43
+ }
44
+ out.push(argv[i]);
45
+ }
46
+ return out;
47
+ }
48
+ /**
49
+ * Whether the local CLI would read a body for this call. Mirrors the verbs'
50
+ * own rules (remember always; update unless it's a pure --confirm or
51
+ * --contradict observation; neither under --help). Reading stdin for any other verb would hang a caller whose stdin
52
+ * is an open pipe it never writes to.
53
+ */
54
+ function readsBody(sub, argv) {
55
+ if (argv.includes('--help') || argv.includes('-h'))
56
+ return false;
57
+ if (sub === 'remember')
58
+ return true;
59
+ if (sub === 'update')
60
+ return !argv.some((a) => /^--(confirm|contradict)(=|$)/.test(a));
61
+ return false;
62
+ }
63
+ /**
64
+ * The /cli endpoint beside the MCP URL: http://h:8787/mcp → http://h:8787/cli,
65
+ * and a proxy prefix is kept (https://h/loom/mcp → https://h/loom/cli). A URL
66
+ * without /mcp is taken as the server's base (http://h:8787 → /cli).
67
+ */
68
+ export function cliEndpoint(url) {
69
+ const u = new URL(url);
70
+ const base = u.pathname.replace(/\/+$/, '').replace(/\/mcp$/, '');
71
+ u.pathname = `${base}/cli`;
72
+ u.search = '';
73
+ u.hash = '';
74
+ return u;
75
+ }
76
+ function isCliResponse(v) {
77
+ const r = v;
78
+ return typeof r === 'object' && r !== null &&
79
+ Number.isInteger(r.exitCode) && typeof r.stdout === 'string' && typeof r.stderr === 'string';
80
+ }
81
+ /**
82
+ * Run `sub` with `rest` (the argv minus the subcommand token, globals
83
+ * included) on the loom-http at `url`.
84
+ */
85
+ export async function runRemote(sub, rest, url, io) {
86
+ const args = withoutUrl(rest);
87
+ if (args.some((a) => a === '--context-dir' || a.startsWith('--context-dir='))) {
88
+ io.stderr(`loom: --context-dir names a local stack, but this call goes to ${url}. ` +
89
+ `Drop --url / unset LOOM_URL to use the local one.\n`);
90
+ return 2;
91
+ }
92
+ let endpoint;
93
+ try {
94
+ endpoint = cliEndpoint(url);
95
+ }
96
+ catch {
97
+ io.stderr(`loom: not a URL: ${url}\n`);
98
+ return 2;
99
+ }
100
+ const headers = { 'content-type': 'application/json' };
101
+ const tokenFile = io.env.LOOM_TOKEN_FILE;
102
+ if (tokenFile) {
103
+ let token;
104
+ try {
105
+ token = (await readFile(tokenFile, 'utf-8')).trim();
106
+ }
107
+ catch (err) {
108
+ io.stderr(`loom: can't read LOOM_TOKEN_FILE: ${err.message}\n`);
109
+ return 1;
110
+ }
111
+ headers.authorization = `Bearer ${token}`;
112
+ }
113
+ const request = { argv: [sub, ...args] };
114
+ if (readsBody(sub, args)) {
115
+ // Local stdin or the local $EDITOR: an editor can't run over the wire.
116
+ try {
117
+ request.stdin = await readBody(io, sub);
118
+ }
119
+ catch (err) {
120
+ io.stderr(`${err.message}\n`);
121
+ return 1;
122
+ }
123
+ }
124
+ const hints = {};
125
+ if (io.env.LOOM_CLIENT)
126
+ hints.LOOM_CLIENT = io.env.LOOM_CLIENT;
127
+ if (io.env.LOOM_MODEL)
128
+ hints.LOOM_MODEL = io.env.LOOM_MODEL;
129
+ if (Object.keys(hints).length)
130
+ request.env = hints;
131
+ let res;
132
+ try {
133
+ res = await fetch(endpoint, {
134
+ method: 'POST',
135
+ headers,
136
+ body: JSON.stringify(request),
137
+ signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS),
138
+ });
139
+ }
140
+ catch (err) {
141
+ const cause = err.cause?.message;
142
+ io.stderr(`loom: can't reach ${endpoint}: ${cause ?? err.message}\n`);
143
+ return 1;
144
+ }
145
+ const text = await res.text();
146
+ if (res.status !== 200) {
147
+ let message = text;
148
+ try {
149
+ message = JSON.parse(text).error ?? text;
150
+ }
151
+ catch { /* not JSON */ }
152
+ if (res.status === 404)
153
+ message = `no CLI endpoint at ${endpoint} (an older loom-http, or a knowledge-only server)`;
154
+ io.stderr(`loom: ${endpoint} answered ${res.status}: ${message}\n`);
155
+ return res.status === 400 ? 2 : 1;
156
+ }
157
+ let out;
158
+ try {
159
+ out = JSON.parse(text);
160
+ }
161
+ catch {
162
+ io.stderr(`loom: ${endpoint} sent a response that isn't JSON\n`);
163
+ return 1;
164
+ }
165
+ // A malformed answer must never read as success (a missing exitCode would exit 0).
166
+ if (!isCliResponse(out)) {
167
+ io.stderr(`loom: ${endpoint} sent a malformed CLI response\n`);
168
+ return 1;
169
+ }
170
+ io.stdout(out.stdout);
171
+ io.stderr(out.stderr);
172
+ return out.exitCode;
173
+ }
@@ -1,2 +1,9 @@
1
1
  import type { IOStreams } from './io.js';
2
+ /**
3
+ * Parse "identity:token,identity:token" into a token → identity map ready
4
+ * for startHttpServer's `tokens` option (checkBearerMulti). Throws with a
5
+ * message naming the offending pair on malformed input — fail loud at CLI
6
+ * startup rather than silently dropping an identity.
7
+ */
8
+ export declare function parseIdentityTokens(raw: string): Record<string, string>;
2
9
  export declare function run(argv: string[], io: IOStreams): Promise<number>;