@jbarket/loomai 0.9.0 → 0.9.1
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 +12 -1
- package/README.md +6 -3
- package/dist/transport/http-server.js +5 -5
- package/dist/ui/handler.d.ts +3 -2
- package/dist/ui/handler.js +52 -27
- package/dist/ui/render.js +8 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.9.1] - 2026-10-24
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **loom's page is at the root.** `/`, `/mind`, `/people`, `/memory/<ref>`,
|
|
20
|
+
`/p/<piece>/…`, `/act/<source>`, `/login`, `/logout` and `/pieces.json`.
|
|
21
|
+
The old `/ui/…` addresses redirect (308) to the same page. MCP still
|
|
22
|
+
answers everywhere else, and at `/` for a POST, a request with an MCP
|
|
23
|
+
session, or an event-stream GET. The session cookie's path is `/`.
|
|
24
|
+
|
|
15
25
|
## [0.9.0] - 2026-10-24
|
|
16
26
|
|
|
17
27
|
### Changed
|
|
@@ -646,7 +656,8 @@ Initial public release.
|
|
|
646
656
|
stack and all added external-service dependencies or operational
|
|
647
657
|
overhead.
|
|
648
658
|
|
|
649
|
-
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.9.
|
|
659
|
+
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.9.1...HEAD
|
|
660
|
+
[0.9.1]: https://github.com/sleepunit-agents/loom/compare/v0.9.0...v0.9.1
|
|
650
661
|
[0.9.0]: https://github.com/sleepunit-agents/loom/compare/v0.8.0...v0.9.0
|
|
651
662
|
[0.8.0]: https://github.com/sleepunit-agents/loom/compare/v0.7.0...v0.8.0
|
|
652
663
|
[0.7.0]: https://github.com/sleepunit-agents/loom/compare/v0.6.0...v0.7.0
|
package/README.md
CHANGED
|
@@ -383,9 +383,12 @@ 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
|
|
386
|
+
### loom's page
|
|
387
387
|
|
|
388
|
-
The full-scope daemon serves a web page at
|
|
388
|
+
The full-scope daemon serves a web page at its root (`/`; the old `/ui/…`
|
|
389
|
+
addresses redirect). MCP shares the root: a POST, a request with an MCP
|
|
390
|
+
session or an event-stream GET is MCP's, a plain browser GET is the page.
|
|
391
|
+
**Home** is the tape:
|
|
389
392
|
what every body did in the last day, by hour and by where it happened, with
|
|
390
393
|
top of mind and the people cards that need a look beside it. **Mind** lists
|
|
391
394
|
every memory by heat and says why each one has it, which is how you find a
|
|
@@ -409,7 +412,7 @@ until the daemon restarts. Every form carries an anti-forgery token bound to
|
|
|
409
412
|
that session, a cross-site post is refused, and another site can link to the
|
|
410
413
|
page but not embed or fetch it. A form's fields go to the piece on stdin, and loom never logs, keeps
|
|
411
414
|
or puts them in a URL. Scripts can send the bearer header instead (for
|
|
412
|
-
example `/
|
|
415
|
+
example `/pieces.json` for the pieces' manifests). The page runs no script
|
|
413
416
|
and is served under a CSP that forbids script and framing. Pieces never
|
|
414
417
|
receive loom's bearer tokens.
|
|
415
418
|
|
|
@@ -32,7 +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,
|
|
35
|
+
import { handleUi, isUiRequest } from '../ui/handler.js';
|
|
36
36
|
import { assertSafeBind, checkBearer, checkBearerMulti, checkPayloadSize, DEFAULT_MAX_BODY_BYTES, } from './guards.js';
|
|
37
37
|
/**
|
|
38
38
|
* SSE keep-alive: the SDK opens the standalone GET stream but sends no
|
|
@@ -197,11 +197,11 @@ export async function startHttpServer(opts) {
|
|
|
197
197
|
}
|
|
198
198
|
const http = createServer((req, res) => {
|
|
199
199
|
void (async () => {
|
|
200
|
-
//
|
|
201
|
-
// or the bearer header), so it comes before the bearer gate.
|
|
202
|
-
// only: a scoped server has no memory to show.
|
|
200
|
+
// loom's web page (src/ui), at the root. Its own auth (a session from
|
|
201
|
+
// the bearer, or the bearer header), so it comes before the bearer gate.
|
|
202
|
+
// Full scope only: a scoped server has no memory to show.
|
|
203
203
|
const uiPath = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
204
|
-
if (
|
|
204
|
+
if (isUiRequest(req, uiPath)) {
|
|
205
205
|
if (opts.createServer)
|
|
206
206
|
return sendError(res, 404, 'not-found: this server has no UI');
|
|
207
207
|
return handleUi(req, res, uiPath, {
|
package/dist/ui/handler.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface UiOptions {
|
|
|
6
6
|
/** Env for piece discovery and piece processes (PATH decides which pieces exist). */
|
|
7
7
|
env: NodeJS.ProcessEnv;
|
|
8
8
|
}
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/** Whether a request is for the page (see the header for how `/` is shared with MCP). */
|
|
10
|
+
export declare function isUiRequest(req: IncomingMessage, pathname: string): boolean;
|
|
11
|
+
/** Handle a request for the page. The caller has already matched isUiRequest. */
|
|
11
12
|
export declare function handleUi(req: IncomingMessage, res: ServerResponse, pathname: string, opts: UiOptions): Promise<void>;
|
package/dist/ui/handler.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* loom's web UI on the HTTP daemon (full scope only).
|
|
3
3
|
*
|
|
4
|
-
* GET /
|
|
5
|
-
* GET /
|
|
6
|
-
* GET /
|
|
7
|
-
* GET /
|
|
8
|
-
* POST /
|
|
9
|
-
* GET/POST /
|
|
4
|
+
* GET / Home: the tape, tiles, "Your call"
|
|
5
|
+
* GET /mind, /people[/<slug>], /memory/<ref> loom's own pages
|
|
6
|
+
* GET /p/<piece>[/<path>] a piece's page
|
|
7
|
+
* GET /pieces.json the installed pieces' manifests, for scripts
|
|
8
|
+
* POST /act/<source> an action (source = loom or a piece)
|
|
9
|
+
* GET/POST /login, POST /logout
|
|
10
|
+
* /ui/… the old address: redirects to the same page at the root
|
|
11
|
+
*
|
|
12
|
+
* The page shares the daemon with MCP, which answers on any other path. `/`
|
|
13
|
+
* is the page only for a plain browser GET: a POST, a request with an MCP
|
|
14
|
+
* session, or one asking for an event stream is MCP's.
|
|
10
15
|
*
|
|
11
16
|
* Auth follows the daemon's bearer token, and the UI needs one: a daemon with
|
|
12
17
|
* no token serves no /ui (like /cli), because an open page would be readable
|
|
@@ -47,8 +52,19 @@ const SECURITY_HEADERS = {
|
|
|
47
52
|
'referrer-policy': 'no-referrer',
|
|
48
53
|
'cache-control': 'no-store',
|
|
49
54
|
};
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
const PAGE_ROOTS = ['/login', '/logout', '/pieces.json', '/mind', '/people', '/memory', '/p', '/act'];
|
|
56
|
+
/** Whether a request is for the page (see the header for how `/` is shared with MCP). */
|
|
57
|
+
export function isUiRequest(req, pathname) {
|
|
58
|
+
if (pathname === '/ui' || pathname.startsWith('/ui/'))
|
|
59
|
+
return true;
|
|
60
|
+
if (pathname === '/') {
|
|
61
|
+
if (req.method !== 'GET' && req.method !== 'HEAD')
|
|
62
|
+
return false;
|
|
63
|
+
if (req.headers['mcp-session-id'] !== undefined)
|
|
64
|
+
return false;
|
|
65
|
+
return !/text\/event-stream/i.test(req.headers.accept ?? '');
|
|
66
|
+
}
|
|
67
|
+
return PAGE_ROOTS.some((r) => pathname === r || pathname.startsWith(`${r}/`));
|
|
52
68
|
}
|
|
53
69
|
const digest = (s) => createHash('sha256').update(s).digest();
|
|
54
70
|
const sessions = new Map();
|
|
@@ -208,14 +224,14 @@ function hasPages(p) {
|
|
|
208
224
|
}
|
|
209
225
|
function chrome(opts, pieces, skipped, current, csrf, flash) {
|
|
210
226
|
const tabs = pieces.filter(hasPages)
|
|
211
|
-
.map((p) => ({ label: p.manifest.tab?.label ?? p.piece, href: `/
|
|
227
|
+
.map((p) => ({ label: p.manifest.tab?.label ?? p.piece, href: `/p/${p.piece}`, current: current === `p/${p.piece}`, piece: true }))
|
|
212
228
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
213
229
|
return {
|
|
214
230
|
agent: agentName(opts.contextDir),
|
|
215
231
|
nav: [
|
|
216
|
-
{ label: 'Home', href: '/
|
|
217
|
-
{ label: 'Mind', href: '/
|
|
218
|
-
{ label: 'People', href: '/
|
|
232
|
+
{ label: 'Home', href: '/', current: current === '', piece: false },
|
|
233
|
+
{ label: 'Mind', href: '/mind', current: current === 'mind', piece: false },
|
|
234
|
+
{ label: 'People', href: '/people', current: current === 'people', piece: false },
|
|
219
235
|
...tabs,
|
|
220
236
|
],
|
|
221
237
|
clock: clock(),
|
|
@@ -230,7 +246,7 @@ function landing(source, result, back) {
|
|
|
230
246
|
const core = s?.core || source.id === 'loom';
|
|
231
247
|
const path = s?.path ?? '';
|
|
232
248
|
const q = s ? new URLSearchParams(s.query).toString() : '';
|
|
233
|
-
const base = core ?
|
|
249
|
+
const base = core ? `/${path}` : `/p/${source.id}${path ? `/${path}` : ''}`;
|
|
234
250
|
return q ? `${base}?${q}` : base;
|
|
235
251
|
}
|
|
236
252
|
function withFlash(to, id) {
|
|
@@ -246,15 +262,24 @@ function decode(s) {
|
|
|
246
262
|
return null;
|
|
247
263
|
}
|
|
248
264
|
}
|
|
249
|
-
/** Handle a
|
|
265
|
+
/** Handle a request for the page. The caller has already matched isUiRequest. */
|
|
250
266
|
export async function handleUi(req, res, pathname, opts) {
|
|
251
267
|
// No token, no page: an open /ui would be readable by any site that
|
|
252
268
|
// DNS-rebinds a name to the loopback address (the same rule as /cli).
|
|
253
269
|
if (opts.secrets.length === 0) {
|
|
254
270
|
return send(res, 403, 'text/plain', 'forbidden: loom\'s page requires LOOM_BEARER_TOKEN');
|
|
255
271
|
}
|
|
272
|
+
// The page used to live under /ui. Old links and bookmarks land on the same page.
|
|
273
|
+
if (pathname === '/ui' || pathname.startsWith('/ui/')) {
|
|
274
|
+
const to = new URL(req.url ?? pathname, 'http://loom.invalid');
|
|
275
|
+
// One leading slash, never two: `/ui//evil.example` must not become a
|
|
276
|
+
// protocol-relative `//evil.example`.
|
|
277
|
+
const rest = '/' + pathname.slice(3).replace(/^[/\\]+/, '');
|
|
278
|
+
res.writeHead(308, { location: rest + to.search, ...SECURITY_HEADERS });
|
|
279
|
+
return void res.end();
|
|
280
|
+
}
|
|
256
281
|
const secure = req.headers['x-forwarded-proto'] === 'https';
|
|
257
|
-
if (pathname === '/
|
|
282
|
+
if (pathname === '/login') {
|
|
258
283
|
// Unauthenticated: the login page names no agent.
|
|
259
284
|
if (req.method === 'GET')
|
|
260
285
|
return send(res, 200, 'text/html; charset=utf-8', renderLogin());
|
|
@@ -269,13 +294,13 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
269
294
|
const match = opts.secrets.find((s) => safeEq(token, s));
|
|
270
295
|
if (!match)
|
|
271
296
|
return send(res, 401, 'text/html; charset=utf-8', renderLogin('That token didn’t match.'));
|
|
272
|
-
return redirect(res, '/
|
|
273
|
-
'set-cookie': `${COOKIE}=${newSession(match)}; Path
|
|
297
|
+
return redirect(res, '/', {
|
|
298
|
+
'set-cookie': `${COOKIE}=${newSession(match)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
|
|
274
299
|
});
|
|
275
300
|
}
|
|
276
301
|
const who = whoIs(req, opts);
|
|
277
302
|
const csrf = who?.kind === 'session' ? csrfFor(who.cookie) : '';
|
|
278
|
-
if (pathname === '/
|
|
303
|
+
if (pathname === '/logout') {
|
|
279
304
|
if (req.method !== 'POST')
|
|
280
305
|
return send(res, 405, 'text/plain', 'method not allowed');
|
|
281
306
|
if (!sameOrigin(req))
|
|
@@ -284,22 +309,22 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
284
309
|
const c = readCookie(req, COOKIE);
|
|
285
310
|
if (c)
|
|
286
311
|
sessions.delete(c);
|
|
287
|
-
return redirect(res, '/
|
|
312
|
+
return redirect(res, '/login', { 'set-cookie': `${COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0` });
|
|
288
313
|
}
|
|
289
314
|
if (!who) {
|
|
290
315
|
if (pathname.endsWith('.json'))
|
|
291
316
|
return send(res, 401, 'application/json', JSON.stringify({ error: 'unauthorized' }));
|
|
292
317
|
if (req.method === 'GET')
|
|
293
|
-
return redirect(res, '/
|
|
318
|
+
return redirect(res, '/login');
|
|
294
319
|
return send(res, 401, 'text/plain', 'unauthorized');
|
|
295
320
|
}
|
|
296
321
|
const url = new URL(req.url ?? pathname, 'http://loom.invalid');
|
|
297
|
-
if (pathname.startsWith('/
|
|
322
|
+
if (pathname.startsWith('/act/')) {
|
|
298
323
|
if (req.method !== 'POST')
|
|
299
324
|
return send(res, 405, 'text/plain', 'method not allowed');
|
|
300
325
|
if (!sameOrigin(req))
|
|
301
326
|
return send(res, 403, 'text/plain', 'forbidden: cross-site request');
|
|
302
|
-
const sourceId = pathname.slice('/
|
|
327
|
+
const sourceId = pathname.slice('/act/'.length);
|
|
303
328
|
if (sourceId !== 'loom' && !ID.test(sourceId))
|
|
304
329
|
return send(res, 404, 'text/plain', 'not found');
|
|
305
330
|
const form = await readForm(req, ACT_MAX_BYTES);
|
|
@@ -350,7 +375,7 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
350
375
|
return send(res, 405, 'text/plain', 'method not allowed');
|
|
351
376
|
if (!allowedGet(req))
|
|
352
377
|
return send(res, 403, 'text/plain', 'forbidden: loom\'s page can only be opened, not embedded or fetched from another site');
|
|
353
|
-
if (pathname === '/
|
|
378
|
+
if (pathname === '/pieces.json') {
|
|
354
379
|
const m = await pieceManifests(opts.env);
|
|
355
380
|
return send(res, 200, 'application/json', JSON.stringify({
|
|
356
381
|
agent: agentName(opts.contextDir),
|
|
@@ -363,7 +388,7 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
363
388
|
const { pieces, skipped } = await pieceManifests(opts.env);
|
|
364
389
|
const skippedNames = skipped.map((s) => s.piece);
|
|
365
390
|
const html = (body, status = 200) => send(res, status, 'text/html; charset=utf-8', body);
|
|
366
|
-
if (pathname === '/
|
|
391
|
+
if (pathname === '/') {
|
|
367
392
|
const home = homePage(opts.contextDir, query);
|
|
368
393
|
const tiles = [
|
|
369
394
|
{ ...home.tile, source: CORE },
|
|
@@ -372,8 +397,8 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
372
397
|
const asks = pieces.flatMap((p) => p.manifest.asks.map((a) => ({ ...a, source: pieceSource(p.piece) })));
|
|
373
398
|
return html(renderHome(home.page, { source: CORE, path: '', query, csrf }, chrome(opts, pieces, skippedNames, '', csrf, flash), tiles, asks));
|
|
374
399
|
}
|
|
375
|
-
if (pathname.startsWith('/
|
|
376
|
-
const rest = pathname.slice('/
|
|
400
|
+
if (pathname.startsWith('/p/')) {
|
|
401
|
+
const rest = pathname.slice('/p/'.length);
|
|
377
402
|
const slash = rest.indexOf('/');
|
|
378
403
|
const piece = slash === -1 ? rest : rest.slice(0, slash);
|
|
379
404
|
const path = slash === -1 ? '' : decode(rest.slice(slash + 1));
|
|
@@ -388,7 +413,7 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
388
413
|
}
|
|
389
414
|
return html(renderPage(page, ctx, ch), page.title === 'Not found' && page.sections.length === 0 ? 404 : 200);
|
|
390
415
|
}
|
|
391
|
-
const path = decode(pathname.slice(
|
|
416
|
+
const path = decode(pathname.slice(1)) ?? '\0';
|
|
392
417
|
const top = path.split('/')[0];
|
|
393
418
|
const ch = chrome(opts, pieces, skippedNames, top, csrf, flash);
|
|
394
419
|
const page = validPath(path) ? corePage(opts.contextDir, path, query) : null;
|
package/dist/ui/render.js
CHANGED
|
@@ -7,8 +7,8 @@ export function esc(s) {
|
|
|
7
7
|
.replace(/"/g, '"')
|
|
8
8
|
.replace(/'/g, ''');
|
|
9
9
|
}
|
|
10
|
-
export const CORE = { id: 'loom', base: '/
|
|
11
|
-
export const pieceSource = (piece) => ({ id: piece, base: `/
|
|
10
|
+
export const CORE = { id: 'loom', base: '/' };
|
|
11
|
+
export const pieceSource = (piece) => ({ id: piece, base: `/p/${piece}/` });
|
|
12
12
|
function qs(query) {
|
|
13
13
|
const p = new URLSearchParams();
|
|
14
14
|
for (const [k, v] of Object.entries(query))
|
|
@@ -23,7 +23,7 @@ export function pageHref(link, source) {
|
|
|
23
23
|
if (!s)
|
|
24
24
|
return '#';
|
|
25
25
|
const base = s.core ? CORE.base : source.base;
|
|
26
|
-
const url = s.path === '' ? (base === CORE.base ? '/
|
|
26
|
+
const url = s.path === '' ? (base === CORE.base ? '/' : base.replace(/\/$/, '')) : base + s.path;
|
|
27
27
|
return url + qs(s.query);
|
|
28
28
|
}
|
|
29
29
|
const tone = (t) => (t ? ` tone-${t}` : '');
|
|
@@ -40,7 +40,7 @@ function hiddenInputs(fields) {
|
|
|
40
40
|
}
|
|
41
41
|
function actionForm(ctx, action, inner, cls) {
|
|
42
42
|
const back = ctx.path + qs(ctx.query);
|
|
43
|
-
return `<form class="${cls}" method="post" action="/
|
|
43
|
+
return `<form class="${cls}" method="post" action="/act/${esc(ctx.source.id)}">${hiddenInputs({ _csrf: ctx.csrf, _action: action, _path: back })}${inner}</form>`;
|
|
44
44
|
}
|
|
45
45
|
let fieldSeq = 0;
|
|
46
46
|
function renderField(f) {
|
|
@@ -94,7 +94,7 @@ function renderBlock(b, ctx) {
|
|
|
94
94
|
case 'filters': {
|
|
95
95
|
const current = ctx.query[b.param] ?? '';
|
|
96
96
|
return `<nav class="filters" aria-label="Filter">${b.options.map((o) => {
|
|
97
|
-
const href =
|
|
97
|
+
const href = pageHref(ctx.path, ctx.source);
|
|
98
98
|
const target = href + qs({ ...ctx.query, [b.param]: o.value });
|
|
99
99
|
const on = o.value === current;
|
|
100
100
|
return `<a class="pill${on ? ' on' : ''}" href="${esc(target)}"${on ? ' aria-current="true"' : ''}>${esc(o.label)}</a>`;
|
|
@@ -183,9 +183,9 @@ function chromeTop(c) {
|
|
|
183
183
|
const core = c.nav.filter((n) => !n.piece);
|
|
184
184
|
const pieces = c.nav.filter((n) => n.piece);
|
|
185
185
|
const tab = (n) => `<a href="${esc(n.href)}"${n.current ? ' aria-current="page"' : ''}>${esc(n.label)}</a>`;
|
|
186
|
-
return `<header class="top"><div class="top-in"><a class="brand" href="/
|
|
186
|
+
return `<header class="top"><div class="top-in"><a class="brand" href="/"><svg width="20" height="20" viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3 5h16M3 11h16M3 17h16"></path><path d="M7 2v18M15 2v18" opacity=".55"></path></svg>loom <span class="who">· ${esc(c.agent)}</span></a>` +
|
|
187
187
|
`<nav class="tabs" aria-label="Sections">${core.map(tab).join('')}${pieces.length ? `<span class="sep" aria-hidden="true"></span>${pieces.map(tab).join('')}` : ''}</nav>` +
|
|
188
|
-
`<div class="top-right"><span class="mono">${esc(c.clock)}</span><form class="inline" method="post" action="/
|
|
188
|
+
`<div class="top-right"><span class="mono">${esc(c.clock)}</span><form class="inline" method="post" action="/logout"><input type="hidden" name="_csrf" value="${esc(c.csrf)}"><button type="submit">Sign out</button></form></div></div></header>`;
|
|
189
189
|
}
|
|
190
190
|
function chromeFoot(c, refresh) {
|
|
191
191
|
return `<footer>${refresh ? '<span>Refreshes every minute.</span>' : ''}${c.skipped.length ? `<span>Pieces with no page: ${c.skipped.map(esc).join(', ')}</span>` : ''}</footer>`;
|
|
@@ -238,5 +238,5 @@ export function renderHome(page, ctx, chrome, tiles, asks) {
|
|
|
238
238
|
return document(chrome.agent, body, true);
|
|
239
239
|
}
|
|
240
240
|
export function renderLogin(error) {
|
|
241
|
-
return document('loom', `<form class="login" method="post" action="/
|
|
241
|
+
return document('loom', `<form class="login" method="post" action="/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);
|
|
242
242
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbarket/loomai",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
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",
|