dsh-checklists 0.0.0-stage → 0.3.3

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.
@@ -0,0 +1,10 @@
1
+ # dsh bundle patch: inserts this plugin into a profile's layer stack.
2
+ # Install with:
3
+ # dsh plugin --profile dev add "C:/path/to/dsh-checklists"
4
+ - insert:
5
+ - id: tool-checklist
6
+ name: dsh-checklists
7
+ # Optional overrides (uncomment and edit):
8
+ # config:
9
+ # maxDigestLines: 12
10
+ # pruneDays: 14
package/lib/digest.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The compact checklist view injected into the model context every turn.
3
+ *
4
+ * This is the piece that removes the "ask me for the checklist again" loop:
5
+ * the digest is re-evaluated per assembly, so the model always sees current
6
+ * progress without anyone restating it.
7
+ *
8
+ * The rendering is deliberately small and stable — pending items first, a
9
+ * per-list line budget, and a hard total cap. A checklist that eats the context
10
+ * window would be self-defeating.
11
+ */
12
+ import { countItems, flatten } from './model.js';
13
+ const GLYPH = {
14
+ done: '[x]',
15
+ doing: '[~]',
16
+ todo: '[ ]',
17
+ blocked: '[!]',
18
+ };
19
+ /** Absolute cap per checklist, whatever the shared budget allows. */
20
+ const PER_LIST_CAP = 8;
21
+ /** Deepest nesting the digest renders. */
22
+ const MAX_DEPTH = 2;
23
+ const DEFAULT_RULE = 'Mark each item with checklist_check as soon as you finish it.';
24
+ /**
25
+ * Render the session's checklists as a compact prompt block.
26
+ *
27
+ * Each checklist gets an equal share of the line budget. Giving the first list
28
+ * the whole budget starved the rest: with two lists the second showed a single
29
+ * line, and a third never appeared at all.
30
+ *
31
+ * @param doc - the session document, or undefined when nothing is cached.
32
+ * @param options - line budget and rule text.
33
+ * @returns the digest, or `''` when the session has no checklists.
34
+ */
35
+ export function renderDigest(doc, options = {}) {
36
+ if (!doc || doc.lists.length === 0)
37
+ return '';
38
+ const maxLines = Math.max(4, options.maxLines ?? 12);
39
+ const rule = options.rule ?? DEFAULT_RULE;
40
+ const body = [];
41
+ const listCount = Math.max(1, doc.lists.length);
42
+ // One title line per list, the rest split evenly. Never below 3 lines, or a
43
+ // list would render its title and nothing else.
44
+ const lineBudget = Math.max(3, Math.floor(maxLines / listCount));
45
+ for (const list of doc.lists) {
46
+ const { total, done } = countItems(list.items);
47
+ body.push(`${list.title} · ${done}/${total}`);
48
+ const all = flatten(list.items).filter((entry) => entry.depth <= MAX_DEPTH);
49
+ // Pending first: truncation must never hide unfinished work.
50
+ const ordered = [
51
+ ...all.filter((entry) => entry.item.state !== 'done'),
52
+ ...all.filter((entry) => entry.item.state === 'done'),
53
+ ];
54
+ const room = Math.max(1, Math.min(PER_LIST_CAP, lineBudget - 1));
55
+ if (ordered.length > room) {
56
+ // Reserve one of the slots for the elision notice, so every list costs
57
+ // exactly the same number of lines and the total cap still holds.
58
+ const keep = Math.max(0, room - 1);
59
+ for (const entry of ordered.slice(0, keep)) {
60
+ body.push(` ${' '.repeat(entry.depth)}${GLYPH[entry.item.state]} ${entry.item.text}`);
61
+ }
62
+ body.push(` … ${ordered.length - keep} more (use checklist_list)`);
63
+ }
64
+ else {
65
+ for (const entry of ordered) {
66
+ body.push(` ${' '.repeat(entry.depth)}${GLYPH[entry.item.state]} ${entry.item.text}`);
67
+ }
68
+ }
69
+ }
70
+ if (body.length > maxLines) {
71
+ const hidden = body.length - maxLines;
72
+ body.length = maxLines;
73
+ body.push(`(+${hidden} more lines — checklist_list for the full tree)`);
74
+ }
75
+ return ['## Session checklist', ...body, rule].join('\n');
76
+ }
package/lib/index.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * dsh-checklist — session-scoped checklists for DeepSeek Harness.
3
+ *
4
+ * Two contributions:
5
+ * 1. Five tools (`checklist_*`) that let the agent create and update a tree
6
+ * of items for the current session.
7
+ * 2. A dynamic prompt context that renders a compact digest of that tree on
8
+ * every assembly, so the model never has to be reminded what is pending.
9
+ *
10
+ * The agent is the only writer. There is no client write path: the session
11
+ * document is the single source of truth.
12
+ */
13
+ import { renderDigest } from './digest.js';
14
+ import { serveProbe, PROBE_ROUTE } from './probe.js';
15
+ import { serveItemUpdate, serveState, ITEM_ROUTE, STATE_ROUTE } from './route.js';
16
+ import { ChecklistStore } from './store.js';
17
+ import { registerChecklistTools } from './tools.js';
18
+ export const name = 'dsh-checklist';
19
+ export const inject = ['tools'];
20
+ /**
21
+ * Resolve which session this prompt assembly belongs to.
22
+ *
23
+ * An agent's scope key is the Agent object itself (`scopeTarget(agent, agent)`
24
+ * in the agent registry), and `Agent.id` is the shared agent/session id. The
25
+ * initiator lookup is a fallback for assemblies that carry no scope. When
26
+ * neither yields an id, the digest stays empty rather than guessing.
27
+ */
28
+ function sessionOfAssembly(ctx, assembly) {
29
+ const scope = assembly?.scope;
30
+ if (scope && typeof scope.id === 'string' && scope.id.length > 0)
31
+ return scope.id;
32
+ const agents = ctx.agents;
33
+ const initiator = agents?.currentInitiator?.();
34
+ return typeof initiator?.id === 'string' && initiator.id.length > 0 ? initiator.id : undefined;
35
+ }
36
+ export function apply(ctx, config = {}) {
37
+ const store = new ChecklistStore(config.storageRoot);
38
+ const maxLines = config.maxDigestLines ?? 12;
39
+ registerChecklistTools(ctx, store, {
40
+ maxItemsPerList: config.maxItemsPerList,
41
+ templates: config.templates,
42
+ });
43
+ // Opportunistic housekeeping: never blocks startup, never fails the plugin.
44
+ if (config.pruneDays !== 0) {
45
+ void store.prune(config.pruneDays ?? 14).catch(() => undefined);
46
+ }
47
+ let loggedScope = false;
48
+ ctx.inject(['systemPrompt'], (promptCtx) => {
49
+ promptCtx.systemPrompt.context({
50
+ name: 'dsh-checklist',
51
+ order: 65,
52
+ text: (assembly) => {
53
+ if (!loggedScope && process.env.DSH_CHECKLIST_DEBUG === '1') {
54
+ loggedScope = true;
55
+ const scope = assembly?.scope;
56
+ console.error('[dsh-checklist] assembly scope:', scope ? `keys=[${Object.keys(scope).join(',')}] id=${String(scope.id)}` : 'none');
57
+ }
58
+ const sessionId = sessionOfAssembly(ctx, assembly);
59
+ if (!sessionId)
60
+ return '';
61
+ const cached = store.peek(sessionId);
62
+ if (!cached) {
63
+ // First turn after a restart: warm the cache for the next assembly.
64
+ void store.prime(sessionId).catch(() => undefined);
65
+ return '';
66
+ }
67
+ return renderDigest(cached, { maxLines });
68
+ },
69
+ });
70
+ });
71
+ // Read-only state endpoint consumed by the sidebar tab (phase 2).
72
+ ctx.inject(['webServer'], (webCtx) => {
73
+ webCtx.effect(() => webCtx.webServer.register({
74
+ kind: 'exact',
75
+ path: STATE_ROUTE,
76
+ handler: (req, res) => {
77
+ void serveState(req, res, store);
78
+ },
79
+ }), 'dsh-checklist: state route');
80
+ // The single write path outside the agent: an item-text edit from the sidebar.
81
+ webCtx.effect(() => webCtx.webServer.register({
82
+ kind: 'exact',
83
+ path: ITEM_ROUTE,
84
+ handler: (req, res) => {
85
+ void serveItemUpdate(req, res, store);
86
+ },
87
+ }), 'dsh-checklist: item route');
88
+ // Diagnostics channel for the browser half; see src/probe.ts.
89
+ webCtx.effect(() => webCtx.webServer.register({
90
+ kind: 'exact',
91
+ path: PROBE_ROUTE,
92
+ handler: (req, res) => {
93
+ serveProbe(req, res);
94
+ },
95
+ }), 'dsh-checklist: client probe route');
96
+ });
97
+ }
package/lib/model.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Pure data model for session-scoped checklists.
3
+ *
4
+ * Nothing in this module imports a DSH package: the tree shape, the id
5
+ * allocation, and the reference resolver are unit-testable without a running
6
+ * harness.
7
+ */
8
+ export const ITEM_STATES = ['todo', 'doing', 'done', 'blocked'];
9
+ export function emptyDoc(sessionId) {
10
+ return { version: 1, sessionId, lists: [] };
11
+ }
12
+ /** Mint the next `<prefix>N` identifier that is not already in `used`. */
13
+ export function mintId(prefix, used) {
14
+ let n = 1;
15
+ while (used.has(`${prefix}${n}`))
16
+ n += 1;
17
+ return `${prefix}${n}`;
18
+ }
19
+ /**
20
+ * Build an item tree from plain lines.
21
+ *
22
+ * A line indented with two leading spaces per level nests under the previous
23
+ * shallower line. This is the format the model writes, and the format
24
+ * {@link toLines} round-trips.
25
+ *
26
+ * @param lines - item texts, optionally indented.
27
+ * @param used - id set to extend; pass the ids already present when appending
28
+ * to an existing checklist, or ids will collide.
29
+ * @returns the root items, with ids assigned depth-first.
30
+ */
31
+ export function buildItems(lines, used = new Set()) {
32
+ const roots = [];
33
+ const stack = [];
34
+ for (const raw of lines) {
35
+ const text = raw.trim();
36
+ if (text.length === 0)
37
+ continue;
38
+ const indent = raw.length - raw.trimStart().length;
39
+ const node = { id: mintId('i', used), text, state: 'todo', children: [] };
40
+ used.add(node.id);
41
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent)
42
+ stack.pop();
43
+ const parent = stack[stack.length - 1];
44
+ if (parent)
45
+ parent.node.children.push(node);
46
+ else
47
+ roots.push(node);
48
+ stack.push({ indent, node });
49
+ }
50
+ return roots;
51
+ }
52
+ /** Render an item tree back to indented lines (inverse of {@link buildItems}). */
53
+ export function toLines(items, depth = 0) {
54
+ const out = [];
55
+ for (const item of items) {
56
+ out.push(`${' '.repeat(depth)}${item.text}`);
57
+ out.push(...toLines(item.children, depth + 1));
58
+ }
59
+ return out;
60
+ }
61
+ /** Depth-first flattening, carrying each item's nesting depth. */
62
+ export function flatten(items, depth = 0) {
63
+ const out = [];
64
+ for (const item of items) {
65
+ out.push({ item, depth });
66
+ out.push(...flatten(item.children, depth + 1));
67
+ }
68
+ return out;
69
+ }
70
+ export function countItems(items) {
71
+ let total = 0;
72
+ let done = 0;
73
+ for (const item of items) {
74
+ total += 1;
75
+ if (item.state === 'done')
76
+ done += 1;
77
+ const nested = countItems(item.children);
78
+ total += nested.total;
79
+ done += nested.done;
80
+ }
81
+ return { total, done };
82
+ }
83
+ /**
84
+ * Resolve one model-supplied reference against the session's checklists.
85
+ *
86
+ * Resolution order: exact id, exact case-insensitive text, unique
87
+ * case-insensitive prefix. Ambiguity is surfaced rather than guessed: the
88
+ * model can then pass a list id or the full text.
89
+ *
90
+ * @param lists - the session's checklists.
91
+ * @param ref - a model-supplied item id, text, or prefix.
92
+ * @param listId - optional checklist id to search within.
93
+ * @returns the hit, the ambiguity, or a miss.
94
+ */
95
+ export function resolveRef(lists, ref, listId) {
96
+ const needles = ref.trim().toLowerCase();
97
+ if (needles.length === 0)
98
+ return { kind: 'miss' };
99
+ const scoped = listId ? lists.filter((list) => list.id === listId) : lists;
100
+ const entries = [];
101
+ for (const list of scoped) {
102
+ for (const { item } of flatten(list.items))
103
+ entries.push({ list, item });
104
+ }
105
+ const byId = entries.find((entry) => entry.item.id.toLowerCase() === needles);
106
+ if (byId)
107
+ return { kind: 'hit', hit: byId };
108
+ const exact = entries.filter((entry) => entry.item.text.toLowerCase() === needles);
109
+ if (exact.length === 1)
110
+ return { kind: 'hit', hit: exact[0] };
111
+ if (exact.length > 1)
112
+ return { kind: 'ambiguous', candidates: exact };
113
+ const prefixed = entries.filter((entry) => entry.item.text.toLowerCase().startsWith(needles));
114
+ if (prefixed.length === 1)
115
+ return { kind: 'hit', hit: prefixed[0] };
116
+ if (prefixed.length > 1)
117
+ return { kind: 'ambiguous', candidates: prefixed };
118
+ return { kind: 'miss' };
119
+ }
120
+ /** Whether a value is a valid item state. */
121
+ export function isItemState(value) {
122
+ return typeof value === 'string' && ITEM_STATES.includes(value);
123
+ }
124
+ /**
125
+ * Find a checklist by id or by exact case-insensitive title.
126
+ *
127
+ * Ids are what the tools hand out, but a model that just created "Release 2.3"
128
+ * will often pass the title back; accepting both avoids a pointless round trip.
129
+ *
130
+ * @param lists - the session's checklists.
131
+ * @param ref - a checklist id or its exact title.
132
+ * @returns the matching checklist, or `undefined`.
133
+ */
134
+ export function findList(lists, ref) {
135
+ const needle = ref.trim().toLowerCase();
136
+ if (needle.length === 0)
137
+ return undefined;
138
+ return lists.find((list) => list.id.toLowerCase() === needle || list.title.toLowerCase() === needle);
139
+ }
140
+ /** Find one item by exact id across every list. */
141
+ export function findItemById(lists, id) {
142
+ for (const list of lists) {
143
+ for (const { item } of flatten(list.items)) {
144
+ if (item.id === id)
145
+ return { list, item };
146
+ }
147
+ }
148
+ return undefined;
149
+ }
150
+ /**
151
+ * Validate a checklist rename before applying it.
152
+ *
153
+ * Titles are how the user reads the sidebar and how `findList` resolves a
154
+ * reference, so a rename must never create a second list with the same title.
155
+ * Renaming a list to its own title (any case) is a no-op, not a conflict.
156
+ *
157
+ * @param lists - the session's checklists.
158
+ * @param ref - checklist id or exact title.
159
+ * @param nextTitle - the requested new title.
160
+ * @returns the resolved list, or the reason the rename cannot proceed.
161
+ */
162
+ export function checkRename(lists, ref, nextTitle) {
163
+ const to = nextTitle.trim();
164
+ if (to.length === 0)
165
+ return { kind: 'empty' };
166
+ const list = findList(lists, ref);
167
+ if (!list) {
168
+ return { kind: 'missing', available: lists.map((entry) => `${entry.id} (${entry.title})`) };
169
+ }
170
+ const lower = to.toLowerCase();
171
+ if (list.title.trim().toLowerCase() === lower) {
172
+ return { kind: 'ok', list, from: list.title, to };
173
+ }
174
+ const clash = lists.find((entry) => entry !== list && entry.title.trim().toLowerCase() === lower);
175
+ if (clash)
176
+ return { kind: 'duplicate', existing: clash.title };
177
+ return { kind: 'ok', list, from: list.title, to };
178
+ }
179
+ /**
180
+ * The next item to work on, in depth-first order.
181
+ *
182
+ * Rules, in the order they are applied:
183
+ * - A `done` subtree is skipped entirely.
184
+ * - A `blocked` subtree is skipped (and counted).
185
+ * - An item with open children is a container: descend and return its first
186
+ * actionable leaf. A section header is never "the next thing to do".
187
+ * - A leaf is returned as-is.
188
+ *
189
+ * @param lists - the checklists to search.
190
+ * @param listId - optional checklist id to scope the search.
191
+ * @returns the next item (if any) plus the remaining and blocked counts.
192
+ */
193
+ export function nextItem(lists, listId) {
194
+ const scope = listId ? lists.filter((list) => list.id === listId) : lists;
195
+ let remaining = 0;
196
+ let blocked = 0;
197
+ for (const list of scope) {
198
+ for (const { item } of flatten(list.items)) {
199
+ if (item.state === 'blocked')
200
+ blocked += 1;
201
+ else if (item.state !== 'done')
202
+ remaining += 1;
203
+ }
204
+ }
205
+ for (const list of scope) {
206
+ const found = firstOpen(list.items, []);
207
+ if (found)
208
+ return { next: { list, item: found.item, path: found.path }, remaining, blocked };
209
+ }
210
+ return { remaining, blocked };
211
+ }
212
+ function firstOpen(items, ancestors) {
213
+ for (const item of items) {
214
+ if (item.state === 'done' || item.state === 'blocked')
215
+ continue;
216
+ const child = firstOpen(item.children, [...ancestors, item.text]);
217
+ if (child)
218
+ return child;
219
+ return { item, path: ancestors };
220
+ }
221
+ return undefined;
222
+ }
package/lib/probe.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Client diagnostics channel.
3
+ *
4
+ * The client half runs in a browser we cannot attach a debugger to from here.
5
+ * This endpoint lets it report its own load/registration stages to the host, so
6
+ * "the tab is not there" can be told apart from "the module never loaded".
7
+ *
8
+ * In-memory and bounded: it answers one question and dies with the process.
9
+ */
10
+ export const PROBE_ROUTE = '/dsh-checklist/client-probe';
11
+ const MAX_REPORTS = 12;
12
+ const MAX_DETAIL = 500;
13
+ const reports = [];
14
+ export function serveProbe(req, res) {
15
+ if (req.method !== 'GET') {
16
+ res.writeHead(405, { allow: 'GET' });
17
+ res.end();
18
+ return;
19
+ }
20
+ let url;
21
+ try {
22
+ url = new URL(req.url ?? '/', 'http://127.0.0.1');
23
+ }
24
+ catch {
25
+ res.writeHead(400);
26
+ res.end();
27
+ return;
28
+ }
29
+ const stage = url.searchParams.get('stage');
30
+ if (stage) {
31
+ reports.push({
32
+ at: new Date().toISOString(),
33
+ stage: stage.slice(0, 80),
34
+ detail: (url.searchParams.get('detail') ?? '').slice(0, MAX_DETAIL),
35
+ });
36
+ if (reports.length > MAX_REPORTS)
37
+ reports.shift();
38
+ }
39
+ const payload = Buffer.from(`${JSON.stringify({ reports }, null, 2)}\n`, 'utf8');
40
+ res.writeHead(200, {
41
+ 'content-type': 'application/json; charset=utf-8',
42
+ 'content-length': String(payload.byteLength),
43
+ 'cache-control': 'no-store',
44
+ });
45
+ res.end(payload);
46
+ }
package/lib/route.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Read-only HTTP endpoint for the sidebar.
3
+ *
4
+ * Same-origin, GET only, one query parameter. The store validates the session
5
+ * id (it becomes a file name), so a crafted `session` cannot escape the storage
6
+ * directory; anything invalid answers 400 rather than throwing.
7
+ */
8
+ import { findItemById } from './model.js';
9
+ import { toPublicState } from './state.js';
10
+ export const STATE_ROUTE = '/dsh-checklist/state';
11
+ export const ITEM_ROUTE = '/dsh-checklist/item';
12
+ /** Longest session id we will even look at; real ones are UUIDs. */
13
+ const MAX_SESSION_ID = 200;
14
+ /** Request body cap for the one write endpoint. */
15
+ const MAX_BODY_BYTES = 16 * 1024;
16
+ /** Item text cap, matching `checklist_edit`. */
17
+ const MAX_TEXT = 500;
18
+ function sendJson(res, status, body) {
19
+ const payload = Buffer.from(`${JSON.stringify(body)}\n`, 'utf8');
20
+ res.writeHead(status, {
21
+ 'content-type': 'application/json; charset=utf-8',
22
+ 'content-length': String(payload.byteLength),
23
+ 'cache-control': 'no-store',
24
+ });
25
+ res.end(payload);
26
+ }
27
+ /**
28
+ * Serve one session's checklist state.
29
+ *
30
+ * @param req - the incoming request.
31
+ * @param res - the response to write.
32
+ * @param store - the session store.
33
+ */
34
+ export async function serveState(req, res, store) {
35
+ if (req.method !== 'GET') {
36
+ res.writeHead(405, { allow: 'GET' });
37
+ res.end();
38
+ return;
39
+ }
40
+ let session;
41
+ try {
42
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
43
+ session = url.searchParams.get('session');
44
+ }
45
+ catch {
46
+ sendJson(res, 400, { error: 'malformed request url' });
47
+ return;
48
+ }
49
+ if (!session) {
50
+ sendJson(res, 400, { error: 'the session query parameter is required' });
51
+ return;
52
+ }
53
+ if (session.length > MAX_SESSION_ID) {
54
+ sendJson(res, 400, { error: 'session id is too long' });
55
+ return;
56
+ }
57
+ try {
58
+ const doc = await store.prime(session);
59
+ sendJson(res, 200, toPublicState(doc));
60
+ }
61
+ catch {
62
+ // The store rejects unsafe ids by throwing; never echo the id back.
63
+ sendJson(res, 400, { error: 'invalid session id' });
64
+ }
65
+ }
66
+ async function readJsonBody(req, limit = MAX_BODY_BYTES) {
67
+ const chunks = [];
68
+ let size = 0;
69
+ for await (const chunk of req) {
70
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
71
+ size += buffer.byteLength;
72
+ if (size > limit)
73
+ throw new Error('body too large');
74
+ chunks.push(buffer);
75
+ }
76
+ if (size === 0)
77
+ throw new Error('empty body');
78
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
79
+ }
80
+ /**
81
+ * Apply one text edit from the sidebar.
82
+ *
83
+ * This is the only write path outside the agent, so it is deliberately narrow:
84
+ * one item, by exact id, with an optional `expectedText` guard. When the guard
85
+ * does not match, the answer is 409 rather than a silent overwrite — the client
86
+ * re-reads and the user retries.
87
+ *
88
+ * @param req - the incoming request; must be POST.
89
+ * @param res - the response to write.
90
+ * @param store - the session store.
91
+ */
92
+ export async function serveItemUpdate(req, res, store) {
93
+ if (req.method !== 'POST') {
94
+ res.writeHead(405, { allow: 'POST' });
95
+ res.end();
96
+ return;
97
+ }
98
+ let body;
99
+ try {
100
+ body = await readJsonBody(req);
101
+ }
102
+ catch {
103
+ sendJson(res, 400, { error: 'invalid JSON body' });
104
+ return;
105
+ }
106
+ if (body === null || typeof body !== 'object') {
107
+ sendJson(res, 400, { error: 'body must be an object' });
108
+ return;
109
+ }
110
+ const { session, itemId, text: nextText, expectedText } = body;
111
+ if (typeof session !== 'string' || session.length === 0 || session.length > MAX_SESSION_ID) {
112
+ sendJson(res, 400, { error: 'session is required' });
113
+ return;
114
+ }
115
+ if (typeof itemId !== 'string' || itemId.length === 0) {
116
+ sendJson(res, 400, { error: 'itemId is required' });
117
+ return;
118
+ }
119
+ if (typeof nextText !== 'string') {
120
+ sendJson(res, 400, { error: 'text is required' });
121
+ return;
122
+ }
123
+ const trimmed = nextText.trim();
124
+ if (trimmed.length === 0) {
125
+ sendJson(res, 400, { error: 'text must not be empty' });
126
+ return;
127
+ }
128
+ if (trimmed.length > MAX_TEXT) {
129
+ sendJson(res, 400, { error: `text is longer than ${MAX_TEXT} characters` });
130
+ return;
131
+ }
132
+ try {
133
+ const outcome = await store.update(session, (doc) => {
134
+ const found = findItemById(doc.lists, itemId);
135
+ if (!found)
136
+ return { status: 404 };
137
+ if (typeof expectedText === 'string' && found.item.text !== expectedText) {
138
+ return { status: 409 };
139
+ }
140
+ found.item.text = trimmed;
141
+ return { status: 200, item: { id: found.item.id, text: trimmed } };
142
+ });
143
+ if (outcome.status === 200)
144
+ sendJson(res, 200, { ok: true, item: outcome.item });
145
+ else if (outcome.status === 409) {
146
+ sendJson(res, 409, { error: 'conflict', detail: 'the item changed since the sidebar read it' });
147
+ }
148
+ else {
149
+ sendJson(res, 404, { error: 'item not found' });
150
+ }
151
+ }
152
+ catch {
153
+ sendJson(res, 400, { error: 'invalid session id' });
154
+ }
155
+ }
package/lib/state.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The public JSON shape the sidebar reads.
3
+ *
4
+ * Deliberately separate from the stored document: the client gets no `version`
5
+ * field, no future internals, and nothing it could mistake for a write channel.
6
+ */
7
+ import { countItems } from './model.js';
8
+ function toPublicItem(item) {
9
+ return {
10
+ id: item.id,
11
+ text: item.text,
12
+ state: item.state,
13
+ children: item.children.map(toPublicItem),
14
+ };
15
+ }
16
+ function toPublicList(list) {
17
+ return {
18
+ id: list.id,
19
+ title: list.title,
20
+ createdAt: list.createdAt,
21
+ items: list.items.map(toPublicItem),
22
+ };
23
+ }
24
+ export function toPublicState(doc) {
25
+ const counts = { lists: doc.lists.length, total: 0, done: 0 };
26
+ for (const list of doc.lists) {
27
+ const listCounts = countItems(list.items);
28
+ counts.total += listCounts.total;
29
+ counts.done += listCounts.done;
30
+ }
31
+ return {
32
+ sessionId: doc.sessionId,
33
+ lists: doc.lists.map(toPublicList),
34
+ counts,
35
+ };
36
+ }