@jbarket/loomai 0.6.0 → 0.8.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 +36 -1
- package/README.md +38 -0
- package/dist/cli/index.js +24 -0
- package/dist/cli/pieces.d.ts +24 -0
- package/dist/cli/pieces.js +139 -0
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- package/dist/index.js +6 -1
- package/dist/transport/http-server.js +14 -0
- package/dist/ui/core.d.ts +9 -0
- package/dist/ui/core.js +140 -0
- package/dist/ui/handler.d.ts +11 -0
- package/dist/ui/handler.js +159 -0
- package/dist/ui/piece-sections.d.ts +20 -0
- package/dist/ui/piece-sections.js +112 -0
- package/dist/ui/render.d.ts +16 -0
- package/dist/ui/render.js +70 -0
- package/dist/ui/sections.d.ts +51 -0
- package/dist/ui/sections.js +121 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.8.0] - 2026-10-24
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **loom's page: `/ui` on the HTTP daemon.** A read-only web page with loom's
|
|
20
|
+
own sections: top of mind, the last 24h of episodes, proposals waiting, and
|
|
21
|
+
people. It also gets a group for each installed piece that answers
|
|
22
|
+
`loom-<piece> ui --json` in a small section vocabulary (stats, list, table,
|
|
23
|
+
text, link). Pieces send data, never markup: everything is escaped, the page
|
|
24
|
+
has no script, and a CSP forbids script and framing. Invalid answers are
|
|
25
|
+
dropped whole, and a slow or missing piece is left out.
|
|
26
|
+
- **Login:** the bearer token, once. The page requires `LOOM_BEARER_TOKEN` (no
|
|
27
|
+
token gives 403, like `/cli`). The cookie is keyed per daemon process, isn't
|
|
28
|
+
the token, and lasts 30 days or until a restart. Pieces never see loom's
|
|
29
|
+
tokens; on a timeout their whole process group is killed; concurrent loads
|
|
30
|
+
share one run per piece.
|
|
31
|
+
- **`/ui/sections.json`:** the same data, for scripts.
|
|
32
|
+
- **Scope:** full scope only.
|
|
33
|
+
|
|
34
|
+
## [0.7.0] - 2026-10-24
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **Pieces: `loom <piece>` dispatches to `loom-<piece>` on PATH, and `loom
|
|
39
|
+
pieces` lists them.** loom's command namespace now reaches separate,
|
|
40
|
+
swappable extensions (tasks, secrets, a wake clock, …) the way `git` reaches
|
|
41
|
+
`git-<cmd>`. loom never links them, and they never write loom's stores. Core
|
|
42
|
+
commands always win, piece names are validated before PATH is searched,
|
|
43
|
+
loom's env passes through unchanged, and the piece's exit code comes back.
|
|
44
|
+
The tiers, the rules (alone, composes without requiring, a contract per
|
|
45
|
+
role, no secrets in argv) and how pieces meet through named seams are in
|
|
46
|
+
`docs/extensions.md`.
|
|
47
|
+
|
|
15
48
|
## [0.6.0] - 2026-10-24
|
|
16
49
|
|
|
17
50
|
### Added
|
|
@@ -574,7 +607,9 @@ Initial public release.
|
|
|
574
607
|
stack and all added external-service dependencies or operational
|
|
575
608
|
overhead.
|
|
576
609
|
|
|
577
|
-
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.
|
|
610
|
+
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.8.0...HEAD
|
|
611
|
+
[0.8.0]: https://github.com/sleepunit-agents/loom/compare/v0.7.0...v0.8.0
|
|
612
|
+
[0.7.0]: https://github.com/sleepunit-agents/loom/compare/v0.6.0...v0.7.0
|
|
578
613
|
[0.6.0]: https://github.com/sleepunit-agents/loom/compare/v0.5.0...v0.6.0
|
|
579
614
|
[0.5.0]: https://github.com/sleepunit-agents/loom/compare/v0.4.1...v0.5.0
|
|
580
615
|
[0.4.1]: https://github.com/sleepunit-agents/loom/compare/v0.3.1...v0.4.1
|
package/README.md
CHANGED
|
@@ -383,6 +383,26 @@ header, or a body that isn't `application/json`). The knowledge-only daemon
|
|
|
383
383
|
has no `/cli`. Every other command (`doctor`, `inject`, `serve`, …) stays
|
|
384
384
|
local, and `--url` on one of them is an error.
|
|
385
385
|
|
|
386
|
+
### loom's page — `/ui`
|
|
387
|
+
|
|
388
|
+
The full-scope daemon serves a read-only web page at `/ui`. It shows top of
|
|
389
|
+
mind, the last 24h of episodes from every body, proposals waiting for
|
|
390
|
+
ratification, and person cards with their freshness. Below that, it shows a
|
|
391
|
+
group for each installed **piece** that answers `ui --json`: a chat bridge's
|
|
392
|
+
live conversations, the task queue, and so on. Nothing to configure: install
|
|
393
|
+
a piece and it shows up ([`docs/extensions.md`](docs/extensions.md), "UI
|
|
394
|
+
sections").
|
|
395
|
+
|
|
396
|
+
**The page needs `LOOM_BEARER_TOKEN`.** A daemon with no token serves none
|
|
397
|
+
(403), the same rule as `/cli`, because any site that DNS-rebinds a name to
|
|
398
|
+
the loopback address could read an open page. Log in once with the token. The
|
|
399
|
+
browser keeps a cookie under a key the daemon made at start: it's not the
|
|
400
|
+
token, it can't be replayed against `/mcp` or `/cli`, and it can't be used to
|
|
401
|
+
guess the token. It lasts 30 days, or until the daemon restarts. Scripts can
|
|
402
|
+
send the bearer header to `/ui/sections.json` for the same data. The page has
|
|
403
|
+
no script and is served under a CSP that forbids script and framing. Pieces
|
|
404
|
+
never receive loom's bearer tokens.
|
|
405
|
+
|
|
386
406
|
### Behind a reverse proxy
|
|
387
407
|
|
|
388
408
|
Terminate TLS at a reverse proxy in front of the daemon and point the
|
|
@@ -593,6 +613,24 @@ loom migrate --dry-run # check without applying
|
|
|
593
613
|
loom migrate --json # machine-readable output
|
|
594
614
|
```
|
|
595
615
|
|
|
616
|
+
### Pieces — `loom tasks`, `loom secrets`, … as separate programs
|
|
617
|
+
|
|
618
|
+
loom stays identity and memory. Everything else an agent leans on (tasks,
|
|
619
|
+
secrets, a wake clock, an approval page) is a **piece**: its own program,
|
|
620
|
+
reached through loom's command namespace the way `git` reaches `git-<cmd>`.
|
|
621
|
+
`loom tasks list` runs the first `loom-tasks` on `PATH` with `list`, and
|
|
622
|
+
passes back its exit code. Core commands can't be shadowed. Pieces never
|
|
623
|
+
write loom's stores, work alone, and compose when others are installed. Each
|
|
624
|
+
piece role has a contract, so an implementation can be swapped for another.
|
|
625
|
+
|
|
626
|
+
```bash
|
|
627
|
+
loom pieces # what's installed (name → path)
|
|
628
|
+
loom tasks ready # → loom-tasks ready
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
The rules, the dispatch details and how pieces compose:
|
|
632
|
+
[`docs/extensions.md`](docs/extensions.md).
|
|
633
|
+
|
|
596
634
|
### Harness manifests and self-describe
|
|
597
635
|
|
|
598
636
|
A **harness** is the MCP-capable runtime the agent runs in (Claude Code,
|
package/dist/cli/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { realStreams } from './io.js';
|
|
|
9
9
|
import { SUBCOMMANDS } from './subcommands.js';
|
|
10
10
|
import { extractGlobalFlags, firstNonGlobalIndex } from './args.js';
|
|
11
11
|
import { REMOTE_VERBS, runRemote } from './remote.js';
|
|
12
|
+
import { findPiece, isPieceName, runPiece } from './pieces.js';
|
|
12
13
|
const TOP_HELP = `Usage: loom <command> [options]
|
|
13
14
|
|
|
14
15
|
Commands:
|
|
@@ -30,6 +31,10 @@ Commands:
|
|
|
30
31
|
install Write loom-setup skill into a harness's skills dir
|
|
31
32
|
doctor Probe the loom environment (read-only)
|
|
32
33
|
migrate Apply pending schema migrations to memories.db
|
|
34
|
+
pieces List installed pieces (loom-<name> on PATH)
|
|
35
|
+
|
|
36
|
+
Pieces: \`loom <name> ...\` runs an installed extension, loom-<name> on PATH
|
|
37
|
+
(e.g. loom tasks list). Core commands always win. See docs/extensions.md.
|
|
33
38
|
|
|
34
39
|
Global flags:
|
|
35
40
|
--context-dir <path> Agent context dir (default: $LOOM_CONTEXT_DIR or ~/.config/loom/default)
|
|
@@ -50,6 +55,19 @@ async function readVersion() {
|
|
|
50
55
|
return pkg.version;
|
|
51
56
|
}
|
|
52
57
|
export async function runCli(argv, io = realStreams()) {
|
|
58
|
+
// A piece (docs/extensions.md): not a core command, and loom-<name> is on PATH.
|
|
59
|
+
const pieceIdx = firstNonGlobalIndex(argv);
|
|
60
|
+
const pieceName = argv[pieceIdx];
|
|
61
|
+
if (isPieceName(pieceName)) {
|
|
62
|
+
const path = findPiece(pieceName, io.env);
|
|
63
|
+
if (path) {
|
|
64
|
+
if (pieceIdx > 0) {
|
|
65
|
+
io.stderr(`loom: global flags (${argv.slice(0, pieceIdx).join(' ')}) don't apply to pieces; put the piece's own flags after its name.\n`);
|
|
66
|
+
return 2;
|
|
67
|
+
}
|
|
68
|
+
return runPiece(path, argv.slice(pieceIdx + 1), io);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
53
71
|
const resolved = await resolveSubcommand(argv, io);
|
|
54
72
|
if (typeof resolved === 'number')
|
|
55
73
|
return resolved;
|
|
@@ -101,6 +119,8 @@ async function resolveSubcommand(argv, io) {
|
|
|
101
119
|
}
|
|
102
120
|
if (!SUBCOMMANDS.includes(first)) {
|
|
103
121
|
io.stderr(`Unknown subcommand: ${first}\n`);
|
|
122
|
+
if (isPieceName(first))
|
|
123
|
+
io.stderr(`(no piece installed either: nothing named loom-${first} on PATH; \`loom pieces\` lists what is)\n`);
|
|
104
124
|
io.stderr(TOP_HELP);
|
|
105
125
|
return 2;
|
|
106
126
|
}
|
|
@@ -174,6 +194,10 @@ async function dispatch(sub, rest, io) {
|
|
|
174
194
|
const { run } = await import('./migrate.js');
|
|
175
195
|
return run(rest, io);
|
|
176
196
|
}
|
|
197
|
+
case 'pieces': {
|
|
198
|
+
const { runPiecesCommand } = await import('./pieces.js');
|
|
199
|
+
return runPiecesCommand(rest, io);
|
|
200
|
+
}
|
|
177
201
|
default:
|
|
178
202
|
io.stderr(`Subcommand not implemented yet: ${sub}\n`);
|
|
179
203
|
return 2;
|
|
@@ -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
|
+
`;
|
|
@@ -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', 'person', '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', 'pieces'];
|
|
7
7
|
export type Subcommand = typeof SUBCOMMANDS[number];
|
package/dist/cli/subcommands.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import { createLoomServer } from './server.js';
|
|
|
27
27
|
import { resolveContextDir } from './config.js';
|
|
28
28
|
import { SUBCOMMANDS } from './cli/subcommands.js';
|
|
29
29
|
import { firstNonGlobalIndex } from './cli/args.js';
|
|
30
|
+
import { findPiece } from './cli/pieces.js';
|
|
30
31
|
const CLI_KEYWORDS = new Set(SUBCOMMANDS);
|
|
31
32
|
function isCliInvocation(argv) {
|
|
32
33
|
const args = argv.slice(2);
|
|
@@ -37,7 +38,11 @@ function isCliInvocation(argv) {
|
|
|
37
38
|
return true;
|
|
38
39
|
if (first === '--version' || first === '-V')
|
|
39
40
|
return true;
|
|
40
|
-
|
|
41
|
+
if (CLI_KEYWORDS.has(first))
|
|
42
|
+
return true;
|
|
43
|
+
// An installed piece (loom-<first> on PATH) is a CLI call too. Anything else
|
|
44
|
+
// still falls through to the stdio server, exactly as before.
|
|
45
|
+
return findPiece(first, process.env) !== undefined;
|
|
41
46
|
}
|
|
42
47
|
export { isCliInvocation };
|
|
43
48
|
async function main() {
|
|
@@ -32,6 +32,7 @@ import { EmptyResultSchema } from '@modelcontextprotocol/core';
|
|
|
32
32
|
import { createLoomServer } from '../server.js';
|
|
33
33
|
import { createBackend } from '../backends/index.js';
|
|
34
34
|
import { handleCliBody } from './cli-endpoint.js';
|
|
35
|
+
import { handleUi, isUiPath } from '../ui/handler.js';
|
|
35
36
|
import { assertSafeBind, checkBearer, checkBearerMulti, checkPayloadSize, DEFAULT_MAX_BODY_BYTES, } from './guards.js';
|
|
36
37
|
/**
|
|
37
38
|
* SSE keep-alive: the SDK opens the standalone GET stream but sends no
|
|
@@ -196,6 +197,19 @@ export async function startHttpServer(opts) {
|
|
|
196
197
|
}
|
|
197
198
|
const http = createServer((req, res) => {
|
|
198
199
|
void (async () => {
|
|
200
|
+
// /ui: loom's web page (src/ui). Its own auth (a cookie from the bearer,
|
|
201
|
+
// or the bearer header), so it comes before the bearer gate. Full scope
|
|
202
|
+
// only: a scoped server has no memory to show.
|
|
203
|
+
const uiPath = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
204
|
+
if (isUiPath(uiPath)) {
|
|
205
|
+
if (opts.createServer)
|
|
206
|
+
return sendError(res, 404, 'not-found: this server has no UI');
|
|
207
|
+
return handleUi(req, res, uiPath, {
|
|
208
|
+
contextDir: opts.contextDir,
|
|
209
|
+
secrets: opts.token ? [opts.token] : Object.values(opts.tokens ?? {}),
|
|
210
|
+
env: process.env,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
199
213
|
// Auth gate (ac-lt-auth-gate): reject unauthenticated when a token (or
|
|
200
214
|
// token map) is configured. Multi-token mode additionally resolves
|
|
201
215
|
// WHICH identity made this call — the bearer itself is the identity
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Section } from './sections.js';
|
|
2
|
+
/** loom's zone (LOOM_TIMEZONE, then TZ), and one that Intl accepts: a bad value never breaks the page. */
|
|
3
|
+
export declare function uiTimeZone(): string;
|
|
4
|
+
/** The agent's name: IDENTITY.md's first heading, else the context dir's name. */
|
|
5
|
+
export declare function agentName(contextDir: string): string;
|
|
6
|
+
export declare function coreSections(contextDir: string, opts?: {
|
|
7
|
+
timeZone?: string;
|
|
8
|
+
nowMs?: number;
|
|
9
|
+
}): Section[];
|
package/dist/ui/core.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loom's own sections for the UI: what only loom knows. Read-only views over
|
|
3
|
+
* the stores (top of mind, the episode tape, pending proposals, people),
|
|
4
|
+
* assembled from the same functions the identity payload and the MCP-App
|
|
5
|
+
* widgets use. Nothing here writes.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { digestData } from '../backends/salience.js';
|
|
10
|
+
import { episodeRows, tapeClock } from '../backends/episodes.js';
|
|
11
|
+
import { listProposals } from '../backends/proposals.js';
|
|
12
|
+
import { listPeople } from '../tools/person.js';
|
|
13
|
+
const TOP_OF_MIND = 12;
|
|
14
|
+
const TAPE_HOURS = 24;
|
|
15
|
+
const TAPE_SHOWN = 20;
|
|
16
|
+
function whereOf(metadata, project) {
|
|
17
|
+
try {
|
|
18
|
+
const meta = JSON.parse(metadata || '{}');
|
|
19
|
+
if (typeof meta.where === 'string' && meta.where.trim())
|
|
20
|
+
return meta.where.trim();
|
|
21
|
+
}
|
|
22
|
+
catch { /* fall through */ }
|
|
23
|
+
return project?.trim() || '?';
|
|
24
|
+
}
|
|
25
|
+
/** loom's zone (LOOM_TIMEZONE, then TZ), and one that Intl accepts: a bad value never breaks the page. */
|
|
26
|
+
export function uiTimeZone() {
|
|
27
|
+
for (const z of [process.env.LOOM_TIMEZONE, process.env.TZ, 'America/Chicago']) {
|
|
28
|
+
if (!z)
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
new Intl.DateTimeFormat('en-US', { timeZone: z });
|
|
32
|
+
return z;
|
|
33
|
+
}
|
|
34
|
+
catch { /* next */ }
|
|
35
|
+
}
|
|
36
|
+
return 'UTC';
|
|
37
|
+
}
|
|
38
|
+
function tierTone(tier) {
|
|
39
|
+
return tier === 'cold' ? 'muted' : undefined;
|
|
40
|
+
}
|
|
41
|
+
/** The agent's name: IDENTITY.md's first heading, else the context dir's name. */
|
|
42
|
+
export function agentName(contextDir) {
|
|
43
|
+
try {
|
|
44
|
+
const line = readFileSync(join(contextDir, 'IDENTITY.md'), 'utf-8').split('\n').find((l) => /^#\s+\S/.test(l));
|
|
45
|
+
if (line)
|
|
46
|
+
return line.replace(/^#\s+/, '').trim();
|
|
47
|
+
}
|
|
48
|
+
catch { /* no identity file */ }
|
|
49
|
+
return contextDir.split('/').filter(Boolean).pop() ?? 'loom';
|
|
50
|
+
}
|
|
51
|
+
export function coreSections(contextDir, opts = {}) {
|
|
52
|
+
const timeZone = opts.timeZone ?? uiTimeZone();
|
|
53
|
+
const digest = digestData(contextDir, TOP_OF_MIND);
|
|
54
|
+
const episodes = episodeRows(contextDir, { hours: TAPE_HOURS, nowMs: opts.nowMs });
|
|
55
|
+
const pending = listProposals(contextDir).filter((p) => p.status === 'pending');
|
|
56
|
+
const people = listPeople(contextDir);
|
|
57
|
+
const sections = [
|
|
58
|
+
{
|
|
59
|
+
id: 'overview',
|
|
60
|
+
title: 'Overview',
|
|
61
|
+
blocks: [{
|
|
62
|
+
type: 'stats',
|
|
63
|
+
items: [
|
|
64
|
+
{ label: 'memories', value: String(digest?.total ?? 0) },
|
|
65
|
+
{ label: `episodes, last ${TAPE_HOURS}h`, value: String(episodes.length) },
|
|
66
|
+
{ label: 'proposals pending', value: String(pending.length), tone: pending.length ? 'warn' : undefined },
|
|
67
|
+
{ label: 'people', value: String(people.length) },
|
|
68
|
+
],
|
|
69
|
+
}],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'tape',
|
|
73
|
+
title: `Last ${TAPE_HOURS}h`,
|
|
74
|
+
blocks: episodes.length
|
|
75
|
+
? [
|
|
76
|
+
{
|
|
77
|
+
type: 'list',
|
|
78
|
+
items: [...episodes].reverse().slice(0, TAPE_SHOWN).map((e) => ({
|
|
79
|
+
text: e.title,
|
|
80
|
+
meta: `${tapeClock(e.created, timeZone)} · ${whereOf(e.metadata, e.project)}`,
|
|
81
|
+
})),
|
|
82
|
+
},
|
|
83
|
+
...(episodes.length > TAPE_SHOWN
|
|
84
|
+
? [{ type: 'text', text: `and ${episodes.length - TAPE_SHOWN} earlier` }]
|
|
85
|
+
: []),
|
|
86
|
+
]
|
|
87
|
+
: [{ type: 'text', text: 'No episodes in the last day.' }],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'top-of-mind',
|
|
91
|
+
title: 'Top of mind',
|
|
92
|
+
blocks: digest && digest.atoms.length
|
|
93
|
+
? [{
|
|
94
|
+
type: 'list',
|
|
95
|
+
items: digest.atoms.map((a) => ({ text: a.title, meta: `${a.category} · ${a.tier}`, tone: tierTone(a.tier) })),
|
|
96
|
+
}]
|
|
97
|
+
: [{ type: 'text', text: 'Nothing hot yet.' }],
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
if (pending.length) {
|
|
101
|
+
sections.push({
|
|
102
|
+
id: 'proposals',
|
|
103
|
+
title: 'Proposals waiting',
|
|
104
|
+
blocks: [{
|
|
105
|
+
type: 'list',
|
|
106
|
+
items: pending.map((p) => ({
|
|
107
|
+
text: p.title ?? '(untitled)',
|
|
108
|
+
meta: `#${p.id} · ${p.category ?? '?'}${p.source ? ` · ${p.source}` : ''}`,
|
|
109
|
+
tone: 'warn',
|
|
110
|
+
})),
|
|
111
|
+
}],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (people.length) {
|
|
115
|
+
// Only the cards that need a look: stale or unverified fields, or memories
|
|
116
|
+
// written since the summary was last synthesized.
|
|
117
|
+
const attention = people.filter((p) => p.stale || p.unverified || p.memories_since_synthesis);
|
|
118
|
+
sections.push({
|
|
119
|
+
id: 'people',
|
|
120
|
+
title: 'People',
|
|
121
|
+
blocks: [
|
|
122
|
+
{
|
|
123
|
+
type: 'stats',
|
|
124
|
+
items: [
|
|
125
|
+
{ label: 'cards', value: String(people.length) },
|
|
126
|
+
{ label: 'need a look', value: String(attention.length), tone: attention.length ? 'warn' : 'ok' },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
attention.length
|
|
130
|
+
? {
|
|
131
|
+
type: 'table',
|
|
132
|
+
columns: ['name', 'stale', 'unverified', 'new memories'],
|
|
133
|
+
rows: attention.map((p) => [p.name, String(p.stale), String(p.unverified), String(p.memories_since_synthesis)]),
|
|
134
|
+
}
|
|
135
|
+
: { type: 'text', text: 'Every card is fresh.' },
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return sections;
|
|
140
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
export interface UiOptions {
|
|
3
|
+
contextDir: string;
|
|
4
|
+
/** Configured bearer secrets (the single token, or the token map's values). Empty = open. */
|
|
5
|
+
secrets: string[];
|
|
6
|
+
/** Env for piece discovery and piece processes (PATH decides which pieces exist). */
|
|
7
|
+
env: NodeJS.ProcessEnv;
|
|
8
|
+
}
|
|
9
|
+
export declare function isUiPath(pathname: string): boolean;
|
|
10
|
+
/** Handle a /ui request. The caller has already matched isUiPath. */
|
|
11
|
+
export declare function handleUi(req: IncomingMessage, res: ServerResponse, pathname: string, opts: UiOptions): Promise<void>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loom's web UI on the HTTP daemon (full scope only): GET /ui, the page;
|
|
3
|
+
* GET /ui/sections.json, the same sections as data; /ui/login and /ui/logout.
|
|
4
|
+
*
|
|
5
|
+
* Auth follows the daemon's bearer token, and the UI needs one: a daemon with
|
|
6
|
+
* no token serves no /ui (like /cli), because an open page would be readable
|
|
7
|
+
* by any site that DNS-rebinds a name to the loopback address. A browser logs
|
|
8
|
+
* in once by pasting the token. The cookie is `<issued-at>.<mac>`, a MAC under
|
|
9
|
+
* a key this process made at start, over the issued-at time and a digest of
|
|
10
|
+
* the token. It isn't the token and isn't derivable from it, so it can't be
|
|
11
|
+
* replayed as a bearer or used to guess the token offline. It lasts 30 days,
|
|
12
|
+
* or until the daemon restarts (log in again). Scripts can send the bearer
|
|
13
|
+
* header instead. The page is read-only, has no script, and is served under a
|
|
14
|
+
* CSP that forbids script and framing.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { checkBearer } from '../transport/guards.js';
|
|
18
|
+
import { agentName, coreSections, uiTimeZone } from './core.js';
|
|
19
|
+
import { pieceSections } from './piece-sections.js';
|
|
20
|
+
import { renderLogin, renderPage } from './render.js';
|
|
21
|
+
const COOKIE = 'loom_ui';
|
|
22
|
+
const LOGIN_MAX_BYTES = 4096;
|
|
23
|
+
const COOKIE_MAX_AGE_S = 30 * 24 * 3600;
|
|
24
|
+
/** This process's cookie key. Never written anywhere: a restart means logging in again. */
|
|
25
|
+
const COOKIE_KEY = randomBytes(32);
|
|
26
|
+
const SECURITY_HEADERS = {
|
|
27
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
28
|
+
'x-content-type-options': 'nosniff',
|
|
29
|
+
'referrer-policy': 'no-referrer',
|
|
30
|
+
'cache-control': 'no-store',
|
|
31
|
+
};
|
|
32
|
+
export function isUiPath(pathname) {
|
|
33
|
+
return pathname === '/ui' || pathname.startsWith('/ui/');
|
|
34
|
+
}
|
|
35
|
+
const digest = (s) => createHash('sha256').update(s).digest();
|
|
36
|
+
function mac(issuedAt, secret) {
|
|
37
|
+
return createHmac('sha256', COOKIE_KEY).update(`loom-ui-v2|${issuedAt}|`).update(digest(secret)).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
function cookieValue(secret, issuedAt = Math.floor(Date.now() / 1000)) {
|
|
40
|
+
return `${issuedAt}.${mac(issuedAt, secret)}`;
|
|
41
|
+
}
|
|
42
|
+
/** Equal-length digests, so neither the content nor the length of a guess leaks. */
|
|
43
|
+
function safeEq(a, b) {
|
|
44
|
+
return timingSafeEqual(digest(a), digest(b));
|
|
45
|
+
}
|
|
46
|
+
function validCookie(value, secrets, nowS = Math.floor(Date.now() / 1000)) {
|
|
47
|
+
const m = /^(\d{1,12})\.([0-9a-f]{64})$/.exec(value);
|
|
48
|
+
if (!m)
|
|
49
|
+
return false;
|
|
50
|
+
const issuedAt = Number(m[1]);
|
|
51
|
+
if (issuedAt > nowS + 60 || nowS - issuedAt > COOKIE_MAX_AGE_S)
|
|
52
|
+
return false;
|
|
53
|
+
return secrets.some((s) => safeEq(m[2], mac(issuedAt, s)));
|
|
54
|
+
}
|
|
55
|
+
function readCookie(req, name) {
|
|
56
|
+
for (const part of (req.headers.cookie ?? '').split(';')) {
|
|
57
|
+
const [k, ...v] = part.trim().split('=');
|
|
58
|
+
if (k === name)
|
|
59
|
+
return v.join('=');
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
function authorized(req, opts) {
|
|
64
|
+
if (opts.secrets.length === 0)
|
|
65
|
+
return false;
|
|
66
|
+
const c = readCookie(req, COOKIE);
|
|
67
|
+
if (c && validCookie(c, opts.secrets))
|
|
68
|
+
return true;
|
|
69
|
+
const header = req.headers['authorization'];
|
|
70
|
+
return header !== undefined && opts.secrets.some((s) => checkBearer(s, header).ok);
|
|
71
|
+
}
|
|
72
|
+
function send(res, status, type, body, extra = {}) {
|
|
73
|
+
res.writeHead(status, { 'content-type': type, ...SECURITY_HEADERS, ...extra });
|
|
74
|
+
res.end(body);
|
|
75
|
+
}
|
|
76
|
+
function redirect(res, to, extra = {}) {
|
|
77
|
+
res.writeHead(303, { location: to, ...SECURITY_HEADERS, ...extra });
|
|
78
|
+
res.end();
|
|
79
|
+
}
|
|
80
|
+
function readForm(req) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
let body = '';
|
|
83
|
+
let done = false;
|
|
84
|
+
const finish = (v) => { if (!done) {
|
|
85
|
+
done = true;
|
|
86
|
+
resolve(v);
|
|
87
|
+
} };
|
|
88
|
+
req.on('data', (d) => {
|
|
89
|
+
if (done)
|
|
90
|
+
return;
|
|
91
|
+
body += d.toString('utf-8');
|
|
92
|
+
if (body.length > LOGIN_MAX_BYTES) {
|
|
93
|
+
finish(null);
|
|
94
|
+
req.destroy();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
req.on('end', () => finish(new URLSearchParams(body)));
|
|
98
|
+
req.on('error', () => finish(null));
|
|
99
|
+
req.on('close', () => finish(null));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async function sections(opts) {
|
|
103
|
+
const core = coreSections(opts.contextDir);
|
|
104
|
+
const { contributed, skipped } = await pieceSections(opts.env);
|
|
105
|
+
return { core, pieces: contributed, skipped };
|
|
106
|
+
}
|
|
107
|
+
/** Handle a /ui request. The caller has already matched isUiPath. */
|
|
108
|
+
export async function handleUi(req, res, pathname, opts) {
|
|
109
|
+
// No token, no page: an open /ui would be readable by any site that
|
|
110
|
+
// DNS-rebinds a name to the loopback address (the same rule as /cli).
|
|
111
|
+
if (opts.secrets.length === 0) {
|
|
112
|
+
return send(res, 403, 'text/plain', 'forbidden: loom\'s page requires LOOM_BEARER_TOKEN');
|
|
113
|
+
}
|
|
114
|
+
const secure = req.headers['x-forwarded-proto'] === 'https';
|
|
115
|
+
if (pathname === '/ui/login') {
|
|
116
|
+
// Unauthenticated: the login page names no agent.
|
|
117
|
+
if (req.method === 'GET')
|
|
118
|
+
return send(res, 200, 'text/html; charset=utf-8', renderLogin());
|
|
119
|
+
if (req.method !== 'POST')
|
|
120
|
+
return send(res, 405, 'text/plain', 'method not allowed');
|
|
121
|
+
const form = await readForm(req);
|
|
122
|
+
if (!form)
|
|
123
|
+
return send(res, 413, 'text/plain', 'too large');
|
|
124
|
+
const token = form.get('token')?.trim() ?? '';
|
|
125
|
+
const match = opts.secrets.find((s) => safeEq(token, s));
|
|
126
|
+
if (!match)
|
|
127
|
+
return send(res, 401, 'text/html; charset=utf-8', renderLogin('That token didn’t match.'));
|
|
128
|
+
return redirect(res, '/ui', {
|
|
129
|
+
'set-cookie': `${COOKIE}=${cookieValue(match)}; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (pathname === '/ui/logout') {
|
|
133
|
+
if (req.method !== 'POST')
|
|
134
|
+
return send(res, 405, 'text/plain', 'method not allowed');
|
|
135
|
+
return redirect(res, '/ui/login', { 'set-cookie': `${COOKIE}=; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=0` });
|
|
136
|
+
}
|
|
137
|
+
const agent = agentName(opts.contextDir);
|
|
138
|
+
if (req.method !== 'GET')
|
|
139
|
+
return send(res, 405, 'text/plain', 'method not allowed');
|
|
140
|
+
if (pathname === '/ui/sections.json') {
|
|
141
|
+
if (!authorized(req, opts))
|
|
142
|
+
return send(res, 401, 'application/json', JSON.stringify({ error: 'unauthorized' }));
|
|
143
|
+
const s = await sections(opts);
|
|
144
|
+
return send(res, 200, 'application/json', JSON.stringify({ agent, ...s }));
|
|
145
|
+
}
|
|
146
|
+
if (pathname === '/ui' || pathname === '/ui/') {
|
|
147
|
+
if (!authorized(req, opts))
|
|
148
|
+
return redirect(res, '/ui/login');
|
|
149
|
+
const s = await sections(opts);
|
|
150
|
+
return send(res, 200, 'text/html; charset=utf-8', renderPage({
|
|
151
|
+
agent,
|
|
152
|
+
core: s.core,
|
|
153
|
+
pieces: s.pieces,
|
|
154
|
+
skipped: s.skipped,
|
|
155
|
+
generatedAt: new Date().toLocaleTimeString('en-US', { timeZone: uiTimeZone(), hour: '2-digit', minute: '2-digit' }),
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
return send(res, 404, 'text/plain', 'not found');
|
|
159
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Section } from './sections.js';
|
|
2
|
+
export declare const PIECE_TIMEOUT_MS = 3000;
|
|
3
|
+
export declare const PIECE_MAX_BYTES: number;
|
|
4
|
+
/** Longer than the page's 30s refresh, so an open tab doesn't re-run every piece each time. */
|
|
5
|
+
export declare const CACHE_MS = 45000;
|
|
6
|
+
export interface PieceSections {
|
|
7
|
+
piece: string;
|
|
8
|
+
sections: Section[];
|
|
9
|
+
}
|
|
10
|
+
/** Why a piece contributed nothing, for the page footer (never shown as an error). */
|
|
11
|
+
export interface PieceSkip {
|
|
12
|
+
piece: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function clearPieceCache(): void;
|
|
16
|
+
export declare function pieceEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
17
|
+
export declare function pieceSections(env: NodeJS.ProcessEnv, nowMs?: number): Promise<{
|
|
18
|
+
contributed: PieceSections[];
|
|
19
|
+
skipped: PieceSkip[];
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sections from installed pieces: each `loom-<piece>` on PATH is asked
|
|
3
|
+
* `ui --json`. A piece that answers with valid sections shows up on loom's
|
|
4
|
+
* page with no configuration; one that doesn't (no `ui` verb, a timeout, bad
|
|
5
|
+
* JSON) simply isn't there. Composes without requiring (docs/extensions.md).
|
|
6
|
+
*
|
|
7
|
+
* Bounded on every axis: one run per piece per cache window however many
|
|
8
|
+
* pages load at once (the in-flight promise is what's cached), 3s and 512KB
|
|
9
|
+
* per answer, and on a timeout the piece's whole process group is killed, not
|
|
10
|
+
* just its first process. A piece gets the daemon's env minus loom's own
|
|
11
|
+
* bearer tokens.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { listPieces } from '../cli/pieces.js';
|
|
15
|
+
import { parseSections } from './sections.js';
|
|
16
|
+
export const PIECE_TIMEOUT_MS = 3_000;
|
|
17
|
+
export const PIECE_MAX_BYTES = 512 * 1024;
|
|
18
|
+
/** Longer than the page's 30s refresh, so an open tab doesn't re-run every piece each time. */
|
|
19
|
+
export const CACHE_MS = 45_000;
|
|
20
|
+
/** After the piece exits, how long to wait for its stdout to close (a background helper may hold it). */
|
|
21
|
+
const DRAIN_MS = 200;
|
|
22
|
+
/** Env loom never hands to a piece. */
|
|
23
|
+
const WITHHELD = ['LOOM_BEARER_TOKEN', 'LOOM_KNOWLEDGE_BEARER_TOKENS'];
|
|
24
|
+
const cache = new Map();
|
|
25
|
+
export function clearPieceCache() { cache.clear(); }
|
|
26
|
+
export function pieceEnv(env) {
|
|
27
|
+
const out = { ...env };
|
|
28
|
+
for (const k of WITHHELD)
|
|
29
|
+
delete out[k];
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
function killGroup(pid) {
|
|
33
|
+
if (pid === undefined)
|
|
34
|
+
return;
|
|
35
|
+
try {
|
|
36
|
+
process.kill(-pid, 'SIGKILL');
|
|
37
|
+
}
|
|
38
|
+
catch { /* already gone */ }
|
|
39
|
+
}
|
|
40
|
+
function ask(path, env) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
// Its own process group, so a timeout takes the piece's children with it.
|
|
43
|
+
const child = spawn(path, ['ui', '--json'], { env: pieceEnv(env), stdio: ['ignore', 'pipe', 'ignore'], detached: true });
|
|
44
|
+
let out = '';
|
|
45
|
+
let bytes = 0;
|
|
46
|
+
let settled = false;
|
|
47
|
+
let drain;
|
|
48
|
+
const finish = (r) => {
|
|
49
|
+
if (settled)
|
|
50
|
+
return;
|
|
51
|
+
settled = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
if (drain)
|
|
54
|
+
clearTimeout(drain);
|
|
55
|
+
child.stdout?.destroy();
|
|
56
|
+
resolve(r);
|
|
57
|
+
};
|
|
58
|
+
const timer = setTimeout(() => { killGroup(child.pid); finish({ ok: false, reason: 'timed out' }); }, PIECE_TIMEOUT_MS);
|
|
59
|
+
child.stdout?.on('data', (d) => {
|
|
60
|
+
bytes += d.length;
|
|
61
|
+
if (bytes > PIECE_MAX_BYTES) {
|
|
62
|
+
killGroup(child.pid);
|
|
63
|
+
finish({ ok: false, reason: 'answer too large' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
out += d.toString('utf-8');
|
|
67
|
+
});
|
|
68
|
+
child.on('error', (e) => finish({ ok: false, reason: e.message }));
|
|
69
|
+
// Resolve on the piece's own exit (plus a short drain), not on 'close':
|
|
70
|
+
// 'close' waits for every holder of the pipe, and a helper the piece left
|
|
71
|
+
// running would hold it open past the timeout.
|
|
72
|
+
child.on('exit', (code) => {
|
|
73
|
+
const done = () => finish(code === 0 ? { ok: true, stdout: out } : { ok: false, reason: 'no ui' });
|
|
74
|
+
if (child.stdout?.readableEnded)
|
|
75
|
+
return done();
|
|
76
|
+
child.stdout?.once('end', done);
|
|
77
|
+
drain = setTimeout(done, DRAIN_MS);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function run(name, path, env) {
|
|
82
|
+
const r = await ask(path, env);
|
|
83
|
+
if (!r.ok)
|
|
84
|
+
return { piece: name, reason: r.reason };
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(r.stdout);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { piece: name, reason: 'invalid: not JSON' };
|
|
91
|
+
}
|
|
92
|
+
const sections = parseSections(parsed);
|
|
93
|
+
return typeof sections === 'string' ? { piece: name, reason: `invalid: ${sections}` } : { piece: name, sections };
|
|
94
|
+
}
|
|
95
|
+
function sectionsFor(name, path, env, nowMs) {
|
|
96
|
+
const hit = cache.get(path);
|
|
97
|
+
if (hit && nowMs - hit.at < CACHE_MS)
|
|
98
|
+
return hit.result;
|
|
99
|
+
// Cache the promise itself: concurrent page loads share one run.
|
|
100
|
+
const result = run(name, path, env);
|
|
101
|
+
cache.set(path, { at: nowMs, result });
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
export async function pieceSections(env, nowMs = Date.now()) {
|
|
105
|
+
// "loom" is the core group's name on the page; a piece can't take it.
|
|
106
|
+
const pieces = listPieces(env).filter((p) => p.name !== 'loom');
|
|
107
|
+
const results = await Promise.all(pieces.map((p) => sectionsFor(p.name, p.path, env, nowMs)));
|
|
108
|
+
return {
|
|
109
|
+
contributed: results.filter((r) => 'sections' in r && r.sections.length > 0),
|
|
110
|
+
skipped: results.filter((r) => 'reason' in r),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side HTML for loom's page. Every string that reaches the page goes
|
|
3
|
+
* through esc(): pieces send data in the section vocabulary, never markup.
|
|
4
|
+
* No client script; the page refreshes itself.
|
|
5
|
+
*/
|
|
6
|
+
import type { Section } from './sections.js';
|
|
7
|
+
import type { PieceSections, PieceSkip } from './piece-sections.js';
|
|
8
|
+
export declare function esc(s: string): string;
|
|
9
|
+
export declare function renderPage(opts: {
|
|
10
|
+
agent: string;
|
|
11
|
+
core: Section[];
|
|
12
|
+
pieces: PieceSections[];
|
|
13
|
+
skipped: PieceSkip[];
|
|
14
|
+
generatedAt: string;
|
|
15
|
+
}): string;
|
|
16
|
+
export declare function renderLogin(error?: string): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export function esc(s) {
|
|
2
|
+
return s
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''');
|
|
8
|
+
}
|
|
9
|
+
const toneClass = (t) => (t ? ` tone-${t}` : '');
|
|
10
|
+
function renderBlock(b) {
|
|
11
|
+
switch (b.type) {
|
|
12
|
+
case 'stats':
|
|
13
|
+
return `<dl class="stats">${b.items
|
|
14
|
+
.map((i) => `<div class="stat${toneClass(i.tone)}"><dt>${esc(i.label)}</dt><dd>${esc(i.value)}</dd></div>`)
|
|
15
|
+
.join('')}</dl>`;
|
|
16
|
+
case 'list':
|
|
17
|
+
return `<ul class="list">${b.items
|
|
18
|
+
.map((i) => `<li class="${toneClass(i.tone).trim()}"><span class="text">${esc(i.text)}</span>${i.meta ? `<span class="meta">${esc(i.meta)}</span>` : ''}</li>`)
|
|
19
|
+
.join('')}</ul>`;
|
|
20
|
+
case 'table':
|
|
21
|
+
return `<div class="table-wrap"><table><thead><tr>${b.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${b.rows
|
|
22
|
+
.map((r) => `<tr>${r.map((c) => `<td>${esc(c)}</td>`).join('')}</tr>`)
|
|
23
|
+
.join('')}</tbody></table></div>`;
|
|
24
|
+
case 'text':
|
|
25
|
+
return `<p class="text-block">${esc(b.text)}</p>`;
|
|
26
|
+
case 'link':
|
|
27
|
+
return `<p><a href="${esc(b.href)}" rel="noopener noreferrer">${esc(b.text)}</a></p>`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Ids are namespaced (g- groups, s-<source>--<id> sections) so no piece's
|
|
31
|
+
// anchors can land on another's or on loom's.
|
|
32
|
+
function renderSection(s, source) {
|
|
33
|
+
return `<section class="card" id="s-${esc(source)}--${esc(s.id)}"><h2>${esc(s.title)}</h2>${s.blocks.map(renderBlock).join('')}</section>`;
|
|
34
|
+
}
|
|
35
|
+
const STYLE = `
|
|
36
|
+
:root{--bg:#f7f7f5;--card:#fff;--fg:#1d1d1b;--muted:#6b6b66;--line:#e4e4df;--accent:#3b5bdb;--ok:#2b8a3e;--warn:#b26a00;--bad:#c92a2a;color-scheme:light}
|
|
37
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#141413;--card:#1e1e1c;--fg:#ececea;--muted:#9a9a94;--line:#2e2e2b;--accent:#8ca2ff;--ok:#69db7c;--warn:#ffc078;--bad:#ff8787;color-scheme:dark}}
|
|
38
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
39
|
+
header{padding:14px 16px 0;max-width:1100px;margin:0 auto}header h1{margin:0;font-size:18px;font-weight:600}header .sub{color:var(--muted);font-size:13px}
|
|
40
|
+
nav{display:flex;gap:4px;overflow-x:auto;padding:10px 16px;max-width:1100px;margin:0 auto;border-bottom:1px solid var(--line)}
|
|
41
|
+
nav a{color:var(--muted);text-decoration:none;padding:6px 10px;border-radius:8px;white-space:nowrap;font-size:14px}nav a:hover{background:var(--card);color:var(--fg)}
|
|
42
|
+
main{max-width:1100px;margin:0 auto;padding:16px}.group{margin-bottom:24px}.group>h2.source{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 8px}
|
|
43
|
+
.grid{display:grid;gap:12px;align-items:start;grid-template-columns:repeat(auto-fill,minmax(min(100%,340px),1fr))}
|
|
44
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;min-width:0}.card h2{margin:0 0 10px;font-size:15px}
|
|
45
|
+
.stats{display:flex;flex-wrap:wrap;gap:16px;margin:0}.stat dt{color:var(--muted);font-size:12px}.stat dd{margin:0;font-size:22px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
46
|
+
.list{list-style:none;margin:0;padding:0}.list li{padding:6px 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:2px}.list li:first-child{border-top:0}
|
|
47
|
+
.list .meta{color:var(--muted);font-size:12px}.text-block{margin:0;color:var(--muted)}
|
|
48
|
+
.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:5px 6px;border-top:1px solid var(--line)}th{color:var(--muted);font-weight:500;border-top:0}
|
|
49
|
+
.tone-ok dd,.tone-ok .text{color:var(--ok)}.tone-warn dd,.tone-warn .text{color:var(--warn)}.tone-bad dd,.tone-bad .text{color:var(--bad)}.tone-muted dd,.tone-muted .text{color:var(--muted)}
|
|
50
|
+
a{color:var(--accent)}footer{max-width:1100px;margin:0 auto;padding:0 16px 24px;color:var(--muted);font-size:12px}
|
|
51
|
+
form.login{max-width:360px;margin:48px auto;padding:16px}form.login input{width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);font:inherit}
|
|
52
|
+
form.inline{display:inline}form.inline button{background:none;border:0;padding:0;color:var(--accent);font:inherit;cursor:pointer;text-decoration:underline}
|
|
53
|
+
form.login button{margin-top:10px;padding:9px 14px;border:0;border-radius:8px;background:var(--accent);color:#fff;font:inherit}.err{color:var(--bad)}
|
|
54
|
+
`;
|
|
55
|
+
function shell(title, body, refresh) {
|
|
56
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${refresh ? '<meta http-equiv="refresh" content="30">' : ''}<title>${esc(title)}</title><style>${STYLE}</style></head><body>${body}</body></html>`;
|
|
57
|
+
}
|
|
58
|
+
export function renderPage(opts) {
|
|
59
|
+
const groups = [{ source: 'loom', sections: opts.core }, ...opts.pieces.map((p) => ({ source: p.piece, sections: p.sections }))];
|
|
60
|
+
const nav = groups.map((g) => `<a href="#g-${esc(g.source)}">${esc(g.source)}</a>`).join('');
|
|
61
|
+
const main = groups
|
|
62
|
+
.map((g) => `<div class="group" id="g-${esc(g.source)}"><h2 class="source">${esc(g.source)}</h2><div class="grid">${g.sections.map((s) => renderSection(s, g.source)).join('')}</div></div>`)
|
|
63
|
+
.join('');
|
|
64
|
+
const quiet = opts.skipped.length ? ` · pieces with no page: ${opts.skipped.map((s) => esc(s.piece)).join(', ')}` : '';
|
|
65
|
+
return shell(`${opts.agent} · loom`, `<header><h1>${esc(opts.agent)}</h1><div class="sub">loom</div></header><nav>${nav}</nav><main>${main}</main>` +
|
|
66
|
+
`<footer>Updated ${esc(opts.generatedAt)} · refreshes every 30s${quiet} · <form class="inline" method="post" action="/ui/logout"><button type="submit">log out</button></form></footer>`, true);
|
|
67
|
+
}
|
|
68
|
+
export function renderLogin(error) {
|
|
69
|
+
return shell('loom', `<form class="login" method="post" action="/ui/login"><h1>loom</h1><p class="sub">Paste the bearer token once. This browser keeps a cookie (until the daemon restarts, or 30 days).</p>${error ? `<p class="err">${esc(error)}</p>` : ''}<input type="password" name="token" autocomplete="current-password" autofocus aria-label="token"><button type="submit">Open</button></form>`, false);
|
|
70
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI section vocabulary (docs/extensions.md, "UI sections").
|
|
3
|
+
*
|
|
4
|
+
* loom's page is built from sections, both its own and the installed pieces'.
|
|
5
|
+
* A piece contributes by answering `loom-<piece> ui --json` with
|
|
6
|
+
* `{ "sections": [...] }` in this vocabulary. Pieces send data, never markup:
|
|
7
|
+
* loom renders every block itself and escapes every string, so a piece can't
|
|
8
|
+
* put HTML or script on loom's page. Anything that doesn't validate is dropped
|
|
9
|
+
* whole (a piece that sends garbage just isn't shown), never half-rendered.
|
|
10
|
+
*/
|
|
11
|
+
export type Tone = 'ok' | 'warn' | 'bad' | 'muted';
|
|
12
|
+
export type Block = {
|
|
13
|
+
type: 'stats';
|
|
14
|
+
items: Array<{
|
|
15
|
+
label: string;
|
|
16
|
+
value: string;
|
|
17
|
+
tone?: Tone;
|
|
18
|
+
}>;
|
|
19
|
+
} | {
|
|
20
|
+
type: 'list';
|
|
21
|
+
items: Array<{
|
|
22
|
+
text: string;
|
|
23
|
+
meta?: string;
|
|
24
|
+
tone?: Tone;
|
|
25
|
+
}>;
|
|
26
|
+
} | {
|
|
27
|
+
type: 'table';
|
|
28
|
+
columns: string[];
|
|
29
|
+
rows: string[][];
|
|
30
|
+
} | {
|
|
31
|
+
type: 'text';
|
|
32
|
+
text: string;
|
|
33
|
+
} | {
|
|
34
|
+
type: 'link';
|
|
35
|
+
text: string;
|
|
36
|
+
href: string;
|
|
37
|
+
};
|
|
38
|
+
export interface Section {
|
|
39
|
+
id: string;
|
|
40
|
+
title: string;
|
|
41
|
+
blocks: Block[];
|
|
42
|
+
}
|
|
43
|
+
export declare const LIMITS: {
|
|
44
|
+
readonly sections: 10;
|
|
45
|
+
readonly blocks: 20;
|
|
46
|
+
readonly items: 200;
|
|
47
|
+
readonly columns: 12;
|
|
48
|
+
readonly string: 2000;
|
|
49
|
+
};
|
|
50
|
+
/** Validate a piece's `ui --json` answer. Returns the sections, or why it was refused. */
|
|
51
|
+
export declare function parseSections(value: unknown): Section[] | string;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI section vocabulary (docs/extensions.md, "UI sections").
|
|
3
|
+
*
|
|
4
|
+
* loom's page is built from sections, both its own and the installed pieces'.
|
|
5
|
+
* A piece contributes by answering `loom-<piece> ui --json` with
|
|
6
|
+
* `{ "sections": [...] }` in this vocabulary. Pieces send data, never markup:
|
|
7
|
+
* loom renders every block itself and escapes every string, so a piece can't
|
|
8
|
+
* put HTML or script on loom's page. Anything that doesn't validate is dropped
|
|
9
|
+
* whole (a piece that sends garbage just isn't shown), never half-rendered.
|
|
10
|
+
*/
|
|
11
|
+
export const LIMITS = {
|
|
12
|
+
sections: 10,
|
|
13
|
+
blocks: 20,
|
|
14
|
+
items: 200,
|
|
15
|
+
columns: 12,
|
|
16
|
+
string: 2000,
|
|
17
|
+
};
|
|
18
|
+
const TONES = new Set(['ok', 'warn', 'bad', 'muted']);
|
|
19
|
+
const ID = /^[a-z][a-z0-9-]{0,39}$/;
|
|
20
|
+
class Invalid extends Error {
|
|
21
|
+
}
|
|
22
|
+
function str(v, what) {
|
|
23
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
24
|
+
return String(v);
|
|
25
|
+
if (typeof v !== 'string')
|
|
26
|
+
throw new Invalid(`${what} must be a string`);
|
|
27
|
+
if (v.length > LIMITS.string)
|
|
28
|
+
throw new Invalid(`${what} is longer than ${LIMITS.string}`);
|
|
29
|
+
return v;
|
|
30
|
+
}
|
|
31
|
+
function optStr(v, what) {
|
|
32
|
+
return v === undefined ? undefined : str(v, what);
|
|
33
|
+
}
|
|
34
|
+
function tone(v) {
|
|
35
|
+
if (v === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (typeof v !== 'string' || !TONES.has(v))
|
|
38
|
+
throw new Invalid(`tone must be one of ${[...TONES].join(', ')}`);
|
|
39
|
+
return v;
|
|
40
|
+
}
|
|
41
|
+
function arr(v, what, max) {
|
|
42
|
+
if (!Array.isArray(v))
|
|
43
|
+
throw new Invalid(`${what} must be an array`);
|
|
44
|
+
if (v.length > max)
|
|
45
|
+
throw new Invalid(`${what} has more than ${max} entries`);
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
function obj(v, what) {
|
|
49
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v))
|
|
50
|
+
throw new Invalid(`${what} must be an object`);
|
|
51
|
+
return v;
|
|
52
|
+
}
|
|
53
|
+
function block(v) {
|
|
54
|
+
const b = obj(v, 'block');
|
|
55
|
+
switch (b.type) {
|
|
56
|
+
case 'stats':
|
|
57
|
+
return {
|
|
58
|
+
type: 'stats',
|
|
59
|
+
items: arr(b.items, 'stats.items', LIMITS.items).map((i) => {
|
|
60
|
+
const o = obj(i, 'stat');
|
|
61
|
+
return { label: str(o.label, 'stat.label'), value: str(o.value, 'stat.value'), tone: tone(o.tone) };
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
case 'list':
|
|
65
|
+
return {
|
|
66
|
+
type: 'list',
|
|
67
|
+
items: arr(b.items, 'list.items', LIMITS.items).map((i) => {
|
|
68
|
+
const o = obj(i, 'list item');
|
|
69
|
+
return { text: str(o.text, 'list.text'), meta: optStr(o.meta, 'list.meta'), tone: tone(o.tone) };
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
case 'table': {
|
|
73
|
+
const columns = arr(b.columns, 'table.columns', LIMITS.columns).map((c) => str(c, 'table column'));
|
|
74
|
+
const rows = arr(b.rows, 'table.rows', LIMITS.items).map((r) => {
|
|
75
|
+
const row = arr(r, 'table row', columns.length).map((c) => str(c, 'table cell'));
|
|
76
|
+
return row;
|
|
77
|
+
});
|
|
78
|
+
return { type: 'table', columns, rows };
|
|
79
|
+
}
|
|
80
|
+
case 'text':
|
|
81
|
+
return { type: 'text', text: str(b.text, 'text') };
|
|
82
|
+
case 'link': {
|
|
83
|
+
const href = str(b.href, 'link.href');
|
|
84
|
+
// Only http(s): no javascript:, data:, or relative tricks.
|
|
85
|
+
let url;
|
|
86
|
+
try {
|
|
87
|
+
url = new URL(href);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new Invalid('link.href must be an absolute URL');
|
|
91
|
+
}
|
|
92
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
93
|
+
throw new Invalid('link.href must be http(s)');
|
|
94
|
+
return { type: 'link', text: str(b.text, 'link.text'), href: url.href };
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
throw new Invalid(`unknown block type ${JSON.stringify(b.type)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Validate a piece's `ui --json` answer. Returns the sections, or why it was refused. */
|
|
101
|
+
export function parseSections(value) {
|
|
102
|
+
try {
|
|
103
|
+
const top = obj(value, 'answer');
|
|
104
|
+
return arr(top.sections, 'sections', LIMITS.sections).map((s) => {
|
|
105
|
+
const o = obj(s, 'section');
|
|
106
|
+
const id = str(o.id, 'section.id');
|
|
107
|
+
if (!ID.test(id))
|
|
108
|
+
throw new Invalid(`section.id ${JSON.stringify(id)} must be lowercase letters, digits and dashes`);
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
title: str(o.title, 'section.title'),
|
|
112
|
+
blocks: arr(o.blocks, 'section.blocks', LIMITS.blocks).map(block),
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof Invalid)
|
|
118
|
+
return err.message;
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbarket/loomai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "MCP server for persistent AI agent identity and memory. Single-file sqlite-vec + local ONNX embeddings; zero external services.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|