drafted 1.24.6 → 1.25.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/mcp/server.mjs CHANGED
@@ -15,6 +15,7 @@ import { join, dirname, basename, extname, resolve } from 'path';
15
15
  import { homedir, platform, release as osRelease, arch as osArch } from 'os';
16
16
  import { detectGwsStatus } from './gws-status.mjs';
17
17
  import { fileURLToPath } from 'url';
18
+ import { splitRange } from '../src/shared/commit-range.mjs';
18
19
  import { AsyncLocalStorage } from 'node:async_hooks';
19
20
  import { z } from 'zod';
20
21
  import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
@@ -3359,7 +3360,8 @@ tool('minion', {
3359
3360
  context: z.object({
3360
3361
  frames: z.array(z.string()).describe('frame ids in the SAME project, max 20'),
3361
3362
  }).optional().describe('[create|update] the brief pack the Minion may read and answer from (read_brief). DISCLOSED to every visitor — include only what they may see. The Minion cannot read anything else in the org.'),
3362
- visitorDailyLimit: z.number().optional().describe('[create|update] turns per visitor per UTC day (default 30); dailyLimit is shared by all visitors'),
3363
+ weeklyLimit: z.number().optional().describe('[create|update] turns per ISO week (UTC, resets Monday) shared by all visitors (default 350); the org also sets it in Settings → Minions, next to measured usage'),
3364
+ visitorWeeklyLimit: z.number().optional().describe('[create|update] turns per visitor per ISO week (default 210)'),
3363
3365
  limit: z.number().optional().describe('[list] max results per page (default 25, max 100)'),
3364
3366
  offset: z.number().optional().describe('[list] skip N results for pagination (default 0)'),
3365
3367
  compact: z.boolean().optional().describe('[list] return only {id,slug,name,enabled,projectId} per Minion'),
@@ -3388,7 +3390,7 @@ tool('minion', {
3388
3390
  }
3389
3391
  case 'create': {
3390
3392
  // projectId lives in the BODY (the POST route reads body, ignores the query).
3391
- const { name, description, target, checklist, output, enabled, context, visitorDailyLimit } = args;
3393
+ const { name, description, target, checklist, output, enabled, context, weeklyLimit, visitorWeeklyLimit } = args;
3392
3394
  if (!name || !target || !Array.isArray(checklist) || !output) {
3393
3395
  throw new Error('name, target, checklist[], and output are required for action=create');
3394
3396
  }
@@ -3396,14 +3398,15 @@ tool('minion', {
3396
3398
  if (description !== undefined) body.description = description;
3397
3399
  if (enabled !== undefined) body.enabled = enabled;
3398
3400
  if (context !== undefined) body.context = context;
3399
- if (visitorDailyLimit !== undefined) body.visitorDailyLimit = visitorDailyLimit;
3401
+ if (weeklyLimit !== undefined) body.weeklyLimit = weeklyLimit;
3402
+ if (visitorWeeklyLimit !== undefined) body.visitorWeeklyLimit = visitorWeeklyLimit;
3400
3403
  const created = await scoped(() => api('POST', '/api/minions', body));
3401
3404
  return ok({ ...created, projectPath });
3402
3405
  }
3403
3406
  case 'update': {
3404
3407
  if (!args.id) throw new Error('id is required for action=update');
3405
3408
  const body = {};
3406
- for (const k of ['name', 'description', 'target', 'checklist', 'output', 'enabled', 'context', 'visitorDailyLimit']) {
3409
+ for (const k of ['name', 'description', 'target', 'checklist', 'output', 'enabled', 'context', 'weeklyLimit', 'visitorWeeklyLimit']) {
3407
3410
  if (args[k] !== undefined) body[k] = args[k];
3408
3411
  }
3409
3412
  if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
@@ -3459,8 +3462,8 @@ server.resource('info', 'drafted://info', {
3459
3462
  };
3460
3463
  });
3461
3464
 
3462
- tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: todo, in_progress, in_review, done, canceled -- the Linear set minus Backlog, which this product deliberately does not have (a Backlog column only ever accumulates). The names this system used to store -- open, scheduled, needs_review, needs_decision, failed, backlog -- are still ACCEPTED and normalised (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `link` / `unlink` / `links` (ONE link graph across wiki pages, skills, frames, tasks, projects and urls: `link` relates any wiki page, skill or frame to any other node or to a url; `links` on a wiki page, skill or frame lists its edges plus its backlinks from every kind of node, and on a project path lists the tasks linked to that project. Links written IN content are edges too, parsed on every write — markdown links (relative wiki links, or `/o/<org>/...` addresses from any wiki page, skill or frame), HTML `<a href>` addresses, OKF `related:` frontmatter; a bare address in prose is not a link; `unlink` removes only declared ones, so edit the content to drop a written link. A link is stored by ID, so `mv` never breaks it; a link to a page not written yet is stored by path and connects when the page is written). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, `.skillinstall/` is stripped on skill push. ANCHORED SKILLS are enforced by the server on every content write (frames, wiki pages, skills) in the scope they are anchored to — an org, a folder or a project: a write from a session that has not read them is refused with HTTP 428 `anchor_gate`, and the refusal carries the rule text and the `fs(read)` path of each skill; read them, then retry. Anchored FRAMES are not enforced — they are suggested reading only.\n\nLARGE WRITES (chunked): when the content is too big for ONE tool call — a ~1 MB app-frame .html, or any text write from a remote transport, which has no access to a local file — write it in ordered chunks: fs(write, path=…, chunkIndex=0, chunkTotal=N, content=<slice 0>), then 1, … N-1. Each chunk is a contiguous slice of the final file and every chunk carries the same chunkTotal. NOTHING is written until the final chunk (N-1), which performs the one ordinary write: no half-uploaded frame is ever served, no intermediate version is created, app-frame detection sees the whole document, and expectedUpdatedAt is enforced on that single write. A missing index fails at the final chunk with the missing indexes named; a duplicate index fails loudly; chunk 0 restarts a broken upload. Prefer a handful of large chunks (~64–256 KB each) over hundreds of tiny ones. Reading a big frame back is paged — see the read action chars window and nextChars.', {
3463
- action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
3465
+ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: todo, in_progress, in_review, done, canceled -- the Linear set minus Backlog, which this product deliberately does not have (a Backlog column only ever accumulates). The names this system used to store -- open, scheduled, needs_review, needs_decision, failed, backlog -- are still ACCEPTED and normalised (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `link` / `unlink` / `links` (ONE link graph across wiki pages, skills, frames, tasks, projects and urls: `link` relates any wiki page, skill or frame to any other node or to a url; `links` on a wiki page, skill or frame lists its edges plus its backlinks from every kind of node, and on a project path lists the tasks linked to that project. Links written IN content are edges too, parsed on every write — markdown links (relative wiki links, or `/o/<org>/...` addresses from any wiki page, skill or frame), HTML `<a href>` addresses, OKF `related:` frontmatter; a bare address in prose is not a link; `unlink` removes only declared ones, so edit the content to drop a written link. A link is stored by ID, so `mv` never breaks it; a link to a page not written yet is stored by path and connects when the page is written). `status` / `log` / `diff` read a frame\'s version history — `status` gives the head commit plus every Minion / wiki page / frame that records being built from it and how far behind it is, `log` lists commits (author, person vs agent, message, how many writes were squashed into each), and `diff` returns a text-level change summary per content type (Excalidraw element labels, markdown/HTML hunks) — never the full content, always capped. `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, `.skillinstall/` is stripped on skill push. ANCHORED SKILLS are enforced by the server on every content write (frames, wiki pages, skills) in the scope they are anchored to — an org, a folder or a project: a write from a session that has not read them is refused with HTTP 428 `anchor_gate`, and the refusal carries the rule text and the `fs(read)` path of each skill; read them, then retry. Anchored FRAMES are not enforced — they are suggested reading only.\n\nLARGE WRITES (chunked): when the content is too big for ONE tool call — a ~1 MB app-frame .html, or any text write from a remote transport, which has no access to a local file — write it in ordered chunks: fs(write, path=…, chunkIndex=0, chunkTotal=N, content=<slice 0>), then 1, … N-1. Each chunk is a contiguous slice of the final file and every chunk carries the same chunkTotal. NOTHING is written until the final chunk (N-1), which performs the one ordinary write: no half-uploaded frame is ever served, no intermediate version is created, app-frame detection sees the whole document, and expectedUpdatedAt is enforced on that single write. A missing index fails at the final chunk with the missing indexes named; a duplicate index fails loudly; chunk 0 restarts a broken upload. Prefer a handful of large chunks (~64–256 KB each) over hundreds of tiny ones. Reading a big frame back is paged — see the read action chars window and nextChars.', {
3466
+ action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links', 'status', 'log', 'diff']).describe('Filesystem verb. `status` / `log` / `diff` read a frame\'s version history (see their descriptions in the tool text).'),
3464
3467
  path: z.string().describe('Drafted path: /o/<org>[/<folder…>]/wiki/... | .../skills/... | .../tasks/... | .../projects/... — a folder chain may precede any root (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
3465
3468
  to: z.string().optional().describe('[mv] destination — a frame path for a frame, a container path (/<layer> or /<layer>/<lane>, optionally prefixed /o/<org>/projects/<other-project>/… to move across projects) for a lane or layer, or the NEW NAME when the source path is a project; [link/unlink] target path — a wiki page (/o/<org>[/<folder>]/wiki/<page>), a skill (/o/<org>/skills/<slug>), a task, a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
3466
3469
  url: z.string().optional().describe('[link/unlink] external target URL, instead of `to` (a link is internal-by-id OR external-by-url, never both)'),
@@ -3479,6 +3482,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3479
3482
  readme: z.string().optional().describe('[write] README.md markdown for a skill at /o/<org>/skills/<slug> — optional on both create and update. Stored as a supporting skill file so the skill directory renders a landing page on GitHub when exported; a skill created without one is returned with a warning, never rejected. Equivalent to a later fs(write, path="/o/<org>/skills/<slug>/README.md").'),
3480
3483
  position: z.number().int().min(0).optional().describe('[mv] on a LAYER path, no `to`: move the layer to this 0-based index in the project\'s layer order (0 = first; `ls` of the project shows the order). A layer born from a first write is appended last, so use this to pin one first.'),
3481
3484
  recursive: z.boolean().optional().describe('[ls] recurse into subdirectories'),
3485
+ range: z.string().optional().describe('[log/diff] git-style commit range over a frame\'s history: `v41..head`, `<sha>..<sha>`, `head`, or a bare `v41`/sha prefix (log: from there to head; diff: that commit against head). Defaults to the whole history for log and the last two commits for diff.'),
3482
3486
  chars: z.string().optional().describe('[read] character window "start-end" (0-based, end exclusive; at most 60000 per call). A frame longer than that is returned as its first page with `totalChars` and `nextChars` — pass `nextChars` back to read on. Use it for minified/single-line HTML, which `lines` cannot split.'),
3483
3487
  lines: z.string().optional().describe('[read] line range (e.g. "1-50") — partial read; content is hashline-annotated so a later edit stays surgical'),
3484
3488
  pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
@@ -4533,6 +4537,41 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4533
4537
  const result = await api('POST', '/api/fs/mv', { from, to: toPath, ...(toProjectId ? { toProjectId } : {}) }, orgHeader);
4534
4538
  return ok(result);
4535
4539
  }
4540
+ case 'status': {
4541
+ if (!filename) return err(new Error('status needs a frame path (a project path has no history)'));
4542
+ const relPath = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
4543
+ // projectId MUST ride the query: X-Drafted-Project is not a selector (it only
4544
+ // accepts "none"), so without this the read resolves against whatever project
4545
+ // the session's cursor happens to hold — and a same-named frame there answers
4546
+ // for this one.
4547
+ const pid = getState().projectId;
4548
+ const result = await api('GET', `/api/fs/versions/status?path=${encodeURIComponent(relPath)}${pid ? '&projectId=' + encodeURIComponent(pid) : ''}`, undefined, orgHeader);
4549
+ return ok(result);
4550
+ }
4551
+ case 'log': {
4552
+ if (!filename) return err(new Error('log needs a frame path'));
4553
+ const relPath = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
4554
+ const [from, to] = splitRange(args.range);
4555
+ const qs = new URLSearchParams({ path: relPath });
4556
+ if (from) qs.set('from', from);
4557
+ if (to) qs.set('to', to);
4558
+ const pid = getState().projectId;
4559
+ if (pid) qs.set('projectId', pid);
4560
+ const result = await api('GET', `/api/fs/versions/log?${qs.toString()}`, undefined, orgHeader);
4561
+ return ok(result);
4562
+ }
4563
+ case 'diff': {
4564
+ if (!filename) return err(new Error('diff needs a frame path'));
4565
+ const relPath = lane ? `/${layer}/${lane}/${filename}` : `/${layer}/${filename}`;
4566
+ const [from, to] = splitRange(args.range);
4567
+ const qs = new URLSearchParams({ path: relPath });
4568
+ if (from) qs.set('from', from);
4569
+ if (to) qs.set('to', to);
4570
+ const pid = getState().projectId;
4571
+ if (pid) qs.set('projectId', pid);
4572
+ const result = await api('GET', `/api/fs/versions/diff?${qs.toString()}`, undefined, orgHeader);
4573
+ return ok(result);
4574
+ }
4536
4575
  case 'rm': {
4537
4576
  // Project-level rm = move to Trash (soft delete). Frame/lane rm still
4538
4577
  // deletes those items outright, but a whole project is never
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.24.6",
3
+ "version": "1.25.0",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "drafted": "./cli/drafted.mjs",
8
- "drafted-mcp": "./mcp/server.mjs"
7
+ "drafted": "cli/drafted.mjs",
8
+ "drafted-mcp": "mcp/server.mjs"
9
9
  },
10
10
  "files": [
11
11
  "cli/",
@@ -0,0 +1,49 @@
1
+ /**
2
+ * AI Act Art. 50 — ONE wording for "a machine wrote this", shared by every surface.
3
+ *
4
+ * Art. 50(1) wants a person to know they are looking at artificially generated
5
+ * content, and 50(2)-adjacent chat practices want it before they talk to it. A label
6
+ * that is spelled out is the requirement; a colour, an icon alone or a tooltip is not.
7
+ * So the words live here and nowhere else — the canvas frame header, the wiki footer,
8
+ * the skill hero, the public/share views and the Minion chat all render these, and
9
+ * `tests/ai-artifact-labelling.test.ts` fails if two surfaces drift apart.
10
+ *
11
+ * NOT a status and NOT a sharing state: callers render it as plain muted text (the
12
+ * AGENTS.md rule — colour means work status, sharing is an exempted icon+word, so an
13
+ * AI mark may be neither a filled chip nor a status palette entry).
14
+ */
15
+
16
+ /** The word itself. Sentence-case, no emoji, no glyph: it has to survive being read. */
17
+ export const AI_GENERATED_LABEL = 'AI-generated';
18
+
19
+ // The Minion chat's "you are talking to an AI" sentence is NOT here: it is a translated
20
+ // user-facing string and lives in the i18n catalogue as `minion.aiDisclosure` (en/de/nl),
21
+ // which is the one source for copy that has to exist in three languages.
22
+
23
+ /**
24
+ * The visible mark for an artefact whose last writer was an agent session, or null when
25
+ * a person wrote it (`designs.updated_by_agent` and its wiki/skill twins are NULL then,
26
+ * and also NULL for rows written before those columns existed — an unknown author is
27
+ * not an agent claim).
28
+ *
29
+ * @param {string|null|undefined} agentName the session's display name
30
+ * @returns {string|null}
31
+ */
32
+ export function aiGeneratedLabel(agentName) {
33
+ const name = typeof agentName === 'string' ? agentName.trim() : '';
34
+ return name ? `${AI_GENERATED_LABEL} · ${name}` : AI_GENERATED_LABEL;
35
+ }
36
+
37
+ /**
38
+ * The hover text. Says what the label means in full, because the chip is abbreviated by
39
+ * space: last write, not sole author — a person may have written every earlier version.
40
+ *
41
+ * @param {string|null|undefined} agentName
42
+ * @returns {string}
43
+ */
44
+ export function aiGeneratedTitle(agentName) {
45
+ const name = typeof agentName === 'string' ? agentName.trim() : '';
46
+ return name
47
+ ? `Last written by an AI agent session named "${name}" — not by a person`
48
+ : 'Last written by an AI agent session — not by a person';
49
+ }
@@ -0,0 +1,13 @@
1
+ // Git-style range syntax for a frame's commit log: `v41..head`, `<sha>..<sha>`,
2
+ // `head`, or a bare `v41`/sha prefix. Shared rather than defined inside
3
+ // mcp/server.mjs because importing that module opens the MCP WebSocket at module
4
+ // scope and pins the event loop — a test that only wants this split must not pay
5
+ // for a socket.
6
+ export function splitRange(range) {
7
+ if (!range) return [null, null];
8
+ const raw = String(range).trim();
9
+ if (!raw) return [null, null];
10
+ const idx = raw.indexOf('..');
11
+ if (idx === -1) return [raw, null];
12
+ return [raw.slice(0, idx) || null, raw.slice(idx + 2) || null];
13
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A session's display name — ONE definition for the two places that must agree: the
3
+ * browser's session list (`GET /auth/sessions`) and the `via <name>` attribution
4
+ * stamped on a frame an agent wrote (`designs.updated_by_agent`).
5
+ *
6
+ * The name is `sessions.surface_name`, the label a person already sees on the agent's
7
+ * surface tab, because it is the only name a human and the agent both recognise. A
8
+ * session that never named itself — a CLI/device session, or an agent that slipped
9
+ * through the naming gate on an exempt route — falls back to its agent key and then to
10
+ * its caller class. A raw `anon:<sha256>` key is a dedup key, not a name: it is the
11
+ * hash of a root credential, so it is never shown.
12
+ *
13
+ * `origin` is the ONLY browser-vs-agent boundary (see src/db/schema.ts): anything that
14
+ * is not literally 'browser' — including NULL — is an agent.
15
+ */
16
+
17
+ const ORIGIN_LABELS = { browser: 'Browser', device: 'CLI', agent: 'Agent' };
18
+
19
+ const MAX_NAME = 120;
20
+
21
+ /**
22
+ * @param {{ surfaceName?: string|null, agentKey?: string|null, origin?: string|null }} session
23
+ * @returns {string} a short human label; never empty, never a credential
24
+ */
25
+ export function sessionDisplayName(session) {
26
+ const surface = typeof session?.surfaceName === 'string' ? session.surfaceName.trim() : '';
27
+ if (surface) return surface.slice(0, MAX_NAME);
28
+ const key = typeof session?.agentKey === 'string' ? session.agentKey.trim() : '';
29
+ if (key && !/^anon:/.test(key)) return key.slice(0, MAX_NAME);
30
+ return ORIGIN_LABELS[session?.origin] || ORIGIN_LABELS.agent;
31
+ }
32
+
33
+ /**
34
+ * The agent name to stamp on a frame this request writes, or null when a PERSON wrote
35
+ * it. Null is the meaning "a person": inventing a label here would make a human's edit
36
+ * read as an agent's, which is the exact confusion the column exists to end.
37
+ *
38
+ * @param {{ surfaceName?: string|null, agentKey?: string|null, origin?: string|null }} session
39
+ * @returns {string|null}
40
+ */
41
+ export function agentAttribution(session) {
42
+ if (!session || session.origin === 'browser') return null;
43
+ return sessionDisplayName(session);
44
+ }