drafted 1.24.7 → 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 +45 -6
- package/package.json +1 -1
- package/src/shared/ai-attribution.mjs +49 -0
- package/src/shared/commit-range.mjs +13 -0
- package/src/shared/session-label.mjs +44 -0
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
|
-
|
|
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,
|
|
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 (
|
|
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', '
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.
|
|
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": {
|
|
@@ -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
|
+
}
|