@jbarket/loomai 0.9.0 → 0.9.2
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 +23 -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 +56 -28
- package/dist/ui/render.js +8 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.9.2] - 2026-10-24
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **Logging in to the page (and every form on it) failed in a browser with
|
|
20
|
+
"forbidden: cross-site request".** The page was served with
|
|
21
|
+
`Referrer-Policy: no-referrer`, under which browsers send `Origin: null` on
|
|
22
|
+
the page's own form posts, and the cross-site guard refuses that. It's
|
|
23
|
+
`same-origin` now, which still sends nothing to links that leave the page.
|
|
24
|
+
|
|
25
|
+
## [0.9.1] - 2026-10-24
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **loom's page is at the root.** `/`, `/mind`, `/people`, `/memory/<ref>`,
|
|
30
|
+
`/p/<piece>/…`, `/act/<source>`, `/login`, `/logout` and `/pieces.json`.
|
|
31
|
+
The old `/ui/…` addresses redirect (308) to the same page. MCP still
|
|
32
|
+
answers everywhere else, and at `/` for a POST, a request with an MCP
|
|
33
|
+
session, or an event-stream GET. The session cookie's path is `/`.
|
|
34
|
+
|
|
15
35
|
## [0.9.0] - 2026-10-24
|
|
16
36
|
|
|
17
37
|
### Changed
|
|
@@ -646,7 +666,9 @@ Initial public release.
|
|
|
646
666
|
stack and all added external-service dependencies or operational
|
|
647
667
|
overhead.
|
|
648
668
|
|
|
649
|
-
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.9.
|
|
669
|
+
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.9.2...HEAD
|
|
670
|
+
[0.9.2]: https://github.com/sleepunit-agents/loom/compare/v0.9.1...v0.9.2
|
|
671
|
+
[0.9.1]: https://github.com/sleepunit-agents/loom/compare/v0.9.0...v0.9.1
|
|
650
672
|
[0.9.0]: https://github.com/sleepunit-agents/loom/compare/v0.8.0...v0.9.0
|
|
651
673
|
[0.8.0]: https://github.com/sleepunit-agents/loom/compare/v0.7.0...v0.8.0
|
|
652
674
|
[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
|
|
@@ -44,11 +49,25 @@ const COOKIE_KEY = randomBytes(32);
|
|
|
44
49
|
const SECURITY_HEADERS = {
|
|
45
50
|
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
46
51
|
'x-content-type-options': 'nosniff',
|
|
47
|
-
|
|
52
|
+
// same-origin, not no-referrer: under no-referrer, browsers send `Origin:
|
|
53
|
+
// null` on the page's own form posts, which the cross-site guard has to
|
|
54
|
+
// refuse. same-origin still sends nothing to links that leave the page.
|
|
55
|
+
'referrer-policy': 'same-origin',
|
|
48
56
|
'cache-control': 'no-store',
|
|
49
57
|
};
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
const PAGE_ROOTS = ['/login', '/logout', '/pieces.json', '/mind', '/people', '/memory', '/p', '/act'];
|
|
59
|
+
/** Whether a request is for the page (see the header for how `/` is shared with MCP). */
|
|
60
|
+
export function isUiRequest(req, pathname) {
|
|
61
|
+
if (pathname === '/ui' || pathname.startsWith('/ui/'))
|
|
62
|
+
return true;
|
|
63
|
+
if (pathname === '/') {
|
|
64
|
+
if (req.method !== 'GET' && req.method !== 'HEAD')
|
|
65
|
+
return false;
|
|
66
|
+
if (req.headers['mcp-session-id'] !== undefined)
|
|
67
|
+
return false;
|
|
68
|
+
return !/text\/event-stream/i.test(req.headers.accept ?? '');
|
|
69
|
+
}
|
|
70
|
+
return PAGE_ROOTS.some((r) => pathname === r || pathname.startsWith(`${r}/`));
|
|
52
71
|
}
|
|
53
72
|
const digest = (s) => createHash('sha256').update(s).digest();
|
|
54
73
|
const sessions = new Map();
|
|
@@ -208,14 +227,14 @@ function hasPages(p) {
|
|
|
208
227
|
}
|
|
209
228
|
function chrome(opts, pieces, skipped, current, csrf, flash) {
|
|
210
229
|
const tabs = pieces.filter(hasPages)
|
|
211
|
-
.map((p) => ({ label: p.manifest.tab?.label ?? p.piece, href: `/
|
|
230
|
+
.map((p) => ({ label: p.manifest.tab?.label ?? p.piece, href: `/p/${p.piece}`, current: current === `p/${p.piece}`, piece: true }))
|
|
212
231
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
213
232
|
return {
|
|
214
233
|
agent: agentName(opts.contextDir),
|
|
215
234
|
nav: [
|
|
216
|
-
{ label: 'Home', href: '/
|
|
217
|
-
{ label: 'Mind', href: '/
|
|
218
|
-
{ label: 'People', href: '/
|
|
235
|
+
{ label: 'Home', href: '/', current: current === '', piece: false },
|
|
236
|
+
{ label: 'Mind', href: '/mind', current: current === 'mind', piece: false },
|
|
237
|
+
{ label: 'People', href: '/people', current: current === 'people', piece: false },
|
|
219
238
|
...tabs,
|
|
220
239
|
],
|
|
221
240
|
clock: clock(),
|
|
@@ -230,7 +249,7 @@ function landing(source, result, back) {
|
|
|
230
249
|
const core = s?.core || source.id === 'loom';
|
|
231
250
|
const path = s?.path ?? '';
|
|
232
251
|
const q = s ? new URLSearchParams(s.query).toString() : '';
|
|
233
|
-
const base = core ?
|
|
252
|
+
const base = core ? `/${path}` : `/p/${source.id}${path ? `/${path}` : ''}`;
|
|
234
253
|
return q ? `${base}?${q}` : base;
|
|
235
254
|
}
|
|
236
255
|
function withFlash(to, id) {
|
|
@@ -246,15 +265,24 @@ function decode(s) {
|
|
|
246
265
|
return null;
|
|
247
266
|
}
|
|
248
267
|
}
|
|
249
|
-
/** Handle a
|
|
268
|
+
/** Handle a request for the page. The caller has already matched isUiRequest. */
|
|
250
269
|
export async function handleUi(req, res, pathname, opts) {
|
|
251
270
|
// No token, no page: an open /ui would be readable by any site that
|
|
252
271
|
// DNS-rebinds a name to the loopback address (the same rule as /cli).
|
|
253
272
|
if (opts.secrets.length === 0) {
|
|
254
273
|
return send(res, 403, 'text/plain', 'forbidden: loom\'s page requires LOOM_BEARER_TOKEN');
|
|
255
274
|
}
|
|
275
|
+
// The page used to live under /ui. Old links and bookmarks land on the same page.
|
|
276
|
+
if (pathname === '/ui' || pathname.startsWith('/ui/')) {
|
|
277
|
+
const to = new URL(req.url ?? pathname, 'http://loom.invalid');
|
|
278
|
+
// One leading slash, never two: `/ui//evil.example` must not become a
|
|
279
|
+
// protocol-relative `//evil.example`.
|
|
280
|
+
const rest = '/' + pathname.slice(3).replace(/^[/\\]+/, '');
|
|
281
|
+
res.writeHead(308, { location: rest + to.search, ...SECURITY_HEADERS });
|
|
282
|
+
return void res.end();
|
|
283
|
+
}
|
|
256
284
|
const secure = req.headers['x-forwarded-proto'] === 'https';
|
|
257
|
-
if (pathname === '/
|
|
285
|
+
if (pathname === '/login') {
|
|
258
286
|
// Unauthenticated: the login page names no agent.
|
|
259
287
|
if (req.method === 'GET')
|
|
260
288
|
return send(res, 200, 'text/html; charset=utf-8', renderLogin());
|
|
@@ -269,13 +297,13 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
269
297
|
const match = opts.secrets.find((s) => safeEq(token, s));
|
|
270
298
|
if (!match)
|
|
271
299
|
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
|
|
300
|
+
return redirect(res, '/', {
|
|
301
|
+
'set-cookie': `${COOKIE}=${newSession(match)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
|
|
274
302
|
});
|
|
275
303
|
}
|
|
276
304
|
const who = whoIs(req, opts);
|
|
277
305
|
const csrf = who?.kind === 'session' ? csrfFor(who.cookie) : '';
|
|
278
|
-
if (pathname === '/
|
|
306
|
+
if (pathname === '/logout') {
|
|
279
307
|
if (req.method !== 'POST')
|
|
280
308
|
return send(res, 405, 'text/plain', 'method not allowed');
|
|
281
309
|
if (!sameOrigin(req))
|
|
@@ -284,22 +312,22 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
284
312
|
const c = readCookie(req, COOKIE);
|
|
285
313
|
if (c)
|
|
286
314
|
sessions.delete(c);
|
|
287
|
-
return redirect(res, '/
|
|
315
|
+
return redirect(res, '/login', { 'set-cookie': `${COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0` });
|
|
288
316
|
}
|
|
289
317
|
if (!who) {
|
|
290
318
|
if (pathname.endsWith('.json'))
|
|
291
319
|
return send(res, 401, 'application/json', JSON.stringify({ error: 'unauthorized' }));
|
|
292
320
|
if (req.method === 'GET')
|
|
293
|
-
return redirect(res, '/
|
|
321
|
+
return redirect(res, '/login');
|
|
294
322
|
return send(res, 401, 'text/plain', 'unauthorized');
|
|
295
323
|
}
|
|
296
324
|
const url = new URL(req.url ?? pathname, 'http://loom.invalid');
|
|
297
|
-
if (pathname.startsWith('/
|
|
325
|
+
if (pathname.startsWith('/act/')) {
|
|
298
326
|
if (req.method !== 'POST')
|
|
299
327
|
return send(res, 405, 'text/plain', 'method not allowed');
|
|
300
328
|
if (!sameOrigin(req))
|
|
301
329
|
return send(res, 403, 'text/plain', 'forbidden: cross-site request');
|
|
302
|
-
const sourceId = pathname.slice('/
|
|
330
|
+
const sourceId = pathname.slice('/act/'.length);
|
|
303
331
|
if (sourceId !== 'loom' && !ID.test(sourceId))
|
|
304
332
|
return send(res, 404, 'text/plain', 'not found');
|
|
305
333
|
const form = await readForm(req, ACT_MAX_BYTES);
|
|
@@ -350,7 +378,7 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
350
378
|
return send(res, 405, 'text/plain', 'method not allowed');
|
|
351
379
|
if (!allowedGet(req))
|
|
352
380
|
return send(res, 403, 'text/plain', 'forbidden: loom\'s page can only be opened, not embedded or fetched from another site');
|
|
353
|
-
if (pathname === '/
|
|
381
|
+
if (pathname === '/pieces.json') {
|
|
354
382
|
const m = await pieceManifests(opts.env);
|
|
355
383
|
return send(res, 200, 'application/json', JSON.stringify({
|
|
356
384
|
agent: agentName(opts.contextDir),
|
|
@@ -363,7 +391,7 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
363
391
|
const { pieces, skipped } = await pieceManifests(opts.env);
|
|
364
392
|
const skippedNames = skipped.map((s) => s.piece);
|
|
365
393
|
const html = (body, status = 200) => send(res, status, 'text/html; charset=utf-8', body);
|
|
366
|
-
if (pathname === '/
|
|
394
|
+
if (pathname === '/') {
|
|
367
395
|
const home = homePage(opts.contextDir, query);
|
|
368
396
|
const tiles = [
|
|
369
397
|
{ ...home.tile, source: CORE },
|
|
@@ -372,8 +400,8 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
372
400
|
const asks = pieces.flatMap((p) => p.manifest.asks.map((a) => ({ ...a, source: pieceSource(p.piece) })));
|
|
373
401
|
return html(renderHome(home.page, { source: CORE, path: '', query, csrf }, chrome(opts, pieces, skippedNames, '', csrf, flash), tiles, asks));
|
|
374
402
|
}
|
|
375
|
-
if (pathname.startsWith('/
|
|
376
|
-
const rest = pathname.slice('/
|
|
403
|
+
if (pathname.startsWith('/p/')) {
|
|
404
|
+
const rest = pathname.slice('/p/'.length);
|
|
377
405
|
const slash = rest.indexOf('/');
|
|
378
406
|
const piece = slash === -1 ? rest : rest.slice(0, slash);
|
|
379
407
|
const path = slash === -1 ? '' : decode(rest.slice(slash + 1));
|
|
@@ -388,7 +416,7 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
388
416
|
}
|
|
389
417
|
return html(renderPage(page, ctx, ch), page.title === 'Not found' && page.sections.length === 0 ? 404 : 200);
|
|
390
418
|
}
|
|
391
|
-
const path = decode(pathname.slice(
|
|
419
|
+
const path = decode(pathname.slice(1)) ?? '\0';
|
|
392
420
|
const top = path.split('/')[0];
|
|
393
421
|
const ch = chrome(opts, pieces, skippedNames, top, csrf, flash);
|
|
394
422
|
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.2",
|
|
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",
|