@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 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.0...HEAD
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 — `/ui`
386
+ ### loom's page
387
387
 
388
- The full-scope daemon serves a web page at `/ui`. **Home** is the tape:
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 `/ui/pieces.json` for the pieces' manifests). The page runs no script
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, isUiPath } from '../ui/handler.js';
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
- // /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.
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 (isUiPath(uiPath)) {
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, {
@@ -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
- export declare function isUiPath(pathname: string): boolean;
10
- /** Handle a /ui request. The caller has already matched isUiPath. */
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>;
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * loom's web UI on the HTTP daemon (full scope only).
3
3
  *
4
- * GET /ui Home: the tape, tiles, "Your call"
5
- * GET /ui/mind, /ui/people[/<slug>], /ui/memory/<ref> loom's own pages
6
- * GET /ui/p/<piece>[/<path>] a piece's page
7
- * GET /ui/pieces.json the installed pieces' manifests, for scripts
8
- * POST /ui/act/<source> an action (source = loom or a piece)
9
- * GET/POST /ui/login, POST /ui/logout
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
- 'referrer-policy': 'no-referrer',
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
- export function isUiPath(pathname) {
51
- return pathname === '/ui' || pathname.startsWith('/ui/');
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: `/ui/p/${p.piece}`, current: current === `p/${p.piece}`, piece: true }))
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: '/ui', current: current === '', piece: false },
217
- { label: 'Mind', href: '/ui/mind', current: current === 'mind', piece: false },
218
- { label: 'People', href: '/ui/people', current: current === 'people', piece: false },
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 ? `/ui${path ? `/${path}` : ''}` : `/ui/p/${source.id}${path ? `/${path}` : ''}`;
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 /ui request. The caller has already matched isUiPath. */
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 === '/ui/login') {
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, '/ui', {
273
- 'set-cookie': `${COOKIE}=${newSession(match)}; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
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 === '/ui/logout') {
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, '/ui/login', { 'set-cookie': `${COOKIE}=; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=0` });
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, '/ui/login');
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('/ui/act/')) {
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('/ui/act/'.length);
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 === '/ui/pieces.json') {
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 === '/ui' || pathname === '/ui/') {
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('/ui/p/')) {
376
- const rest = pathname.slice('/ui/p/'.length);
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('/ui/'.length)) ?? '\0';
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, '&quot;')
8
8
  .replace(/'/g, '&#39;');
9
9
  }
10
- export const CORE = { id: 'loom', base: '/ui/' };
11
- export const pieceSource = (piece) => ({ id: piece, base: `/ui/p/${piece}/` });
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 ? '/ui' : base.replace(/\/$/, '')) : base + s.path;
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="/ui/act/${esc(ctx.source.id)}">${hiddenInputs({ _csrf: ctx.csrf, _action: action, _path: back })}${inner}</form>`;
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 = ctx.source.base === CORE.base && ctx.path === '' ? '/ui' : pageHref(ctx.path, ctx.source);
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="/ui"><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>` +
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="/ui/logout"><input type="hidden" name="_csrf" value="${esc(c.csrf)}"><button type="submit">Sign out</button></form></div></div></header>`;
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="/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);
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.0",
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",