@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
|
@@ -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
|
+
}
|
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>;
|
package/dist/cli/serve.js
CHANGED
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
* optional bearer token. Host/port/token come from flags or env:
|
|
5
5
|
* --http --host <h> --port <n> (LOOM_HTTP_HOST, LOOM_HTTP_PORT, LOOM_BEARER_TOKEN)
|
|
6
6
|
* Bind-safety refuses a public/0.0.0.0 host; Tailscale is the access control.
|
|
7
|
+
*
|
|
8
|
+
* `--scope knowledge` (t-677) starts the standalone knowledge-only service
|
|
9
|
+
* instead of the full server — same transport, tailscale-bind-safety, and
|
|
10
|
+
* bearer-gating pattern, but the MCP tool surface is knowledge_* only and
|
|
11
|
+
* auth is per-identity (LOOM_KNOWLEDGE_BEARER_TOKENS, "identity:token,...")
|
|
12
|
+
* so the acting identity is derivable server-side for attribution. It is
|
|
13
|
+
* NOT started by default and ships disabled — nothing in this repo enables
|
|
14
|
+
* or deploys it; standing it up is a separate, deliberate operational step.
|
|
7
15
|
*/
|
|
8
16
|
import { parseArgs } from 'node:util';
|
|
9
|
-
import { StdioServerTransport } from '@modelcontextprotocol/
|
|
17
|
+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
10
18
|
import { createLoomServer } from '../server.js';
|
|
19
|
+
import { createKnowledgeOnlyServer } from '../transport/knowledge-server.js';
|
|
11
20
|
import { startHttpServer } from '../transport/http-server.js';
|
|
12
21
|
import { resolveContextDir } from '../config.js';
|
|
13
22
|
const USAGE = `Usage: loom serve [options]
|
|
@@ -17,12 +26,49 @@ mesh-reachable HTTP daemon instead.
|
|
|
17
26
|
|
|
18
27
|
Options:
|
|
19
28
|
--http Serve over HTTP instead of stdio
|
|
29
|
+
--scope <full|knowledge> Tool surface for --http (default full);
|
|
30
|
+
knowledge without --http is rejected
|
|
20
31
|
--host <h> HTTP bind host (env LOOM_HTTP_HOST, default 127.0.0.1)
|
|
21
|
-
--port <n> HTTP port (env LOOM_HTTP_PORT,
|
|
32
|
+
--port <n> HTTP port (env LOOM_HTTP_PORT, or LOOM_KNOWLEDGE_HTTP_PORT
|
|
33
|
+
for --scope knowledge, default 8787 / 8788)
|
|
22
34
|
--help, -h Show this help
|
|
23
35
|
|
|
24
|
-
|
|
36
|
+
--scope full (default): bearer token from LOOM_BEARER_TOKEN.
|
|
37
|
+
--scope knowledge: per-identity bearer from LOOM_KNOWLEDGE_BEARER_TOKENS,
|
|
38
|
+
format "identity:token,identity:token" — e.g. "art:abc123,mark:def456".
|
|
39
|
+
Required and non-empty; the knowledge-only service refuses to boot without
|
|
40
|
+
at least one identity configured. Registers knowledge_* tools only — no
|
|
41
|
+
identity/memory/dossier tools on this surface.
|
|
42
|
+
|
|
43
|
+
Bind-safety refuses a public host in both scopes.
|
|
25
44
|
`;
|
|
45
|
+
/**
|
|
46
|
+
* Parse "identity:token,identity:token" into a token → identity map ready
|
|
47
|
+
* for startHttpServer's `tokens` option (checkBearerMulti). Throws with a
|
|
48
|
+
* message naming the offending pair on malformed input — fail loud at CLI
|
|
49
|
+
* startup rather than silently dropping an identity.
|
|
50
|
+
*/
|
|
51
|
+
export function parseIdentityTokens(raw) {
|
|
52
|
+
const tokens = {};
|
|
53
|
+
const pairs = raw.split(',');
|
|
54
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
55
|
+
const trimmed = pairs[i].trim();
|
|
56
|
+
if (!trimmed)
|
|
57
|
+
continue;
|
|
58
|
+
const idx = trimmed.indexOf(':');
|
|
59
|
+
const identity = idx > 0 ? trimmed.slice(0, idx).trim() : '';
|
|
60
|
+
const token = idx > 0 ? trimmed.slice(idx + 1).trim() : '';
|
|
61
|
+
if (!identity || !token) {
|
|
62
|
+
// Never echo `trimmed` — a malformed pair is exactly the shape a
|
|
63
|
+
// pasted-without-colon secret takes, and this error can land in
|
|
64
|
+
// stderr/journal/CI logs. Report position only, never content.
|
|
65
|
+
throw new Error(`malformed identity:token pair at position ${i + 1} — expected "identity:token", ` +
|
|
66
|
+
`got ${identity ? 'an identity with no token' : 'no identity'} (value withheld from this error)`);
|
|
67
|
+
}
|
|
68
|
+
tokens[identity] = token;
|
|
69
|
+
}
|
|
70
|
+
return tokens;
|
|
71
|
+
}
|
|
26
72
|
export async function run(argv, io) {
|
|
27
73
|
let values;
|
|
28
74
|
try {
|
|
@@ -30,6 +76,7 @@ export async function run(argv, io) {
|
|
|
30
76
|
args: argv,
|
|
31
77
|
options: {
|
|
32
78
|
http: { type: 'boolean', default: false },
|
|
79
|
+
scope: { type: 'string', default: 'full' },
|
|
33
80
|
host: { type: 'string' },
|
|
34
81
|
port: { type: 'string' },
|
|
35
82
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -46,19 +93,61 @@ export async function run(argv, io) {
|
|
|
46
93
|
io.stdout(USAGE);
|
|
47
94
|
return 0;
|
|
48
95
|
}
|
|
96
|
+
if (values.scope !== 'full' && values.scope !== 'knowledge') {
|
|
97
|
+
io.stderr(`Unknown --scope "${values.scope}" — expected "full" or "knowledge".\n${USAGE}`);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
// The knowledge-only surface exists only as an HTTP service. Without
|
|
101
|
+
// --http the stdio branch below would open the FULL server — every
|
|
102
|
+
// identity/memory/dossier tool — under a flag that asked for less
|
|
103
|
+
// (PR #124 review). Refuse before any transport opens.
|
|
104
|
+
if (values.scope === 'knowledge' && !values.http) {
|
|
105
|
+
io.stderr(`--scope knowledge requires --http — the knowledge-only surface is HTTP-only; ` +
|
|
106
|
+
`refusing to start the full stdio server instead.\n${USAGE}`);
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
49
109
|
const contextDir = resolveContextDir();
|
|
110
|
+
// Optional: gaps directory for recall-miss logging (LOOM_GAPS_DIR env var).
|
|
111
|
+
// When set, knowledge_recall misses are appended to ${gapsDir}/recall-miss.jsonl
|
|
112
|
+
// so the knowledge-mine script can surface them as expansion candidates.
|
|
113
|
+
const gapsDir = process.env.LOOM_GAPS_DIR || undefined;
|
|
114
|
+
if (values.http && values.scope === 'knowledge') {
|
|
115
|
+
const host = values.host ?? process.env.LOOM_HTTP_HOST ?? '127.0.0.1';
|
|
116
|
+
const port = Number(values.port ?? process.env.LOOM_KNOWLEDGE_HTTP_PORT ?? 8788);
|
|
117
|
+
const rawTokens = process.env.LOOM_KNOWLEDGE_BEARER_TOKENS ?? '';
|
|
118
|
+
let tokens;
|
|
119
|
+
try {
|
|
120
|
+
tokens = parseIdentityTokens(rawTokens);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
io.stderr(`${err.message}\n${USAGE}`);
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
if (Object.keys(tokens).length === 0) {
|
|
127
|
+
io.stderr(`--scope knowledge requires LOOM_KNOWLEDGE_BEARER_TOKENS ` +
|
|
128
|
+
`("identity:token,identity:token") — refusing to start unauthenticated.\n${USAGE}`);
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
const handle = await startHttpServer({
|
|
132
|
+
contextDir, host, port, tokens, gapsDir, createServer: createKnowledgeOnlyServer,
|
|
133
|
+
});
|
|
134
|
+
io.stderr(`loom: knowledge-only HTTP MCP daemon on http://${handle.host}:${handle.port} ` +
|
|
135
|
+
`(per-identity bearer: ${Object.keys(tokens).join(', ')})\n`);
|
|
136
|
+
await new Promise(() => { });
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
50
139
|
if (values.http) {
|
|
51
140
|
const host = values.host ?? process.env.LOOM_HTTP_HOST ?? '127.0.0.1';
|
|
52
141
|
const port = Number(values.port ?? process.env.LOOM_HTTP_PORT ?? 8787);
|
|
53
142
|
const token = process.env.LOOM_BEARER_TOKEN || undefined;
|
|
54
|
-
const handle = await startHttpServer({ contextDir, host, port, token });
|
|
143
|
+
const handle = await startHttpServer({ contextDir, host, port, token, gapsDir });
|
|
55
144
|
io.stderr(`loom: HTTP MCP daemon on http://${handle.host}:${handle.port} ` +
|
|
56
145
|
`(${token ? 'bearer-gated' : 'open — network is the boundary'})\n`);
|
|
57
146
|
// Hold open until the process is signalled.
|
|
58
147
|
await new Promise(() => { });
|
|
59
148
|
return 0;
|
|
60
149
|
}
|
|
61
|
-
const { server } = createLoomServer({ contextDir });
|
|
150
|
+
const { server } = createLoomServer({ contextDir, gapsDir });
|
|
62
151
|
const transport = new StdioServerTransport();
|
|
63
152
|
await server.connect(transport);
|
|
64
153
|
// connect() resolves when the stdio transport STARTS, not when it closes.
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
* (src/cli/index.ts) and the CLI-vs-MCP routing gate (src/index.ts) so
|
|
4
4
|
* the set never drifts between entry points.
|
|
5
5
|
*/
|
|
6
|
-
export declare const SUBCOMMANDS: readonly ['wake', 'recall', 'remember', 'forget', 'update', 'memory', 'knowledge', 'update-identity', 'bootstrap', 'serve', 'inject', 'harness', 'install', 'doctor', 'migrate'];
|
|
6
|
+
export declare const SUBCOMMANDS: readonly ['wake', 'recall', 'remember', 'forget', 'update', 'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve', 'inject', 'harness', 'install', 'doctor', 'migrate'];
|
|
7
7
|
export type Subcommand = typeof SUBCOMMANDS[number];
|
package/dist/cli/subcommands.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export const SUBCOMMANDS = [
|
|
7
7
|
'wake', 'recall', 'remember', 'forget', 'update',
|
|
8
|
-
'memory', 'knowledge', 'update-identity', 'bootstrap', 'serve',
|
|
8
|
+
'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve',
|
|
9
9
|
'inject', 'harness',
|
|
10
10
|
'install', 'doctor', 'migrate',
|
|
11
11
|
];
|
package/dist/cli/update.js
CHANGED
|
@@ -11,11 +11,14 @@ const USAGE = `Usage: loom update <ref> [options]
|
|
|
11
11
|
loom update --category <cat> --title <exact> [options]
|
|
12
12
|
|
|
13
13
|
Updates content (from stdin or \$EDITOR) and/or metadata on an existing
|
|
14
|
-
memory.
|
|
14
|
+
memory. --confirm/--contradict record an observation against a feedback
|
|
15
|
+
memory's confidence instead — no body required for those.
|
|
15
16
|
|
|
16
17
|
Options:
|
|
17
18
|
--category <name> Identify by category (with --title)
|
|
18
19
|
--title <exact> Identify by title (with --category)
|
|
20
|
+
--confirm Feedback memories only: raise confidence (+0.05)
|
|
21
|
+
--contradict Feedback memories only: lower confidence (-0.1)
|
|
19
22
|
--json Emit UpdateResult
|
|
20
23
|
--context-dir <path> Agent context dir
|
|
21
24
|
--help, -h Show this help
|
|
@@ -29,6 +32,8 @@ export async function run(argv, io) {
|
|
|
29
32
|
options: {
|
|
30
33
|
category: { type: 'string' },
|
|
31
34
|
title: { type: 'string' },
|
|
35
|
+
confirm: { type: 'boolean' },
|
|
36
|
+
contradict: { type: 'boolean' },
|
|
32
37
|
help: { type: 'boolean', short: 'h' },
|
|
33
38
|
},
|
|
34
39
|
strict: true,
|
|
@@ -49,6 +54,15 @@ export async function run(argv, io) {
|
|
|
49
54
|
io.stderr(`Provide a <ref> or --category+--title.\n${USAGE}`);
|
|
50
55
|
return 2;
|
|
51
56
|
}
|
|
57
|
+
if (parsed.values.confirm && parsed.values.contradict) {
|
|
58
|
+
io.stderr(`--confirm and --contradict are mutually exclusive.\n${USAGE}`);
|
|
59
|
+
return 2;
|
|
60
|
+
}
|
|
61
|
+
const observation = parsed.values.confirm
|
|
62
|
+
? 'confirm'
|
|
63
|
+
: parsed.values.contradict
|
|
64
|
+
? 'contradict'
|
|
65
|
+
: undefined;
|
|
52
66
|
const env = resolveEnv(global, io.env);
|
|
53
67
|
try {
|
|
54
68
|
assertStackVersionCompatible(env.contextDir);
|
|
@@ -57,23 +71,28 @@ export async function run(argv, io) {
|
|
|
57
71
|
io.stderr(`${err.message}\n`);
|
|
58
72
|
return 1;
|
|
59
73
|
}
|
|
74
|
+
// A pure observation needs no new body — --confirm/--contradict alone is a
|
|
75
|
+
// complete update. Any other call still requires one (existing behavior).
|
|
60
76
|
let body;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
if (!observation) {
|
|
78
|
+
try {
|
|
79
|
+
body = await readBody(io, 'update');
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
io.stderr(`${err.message}\n`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
if (!body) {
|
|
86
|
+
io.stderr(`body cannot be empty\n`);
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
71
89
|
}
|
|
72
90
|
const input = {
|
|
73
91
|
ref,
|
|
74
92
|
category: parsed.values.category,
|
|
75
93
|
title: parsed.values.title,
|
|
76
94
|
content: body,
|
|
95
|
+
observation,
|
|
77
96
|
};
|
|
78
97
|
if (env.json) {
|
|
79
98
|
const backend = createBackend(env.contextDir);
|
package/dist/clients.js
CHANGED
|
@@ -14,17 +14,20 @@
|
|
|
14
14
|
import { readFile } from 'node:fs/promises';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { assertSafePathSegment } from './path-safety.js';
|
|
17
|
+
import { INSTALL_TARGETS } from './install/harnesses.js';
|
|
17
18
|
// ─── Tool list ────────────────────────────────────────────────────────────────
|
|
18
19
|
// Canonical tool names — used to generate the prefix-specific list per client
|
|
19
20
|
export const TOOLS = [
|
|
20
21
|
'identity', 'dossier', 'remember', 'recall', 'update', 'forget',
|
|
21
22
|
'memory_list', 'memory_prune', 'find_similar', 'memory_audit', 'episodes',
|
|
22
|
-
'memory_archive', 'memory_restore',
|
|
23
|
+
'memory_archive', 'memory_restore', 'memory_merge', 'memory_history', 'memory_revision_restore',
|
|
23
24
|
'memory_propose', 'memory_proposals', 'memory_ratify', 'memory_reject',
|
|
24
25
|
'update_identity', 'bootstrap', 'harness_init', 'harness_describe',
|
|
25
26
|
'knowledge_write', 'knowledge_recall', 'knowledge_maintain',
|
|
26
27
|
'knowledge_archive', 'knowledge_restore', 'knowledge_supersede', 'knowledge_move', 'knowledge_merge',
|
|
27
28
|
'knowledge_purge', 'knowledge_verify', 'knowledge_history',
|
|
29
|
+
'person_write', 'person_verify', 'person_archive', 'person_restore',
|
|
30
|
+
'person', 'person_list', 'person_history', 'person_revision_restore',
|
|
28
31
|
];
|
|
29
32
|
function toolList(prefix) {
|
|
30
33
|
return TOOLS.map((t) => `\`${prefix}${t}\``).join(', ');
|
|
@@ -34,11 +37,11 @@ const ADAPTERS = {
|
|
|
34
37
|
'claude-code': `## Runtime: Claude Code
|
|
35
38
|
|
|
36
39
|
You are running in Claude Code (Anthropic CLI). Loom tools use double-underscore prefix:
|
|
37
|
-
${toolList('
|
|
40
|
+
${toolList(INSTALL_TARGETS['claude-code'].toolPrefix)}`,
|
|
38
41
|
'gemini-cli': `## Runtime: Gemini CLI
|
|
39
42
|
|
|
40
|
-
You are running in Gemini CLI (Google). Loom tools use
|
|
41
|
-
${toolList('
|
|
43
|
+
You are running in Gemini CLI (Google). Loom tools use single-underscore prefix:
|
|
44
|
+
${toolList(INSTALL_TARGETS['gemini-cli'].toolPrefix)}`,
|
|
42
45
|
};
|
|
43
46
|
// ─── Loader ──────────────────────────────────────────────────────────────────
|
|
44
47
|
export function getBuiltInAdapter(client) {
|