@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.
- package/CHANGELOG.md +129 -1
- package/README.md +115 -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 +81 -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/pieces.d.ts +24 -0
- package/dist/cli/pieces.js +139 -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 +2 -2
- 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 +28 -8
- 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
|
@@ -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
|
+
`;
|
package/dist/cli/recall.js
CHANGED
|
@@ -13,7 +13,8 @@ Search memories semantically.
|
|
|
13
13
|
|
|
14
14
|
Options:
|
|
15
15
|
--category <name> Filter by category
|
|
16
|
-
--project <name>
|
|
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
|
package/dist/cli/remember.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/cli/serve.d.ts
CHANGED
|
@@ -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>;
|