@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 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.0...HEAD
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 — `/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
@@ -47,8 +52,19 @@ const SECURITY_HEADERS = {
47
52
  'referrer-policy': 'no-referrer',
48
53
  'cache-control': 'no-store',
49
54
  };
50
- export function isUiPath(pathname) {
51
- return pathname === '/ui' || pathname.startsWith('/ui/');
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: `/ui/p/${p.piece}`, current: current === `p/${p.piece}`, piece: true }))
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: '/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 },
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 ? `/ui${path ? `/${path}` : ''}` : `/ui/p/${source.id}${path ? `/${path}` : ''}`;
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 /ui request. The caller has already matched isUiPath. */
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 === '/ui/login') {
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, '/ui', {
273
- 'set-cookie': `${COOKIE}=${newSession(match)}; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
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 === '/ui/logout') {
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, '/ui/login', { 'set-cookie': `${COOKIE}=; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=0` });
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, '/ui/login');
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('/ui/act/')) {
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('/ui/act/'.length);
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 === '/ui/pieces.json') {
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 === '/ui' || pathname === '/ui/') {
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('/ui/p/')) {
376
- const rest = pathname.slice('/ui/p/'.length);
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('/ui/'.length)) ?? '\0';
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, '&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.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",