reviewzy 0.1.0 → 0.2.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/.env.example +5 -0
- package/README.md +29 -10
- package/package.json +1 -1
- package/src/config.ts +4 -0
- package/src/dashboard/mount.ts +118 -1
- package/src/dashboard/repo-link.ts +126 -0
- package/src/dashboard/static/components.css +15 -3
- package/src/dashboard/static/dashboard.css +172 -37
- package/src/dashboard/static/dashboard.js +141 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/views.tsx +503 -142
- package/src/dashboard/word-diff.ts +89 -0
- package/src/db/human-save.ts +112 -0
- package/src/db/migrations.ts +38 -0
- package/src/db/queries.ts +35 -12
- package/src/db/sweep.ts +2 -2
- package/src/mcp/file-entries.ts +53 -1
- package/src/mcp/list-entries.ts +41 -3
- package/src/mcp/mark-applied.ts +1 -1
- package/src/mcp/route.ts +6 -9
- package/src/mcp/server.ts +1 -1
- package/src/shim/daemon.ts +23 -16
- package/src/shim/main.ts +26 -5
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The word-level diff between the anchor text and the proposed text, computed server-side at
|
|
3
|
+
* render time so the marks need no client js. Whitespace-token based: the split keeps whitespace
|
|
4
|
+
* runs as their own tokens, so both streams re-join to their exact input, bytes included.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One token of a diffed string, carrying its mark. Whitespace runs are tokens too, always `same` when both sides share them. */
|
|
8
|
+
export type WordDiffToken = {
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly kind: "same" | "removed" | "added";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type WordDiff = {
|
|
14
|
+
/** The anchor text tokenized: tokens the proposal does not keep are `removed`, the rest `same`. */
|
|
15
|
+
readonly before: readonly WordDiffToken[];
|
|
16
|
+
/** The proposal tokenized: tokens the anchor did not have are `added`, the rest `same`. */
|
|
17
|
+
readonly after: readonly WordDiffToken[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Splits on whitespace boundaries, keeping the runs: `"a b"` becomes `["a", " ", "b"]`. */
|
|
21
|
+
function tokenize(text: string): string[] {
|
|
22
|
+
return text.split(/(\s+)/).filter((token) => token !== "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The LCS table is quadratic in token count, and the anchor is agent-controlled with no length
|
|
27
|
+
* cap, so past this cell ceiling the walk is skipped: each side reads fully changed, which is
|
|
28
|
+
* honest (no word is claimed unchanged) and renders in linear time.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_LCS_CELLS = 1_000_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A longest-common-subsequence walk over the two token streams: a token both sides share is
|
|
34
|
+
* `same`, one only the anchor has is `removed`, one only the proposal has is `added`. Identical
|
|
35
|
+
* strings produce all-`same` streams, so nothing renders a mark.
|
|
36
|
+
*/
|
|
37
|
+
export function diffWords(before: string, after: string): WordDiff {
|
|
38
|
+
const a = tokenize(before);
|
|
39
|
+
const b = tokenize(after);
|
|
40
|
+
|
|
41
|
+
if (a.length * b.length > MAX_LCS_CELLS) {
|
|
42
|
+
return {
|
|
43
|
+
before: a.map((text) => ({ text, kind: "removed" as const })),
|
|
44
|
+
after: b.map((text) => ({ text, kind: "added" as const })),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// dp[i][j] is the LCS length of a[i:] against b[j:], filled bottom-up so the walk can pick the
|
|
49
|
+
// branch that keeps the longest match. One flat typed array: 8 bytes a cell, no nested rows.
|
|
50
|
+
const stride = b.length + 1;
|
|
51
|
+
const dp = new Float64Array((a.length + 1) * stride);
|
|
52
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
53
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
54
|
+
const below = dp[(i + 1) * stride + j]!;
|
|
55
|
+
const right = dp[i * stride + j + 1]!;
|
|
56
|
+
dp[i * stride + j] = a[i] === b[j] ? below + 1 : Math.max(below, right);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const beforeTokens: WordDiffToken[] = [];
|
|
61
|
+
const afterTokens: WordDiffToken[] = [];
|
|
62
|
+
let i = 0;
|
|
63
|
+
let j = 0;
|
|
64
|
+
while (i < a.length && j < b.length) {
|
|
65
|
+
if (a[i] === b[j]) {
|
|
66
|
+
beforeTokens.push({ text: a[i]!, kind: "same" });
|
|
67
|
+
afterTokens.push({ text: b[j]!, kind: "same" });
|
|
68
|
+
i++;
|
|
69
|
+
j++;
|
|
70
|
+
} else if (dp[(i + 1) * stride + j]! >= dp[i * stride + j + 1]!) {
|
|
71
|
+
// Skipping the anchor's token keeps at least as long a match: it is removed.
|
|
72
|
+
beforeTokens.push({ text: a[i]!, kind: "removed" });
|
|
73
|
+
i++;
|
|
74
|
+
} else {
|
|
75
|
+
afterTokens.push({ text: b[j]!, kind: "added" });
|
|
76
|
+
j++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
while (i < a.length) {
|
|
80
|
+
beforeTokens.push({ text: a[i]!, kind: "removed" });
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
while (j < b.length) {
|
|
84
|
+
afterTokens.push({ text: b[j]!, kind: "added" });
|
|
85
|
+
j++;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { before: beforeTokens, after: afterTokens };
|
|
89
|
+
}
|
package/src/db/human-save.ts
CHANGED
|
@@ -240,3 +240,115 @@ export function listRevisions(store: Store, entryId: string): RevisionRow[] {
|
|
|
240
240
|
)
|
|
241
241
|
.all(entryId) as RevisionRow[];
|
|
242
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The tolerant parse for `entries.images`, mirroring `parseConstraints`: the column holds foreign
|
|
246
|
+
* data (it is also fed by the wire), and an unparseable or shape-broken value must read as no
|
|
247
|
+
* images, never as a 500. Non-string items are dropped rather than propagated.
|
|
248
|
+
*/
|
|
249
|
+
export function parseEntryImages(json: string): string[] {
|
|
250
|
+
let parsed: unknown;
|
|
251
|
+
try {
|
|
252
|
+
parsed = JSON.parse(json);
|
|
253
|
+
} catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
if (!Array.isArray(parsed)) return [];
|
|
257
|
+
return parsed.filter((item): item is string => typeof item === "string");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The per-entry image cap `docs/mcp-contract.md` pins for `images`; the wire's boundary carries the same number in its own layer. */
|
|
261
|
+
const MAX_ENTRY_IMAGES = 8;
|
|
262
|
+
|
|
263
|
+
/** Everything the image writes refuse; the dashboard's upload and remove routes format the messages. */
|
|
264
|
+
export type ImageWriteRefusal =
|
|
265
|
+
| { readonly kind: "unknown" }
|
|
266
|
+
| { readonly kind: "too_many" }
|
|
267
|
+
| { readonly kind: "no_image"; readonly index: number };
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Appends one image the human uploaded to the entry's image list. A note-like write, not a save:
|
|
271
|
+
* it works on every status, writes no revision row, changes no status, and fires no status event
|
|
272
|
+
* (a waiter cares about approval, not about a new screenshot). `updated_at` moves so the row
|
|
273
|
+
* reflects the write. The data url already passed the route's mime and size checks, and the
|
|
274
|
+
* stored list must stay inside the contract's image cap.
|
|
275
|
+
*
|
|
276
|
+
* The read and the write share one immediate transaction: a deferred one would let two racing
|
|
277
|
+
* uploads (a double submit) both read the old list and drop one image on the later write, while
|
|
278
|
+
* the immediate lock serializes them at the read.
|
|
279
|
+
*/
|
|
280
|
+
export function appendEntryImage(
|
|
281
|
+
store: Store,
|
|
282
|
+
id: string,
|
|
283
|
+
dataUrl: string,
|
|
284
|
+
): { readonly ok: true } | { readonly ok: false; readonly refusal: ImageWriteRefusal } {
|
|
285
|
+
const write = store.db.transaction(() => {
|
|
286
|
+
const row = store.db.query("SELECT images FROM entries WHERE id = ?").get(id) as { images: string } | null;
|
|
287
|
+
if (row === null) return { ok: false as const, refusal: { kind: "unknown" as const } };
|
|
288
|
+
|
|
289
|
+
const images = parseEntryImages(row.images);
|
|
290
|
+
if (images.length >= MAX_ENTRY_IMAGES) {
|
|
291
|
+
return { ok: false as const, refusal: { kind: "too_many" as const } };
|
|
292
|
+
}
|
|
293
|
+
images.push(dataUrl);
|
|
294
|
+
store.db.run("UPDATE entries SET images = ?, updated_at = ? WHERE id = ?", [
|
|
295
|
+
JSON.stringify(images),
|
|
296
|
+
Date.now(),
|
|
297
|
+
id,
|
|
298
|
+
]);
|
|
299
|
+
return { ok: true as const };
|
|
300
|
+
});
|
|
301
|
+
return write.immediate();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Removes one image by its index in the stored array. Same write class as `appendEntryImage`: no
|
|
306
|
+
* revision, no status change, no event; `updated_at` moves, and the read + write share one
|
|
307
|
+
* immediate transaction for the same double-submit race the append guards. An index past either
|
|
308
|
+
* end is a refusal carrying the index the route's message names.
|
|
309
|
+
*/
|
|
310
|
+
export function removeEntryImage(
|
|
311
|
+
store: Store,
|
|
312
|
+
id: string,
|
|
313
|
+
index: number,
|
|
314
|
+
): { readonly ok: true } | { readonly ok: false; readonly refusal: ImageWriteRefusal } {
|
|
315
|
+
const write = store.db.transaction(() => {
|
|
316
|
+
const row = store.db.query("SELECT images FROM entries WHERE id = ?").get(id) as { images: string } | null;
|
|
317
|
+
if (row === null) return { ok: false as const, refusal: { kind: "unknown" as const } };
|
|
318
|
+
|
|
319
|
+
const images = parseEntryImages(row.images);
|
|
320
|
+
if (index < 0 || index >= images.length) {
|
|
321
|
+
return { ok: false as const, refusal: { kind: "no_image" as const, index } };
|
|
322
|
+
}
|
|
323
|
+
images.splice(index, 1);
|
|
324
|
+
store.db.run("UPDATE entries SET images = ?, updated_at = ? WHERE id = ?", [
|
|
325
|
+
JSON.stringify(images),
|
|
326
|
+
Date.now(),
|
|
327
|
+
id,
|
|
328
|
+
]);
|
|
329
|
+
return { ok: true as const };
|
|
330
|
+
});
|
|
331
|
+
return write.immediate();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Saves the human's own notes. Deliberately unlike `saveHumanText`: the notes are a scratchpad, so
|
|
336
|
+
* the write works on every status (an applied or rejected entry's notes stay editable), an empty
|
|
337
|
+
* string clears them rather than refusing, and nothing else moves — no revision row (only prose
|
|
338
|
+
* saves make history), no status change, no status event (a waiter cares about approval, not about
|
|
339
|
+
* a note). `updated_at` moves so the row reflects the write. The only refusal is an unknown id.
|
|
340
|
+
*/
|
|
341
|
+
export function saveHumanNotes(
|
|
342
|
+
store: Store,
|
|
343
|
+
input: { readonly id: string; readonly notes: string },
|
|
344
|
+
): { readonly ok: true } | { readonly ok: false; readonly refusal: { readonly kind: "unknown" } } {
|
|
345
|
+
const row = store.db.query("SELECT id FROM entries WHERE id = ?").get(input.id) as { id: string } | null;
|
|
346
|
+
if (row === null) return { ok: false, refusal: { kind: "unknown" } };
|
|
347
|
+
|
|
348
|
+
store.db.run("UPDATE entries SET human_notes = ?, updated_at = ? WHERE id = ?", [
|
|
349
|
+
input.notes,
|
|
350
|
+
Date.now(),
|
|
351
|
+
input.id,
|
|
352
|
+
]);
|
|
353
|
+
return { ok: true };
|
|
354
|
+
}
|
package/src/db/migrations.ts
CHANGED
|
@@ -146,4 +146,42 @@ export const MIGRATIONS: Migration[] = [
|
|
|
146
146
|
ALTER TABLE entries_archive ADD COLUMN applied_hash TEXT;
|
|
147
147
|
`,
|
|
148
148
|
},
|
|
149
|
+
{
|
|
150
|
+
version: 4,
|
|
151
|
+
name: "entry-title",
|
|
152
|
+
sql: `
|
|
153
|
+
-- An optional human-readable label the dashboard shows in place of the file basename, so a
|
|
154
|
+
-- row reads as what it changes rather than where it lives (cloudy, 2026-08-20). Nullable:
|
|
155
|
+
-- an entry without one falls back to its anchor text at render. The archive mirrors entries
|
|
156
|
+
-- column-for-column, so it gains the column too.
|
|
157
|
+
ALTER TABLE entries ADD COLUMN title TEXT;
|
|
158
|
+
ALTER TABLE entries_archive ADD COLUMN title TEXT;
|
|
159
|
+
`,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
version: 5,
|
|
163
|
+
name: "entry-images",
|
|
164
|
+
sql: `
|
|
165
|
+
-- Screenshots and links attached to an entry, filed by agents and appended to by the human
|
|
166
|
+
-- on the dashboard. A json array of strings, each a data:image/ data url or an http(s) url;
|
|
167
|
+
-- the wire boundary enforces the shape (max 8, max 5 MiB each). NOT NULL with an empty
|
|
168
|
+
-- array default: an entry without images stores [] like the other json columns. The archive
|
|
169
|
+
-- mirrors entries column-for-column, so it gains the column too.
|
|
170
|
+
ALTER TABLE entries ADD COLUMN images TEXT NOT NULL DEFAULT '[]';
|
|
171
|
+
ALTER TABLE entries_archive ADD COLUMN images TEXT NOT NULL DEFAULT '[]';
|
|
172
|
+
`,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
version: 6,
|
|
176
|
+
name: "entry-human-notes",
|
|
177
|
+
sql: `
|
|
178
|
+
-- The human's own scratchpad, dashboard-only: agents can neither set nor read it, so the
|
|
179
|
+
-- filing insert leaves it NULL and the list_entries tool projects it out of the wire row.
|
|
180
|
+
-- Nullable, unlike the agent-fed json columns: no agent ever sends a value, and NULL is
|
|
181
|
+
-- what renders as "no notes yet". The archive mirrors entries column-for-column, so it
|
|
182
|
+
-- gains the column too.
|
|
183
|
+
ALTER TABLE entries ADD COLUMN human_notes TEXT;
|
|
184
|
+
ALTER TABLE entries_archive ADD COLUMN human_notes TEXT;
|
|
185
|
+
`,
|
|
186
|
+
},
|
|
149
187
|
];
|
package/src/db/queries.ts
CHANGED
|
@@ -8,14 +8,15 @@ export type EntryStatus = "draft" | "approved" | "applied" | "rejected";
|
|
|
8
8
|
/**
|
|
9
9
|
* One entry as it crosses into the store: every field already typed, hashed, and stringified.
|
|
10
10
|
* The json boundary belongs to the tool that reads the wire (`src/mcp/file-entries.ts`); nothing
|
|
11
|
-
* here re-parses what it already accepted. `agentDraft`, `contextJson`,
|
|
12
|
-
* null when the caller did not send the field: a first filing stores the empty
|
|
13
|
-
* draft re-file keeps the stored value (the schema refuses explicit null, so no
|
|
14
|
-
* express a wipe).
|
|
11
|
+
* here re-parses what it already accepted. `agentDraft`, `contextJson`, `constraintsJson`, and
|
|
12
|
+
* `imagesJson` are null when the caller did not send the field: a first filing stores the empty
|
|
13
|
+
* default, and a draft re-file keeps the stored value (the schema refuses explicit null, so no
|
|
14
|
+
* caller can express a wipe).
|
|
15
15
|
*/
|
|
16
16
|
export type NewEntry = {
|
|
17
17
|
readonly repo: string;
|
|
18
18
|
readonly file: string;
|
|
19
|
+
readonly title: string | null;
|
|
19
20
|
readonly anchorText: string;
|
|
20
21
|
readonly anchorBefore: string;
|
|
21
22
|
readonly anchorAfter: string;
|
|
@@ -24,6 +25,7 @@ export type NewEntry = {
|
|
|
24
25
|
readonly agentDraft: string | null;
|
|
25
26
|
readonly contextJson: string | null;
|
|
26
27
|
readonly constraintsJson: string | null;
|
|
28
|
+
readonly imagesJson: string | null;
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
export type FiledEntry = {
|
|
@@ -48,6 +50,7 @@ export type EntryRow = {
|
|
|
48
50
|
readonly batch_id: string;
|
|
49
51
|
readonly repo: string;
|
|
50
52
|
readonly file: string;
|
|
53
|
+
readonly title: string | null;
|
|
51
54
|
readonly anchor_text: string;
|
|
52
55
|
readonly anchor_before: string;
|
|
53
56
|
readonly anchor_after: string;
|
|
@@ -61,6 +64,8 @@ export type EntryRow = {
|
|
|
61
64
|
readonly filed_by: string | null;
|
|
62
65
|
readonly stale_note: string | null;
|
|
63
66
|
readonly applied_hash: string | null;
|
|
67
|
+
readonly images: string;
|
|
68
|
+
readonly human_notes: string | null;
|
|
64
69
|
readonly created_at: number;
|
|
65
70
|
readonly updated_at: number;
|
|
66
71
|
readonly applied_at: number | null;
|
|
@@ -189,16 +194,17 @@ function fileOneEntry(
|
|
|
189
194
|
const now = Date.now();
|
|
190
195
|
db.run(
|
|
191
196
|
`INSERT INTO entries (
|
|
192
|
-
id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after,
|
|
197
|
+
id, project_id, batch_id, repo, file, title, anchor_text, anchor_before, anchor_after,
|
|
193
198
|
anchor_hash, file_hash, agent_draft, human_text, status, context, constraints,
|
|
194
|
-
filed_by, stale_note, created_at, updated_at, applied_at, archived_at
|
|
195
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'draft', ?, ?, ?, NULL, ?, ?, NULL, NULL)`,
|
|
199
|
+
filed_by, stale_note, images, created_at, updated_at, applied_at, archived_at
|
|
200
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'draft', ?, ?, ?, NULL, ?, ?, ?, NULL, NULL)`,
|
|
196
201
|
[
|
|
197
202
|
id,
|
|
198
203
|
projectId,
|
|
199
204
|
batchId,
|
|
200
205
|
entry.repo,
|
|
201
206
|
entry.file,
|
|
207
|
+
entry.title,
|
|
202
208
|
entry.anchorText,
|
|
203
209
|
entry.anchorBefore,
|
|
204
210
|
entry.anchorAfter,
|
|
@@ -208,6 +214,7 @@ function fileOneEntry(
|
|
|
208
214
|
entry.contextJson ?? "{}",
|
|
209
215
|
entry.constraintsJson ?? "{}",
|
|
210
216
|
filedBy,
|
|
217
|
+
entry.imagesJson ?? "[]",
|
|
211
218
|
now,
|
|
212
219
|
now,
|
|
213
220
|
],
|
|
@@ -219,8 +226,8 @@ function fileOneEntry(
|
|
|
219
226
|
// `COALESCE(?, column)`: a field the newer call did not carry (null) keeps the stored value.
|
|
220
227
|
// The schema refuses explicit null, so a re-file can only move the fields it names.
|
|
221
228
|
db.run(
|
|
222
|
-
"UPDATE entries SET agent_draft = COALESCE(?, agent_draft), context = COALESCE(?, context), constraints = COALESCE(?, constraints), updated_at = ? WHERE id = ?",
|
|
223
|
-
[entry.agentDraft, entry.contextJson, entry.constraintsJson, Date.now(), existing.id],
|
|
229
|
+
"UPDATE entries SET agent_draft = COALESCE(?, agent_draft), context = COALESCE(?, context), constraints = COALESCE(?, constraints), title = COALESCE(?, title), images = COALESCE(?, images), updated_at = ? WHERE id = ?",
|
|
230
|
+
[entry.agentDraft, entry.contextJson, entry.constraintsJson, entry.title, entry.imagesJson, Date.now(), existing.id],
|
|
224
231
|
);
|
|
225
232
|
return { id: existing.id, status: "draft", deduped: true, updated: true };
|
|
226
233
|
}
|
|
@@ -254,9 +261,10 @@ const APPROVED_PAGE_SIZE = 50;
|
|
|
254
261
|
|
|
255
262
|
/** The columns `docs/mcp-contract.md`'s entry schema names; the one place the output row's shape is spelled out in SQL. */
|
|
256
263
|
const ENTRY_COLUMNS = [
|
|
257
|
-
"id", "project_id", "batch_id", "repo", "file", "anchor_text", "anchor_before",
|
|
258
|
-
"anchor_after", "anchor_hash", "file_hash", "agent_draft", "human_text", "status",
|
|
259
|
-
"context", "constraints", "filed_by", "stale_note", "applied_hash", "
|
|
264
|
+
"id", "project_id", "batch_id", "repo", "file", "title", "anchor_text", "anchor_before",
|
|
265
|
+
"anchor_after", "anchor_hash", "file_hash", "agent_draft", "human_notes", "human_text", "status",
|
|
266
|
+
"context", "constraints", "filed_by", "stale_note", "applied_hash", "images", "created_at",
|
|
267
|
+
"updated_at", "applied_at", "archived_at",
|
|
260
268
|
] as const;
|
|
261
269
|
|
|
262
270
|
/**
|
|
@@ -304,6 +312,21 @@ export function listEntries(store: Store, filter: ListEntriesFilter): ListEntrie
|
|
|
304
312
|
return { rows, nextCursor };
|
|
305
313
|
}
|
|
306
314
|
|
|
315
|
+
/**
|
|
316
|
+
* The entries immediately before and after `id` in the ascending-id walk the list renders, for the
|
|
317
|
+
* editor's full-height prev/next rails; null at either boundary. The `id` column is a primary key,
|
|
318
|
+
* so both comparisons run on its unique index.
|
|
319
|
+
*/
|
|
320
|
+
export function adjacentEntryIds(store: Store, id: string): { prevId: string | null; nextId: string | null } {
|
|
321
|
+
const prev = store.db.query("SELECT id FROM entries WHERE id < ? ORDER BY id DESC LIMIT 1").get(id) as
|
|
322
|
+
| { id: string }
|
|
323
|
+
| null;
|
|
324
|
+
const next = store.db.query("SELECT id FROM entries WHERE id > ? ORDER BY id ASC LIMIT 1").get(id) as
|
|
325
|
+
| { id: string }
|
|
326
|
+
| null;
|
|
327
|
+
return { prevId: prev?.id ?? null, nextId: next?.id ?? null };
|
|
328
|
+
}
|
|
329
|
+
|
|
307
330
|
/**
|
|
308
331
|
* The `fetch_approved` read. The status is not a filter: the tool's invariant is `'approved'`
|
|
309
332
|
* (a draft, applied, or rejected row never leaves this query), and the page size is the contract's
|
package/src/db/sweep.ts
CHANGED
|
@@ -55,12 +55,12 @@ export function sweepArchive(store: Store, now: number, archiveAfterDays: number
|
|
|
55
55
|
`INSERT INTO entries_archive (
|
|
56
56
|
id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after,
|
|
57
57
|
anchor_hash, file_hash, agent_draft, human_text, status, context, constraints,
|
|
58
|
-
filed_by, stale_note, applied_hash, created_at, updated_at, applied_at, archived_at
|
|
58
|
+
filed_by, stale_note, applied_hash, images, human_notes, created_at, updated_at, applied_at, archived_at
|
|
59
59
|
)
|
|
60
60
|
SELECT
|
|
61
61
|
id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after,
|
|
62
62
|
anchor_hash, file_hash, agent_draft, human_text, status, context, constraints,
|
|
63
|
-
filed_by, stale_note, applied_hash, created_at, updated_at, applied_at, ?
|
|
63
|
+
filed_by, stale_note, applied_hash, images, human_notes, created_at, updated_at, applied_at, ?
|
|
64
64
|
FROM entries
|
|
65
65
|
WHERE ${EXPIRED_APPLIED}`,
|
|
66
66
|
[now, cutoff],
|
package/src/mcp/file-entries.ts
CHANGED
|
@@ -42,6 +42,7 @@ const ConstraintsJson = z.strictObject({
|
|
|
42
42
|
const FileEntryArgs = z.object({
|
|
43
43
|
repo: z.string().min(1),
|
|
44
44
|
file: z.string().min(1),
|
|
45
|
+
title: z.string().min(1).optional(),
|
|
45
46
|
anchor_text: z.string().min(1),
|
|
46
47
|
anchor_before: z.string().optional(),
|
|
47
48
|
anchor_after: z.string().optional(),
|
|
@@ -50,6 +51,7 @@ const FileEntryArgs = z.object({
|
|
|
50
51
|
file_hash: z.string().optional(),
|
|
51
52
|
context: z.unknown().optional(),
|
|
52
53
|
constraints: z.unknown().optional(),
|
|
54
|
+
images: z.unknown().optional(),
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
const FiledOutput = z.object({
|
|
@@ -84,6 +86,10 @@ const CONSTRAINT_FIX: Record<string, string> = {
|
|
|
84
86
|
notes: "send constraints.notes as a string",
|
|
85
87
|
};
|
|
86
88
|
|
|
89
|
+
/** The caps `docs/mcp-contract.md` pins for `images`: at most 8 per entry, each at most 5 MiB. */
|
|
90
|
+
const MAX_IMAGES_PER_ENTRY = 8;
|
|
91
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
92
|
+
|
|
87
93
|
/** Turns zod's issues into the named-field refusal the contract's error row promises. */
|
|
88
94
|
function refuseConstraints(index: number, file: string, anchorText: string, issues: readonly z.ZodIssue[]): never {
|
|
89
95
|
const where = entryName(index, file, anchorText);
|
|
@@ -123,6 +129,50 @@ function validateEntry(index: number, raw: z.output<typeof FileEntryArgs>): NewE
|
|
|
123
129
|
refuseConstraints(index, raw.file, raw.anchor_text, constraints.error.issues);
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
// Held loose like context and constraints, so the refusal names the entry and the fix instead
|
|
133
|
+
// of stopping at the sdk's field-path error. Absent (or explicit null) means "the newer call
|
|
134
|
+
// does not carry this field": a draft re-file then keeps the stored images.
|
|
135
|
+
let imagesJson: string | null;
|
|
136
|
+
if (raw.images == null) {
|
|
137
|
+
imagesJson = null;
|
|
138
|
+
} else {
|
|
139
|
+
if (!Array.isArray(raw.images)) {
|
|
140
|
+
refuse(
|
|
141
|
+
where,
|
|
142
|
+
"images must be an array of image urls or data urls",
|
|
143
|
+
'send an array such as ["https://example.com/screenshot.png"], or omit images',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (raw.images.length > MAX_IMAGES_PER_ENTRY) {
|
|
147
|
+
refuse(
|
|
148
|
+
where,
|
|
149
|
+
`images has ${raw.images.length} items; the limit is ${MAX_IMAGES_PER_ENTRY}`,
|
|
150
|
+
`send at most ${MAX_IMAGES_PER_ENTRY} images per entry`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
raw.images.forEach((item, imageIndex) => {
|
|
154
|
+
if (typeof item !== "string") {
|
|
155
|
+
refuse(where, `images[${imageIndex}] must be a string`, "send each image as a string url or data url");
|
|
156
|
+
}
|
|
157
|
+
if (!item.startsWith("data:image/") && !/^https?:\/\//.test(item)) {
|
|
158
|
+
refuse(
|
|
159
|
+
where,
|
|
160
|
+
`images[${imageIndex}] must start with "data:image/" or an http(s) url, got "${item.slice(0, 40)}"`,
|
|
161
|
+
"send a data:image/ data url or an http(s) url",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const bytes = Buffer.byteLength(item, "utf8");
|
|
165
|
+
if (bytes > MAX_IMAGE_BYTES) {
|
|
166
|
+
refuse(
|
|
167
|
+
where,
|
|
168
|
+
`images[${imageIndex}] is ${bytes} bytes; the limit is ${MAX_IMAGE_BYTES} bytes (5 MiB)`,
|
|
169
|
+
"send a smaller image, or link an http(s) url instead",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
imagesJson = JSON.stringify(raw.images);
|
|
174
|
+
}
|
|
175
|
+
|
|
126
176
|
let fileHash: string;
|
|
127
177
|
if (raw.file_content !== undefined && raw.file_hash !== undefined) {
|
|
128
178
|
refuse(
|
|
@@ -152,6 +202,7 @@ function validateEntry(index: number, raw: z.output<typeof FileEntryArgs>): NewE
|
|
|
152
202
|
return {
|
|
153
203
|
repo: raw.repo,
|
|
154
204
|
file: raw.file,
|
|
205
|
+
title: raw.title ?? null,
|
|
155
206
|
anchorText: raw.anchor_text,
|
|
156
207
|
anchorBefore: raw.anchor_before ?? "",
|
|
157
208
|
anchorAfter: raw.anchor_after ?? "",
|
|
@@ -162,6 +213,7 @@ function validateEntry(index: number, raw: z.output<typeof FileEntryArgs>): NewE
|
|
|
162
213
|
// re-file then keeps the stored value, per the COALESCE in src/db/queries.ts.
|
|
163
214
|
contextJson: raw.context == null ? null : JSON.stringify(context.data),
|
|
164
215
|
constraintsJson: raw.constraints == null ? null : JSON.stringify(constraints.data),
|
|
216
|
+
imagesJson,
|
|
165
217
|
};
|
|
166
218
|
}
|
|
167
219
|
|
|
@@ -177,7 +229,7 @@ export function registerFileEntriesTool(server: McpServer, baseUrl: string, stor
|
|
|
177
229
|
{
|
|
178
230
|
title: "File entries for review",
|
|
179
231
|
description:
|
|
180
|
-
"File draft entries for a human to author or approve.
|
|
232
|
+
"File draft entries for a human to author or approve. One entry is one edit: anchor_text is the whole passage being replaced and agent_draft the proposed replacement; either may be a single line or span many lines, and a multi-line block is ONE entry, never one entry per line. Each entry names repo (git remote url preferred), file path, an optional title (a human-readable label the dashboard shows in place of the file path), anchor_text, and anchor_before/anchor_after context lines. Identity is (project, repo, file, sha256(anchor_text)): re-filing a known draft overwrites the agent draft, context, and constraints in place; an entry already approved, applied, or rejected is returned untouched with its own status, and a rejected anchor stays rejected — stop proposing a turned-down passage. file provenance: send exactly one of file_content (the whole file at filing time; the server hashes it) or file_hash (its sha256 hex). constraints: {max_len?, placeholders?: string[], tone?, notes?} — a save breaking max_len or dropping a placeholder is refused later, so declare what the copy must keep. images: up to 8 per entry, each a data:image/ data url or an http(s) url of at most 5 MiB, rendered on the dashboard. Keys the schema does not list are ignored. The dashboard shows entries in the order the agent sends them, so file the most important entries first.",
|
|
181
233
|
inputSchema: z.object({
|
|
182
234
|
project: z.string().min(1),
|
|
183
235
|
entries: z.array(FileEntryArgs).min(1),
|
package/src/mcp/list-entries.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { listEntries, projectIdBySlug } from "../db/queries.ts";
|
|
4
|
+
import type { EntryRow } from "../db/queries.ts";
|
|
4
5
|
import type { Store } from "../db/store.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -18,13 +19,49 @@ const ListEntriesArgs = z.object({
|
|
|
18
19
|
cursor: z.string().min(1).optional(),
|
|
19
20
|
});
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* The one mapping from stored rows to the wire shape: every field spelled out, so a new store
|
|
24
|
+
* column reaches the wire only when someone adds it here deliberately — `human_notes` must never
|
|
25
|
+
* (the contract pins it dashboard-only), and `images` is carried on purpose. The SDK passes
|
|
26
|
+
* structuredContent through untouched, so this projection is the strip, not the schema.
|
|
27
|
+
*/
|
|
28
|
+
function toWireEntry(row: EntryRow): z.infer<typeof Entry> {
|
|
29
|
+
return {
|
|
30
|
+
id: row.id,
|
|
31
|
+
project_id: row.project_id,
|
|
32
|
+
batch_id: row.batch_id,
|
|
33
|
+
repo: row.repo,
|
|
34
|
+
file: row.file,
|
|
35
|
+
title: row.title,
|
|
36
|
+
anchor_text: row.anchor_text,
|
|
37
|
+
anchor_before: row.anchor_before,
|
|
38
|
+
anchor_after: row.anchor_after,
|
|
39
|
+
anchor_hash: row.anchor_hash,
|
|
40
|
+
file_hash: row.file_hash,
|
|
41
|
+
agent_draft: row.agent_draft,
|
|
42
|
+
human_text: row.human_text,
|
|
43
|
+
status: row.status,
|
|
44
|
+
context: row.context,
|
|
45
|
+
constraints: row.constraints,
|
|
46
|
+
filed_by: row.filed_by,
|
|
47
|
+
stale_note: row.stale_note,
|
|
48
|
+
applied_hash: row.applied_hash,
|
|
49
|
+
images: row.images,
|
|
50
|
+
created_at: row.created_at,
|
|
51
|
+
updated_at: row.updated_at,
|
|
52
|
+
applied_at: row.applied_at,
|
|
53
|
+
archived_at: row.archived_at,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The wire row shape, `context`, `constraints`, and `images` kept as their stored JSON strings — no re-parse on the way out. */
|
|
22
58
|
const Entry = z.object({
|
|
23
59
|
id: z.string(),
|
|
24
60
|
project_id: z.string(),
|
|
25
61
|
batch_id: z.string(),
|
|
26
62
|
repo: z.string(),
|
|
27
63
|
file: z.string(),
|
|
64
|
+
title: z.string().nullable(),
|
|
28
65
|
anchor_text: z.string(),
|
|
29
66
|
anchor_before: z.string(),
|
|
30
67
|
anchor_after: z.string(),
|
|
@@ -38,6 +75,7 @@ const Entry = z.object({
|
|
|
38
75
|
filed_by: z.string().nullable(),
|
|
39
76
|
stale_note: z.string().nullable(),
|
|
40
77
|
applied_hash: z.string().nullable(),
|
|
78
|
+
images: z.string(),
|
|
41
79
|
created_at: z.number(),
|
|
42
80
|
updated_at: z.number(),
|
|
43
81
|
applied_at: z.number().nullable(),
|
|
@@ -65,7 +103,7 @@ export function registerListEntriesTool(server: McpServer, store: Store): void {
|
|
|
65
103
|
{
|
|
66
104
|
title: "List entries for review",
|
|
67
105
|
description:
|
|
68
|
-
"List entries in the review queue, ordered by ascending entry id (ulid, i.e. filing order) — the same order a cursor walk covers. Every filter is optional and they combine with AND: project (a slug with no project is refused: read tools never create one), status (draft, approved, applied, rejected), ids (exact entry ids), q (case-insensitive substring, ASCII-only case fold: a row matches when any of file, anchor_text, agent_draft, human_text contains it), limit (default 50, max 200 — a higher value is refused), cursor (keyset pagination: pass the previous page's next_cursor to continue from after the last entry returned). next_cursor is present only when the page returned exactly limit rows, meaning more may exist; absent means the walk is exhausted — stop paging then, never craft a cursor of your own. Each entry carries its stored fields as stored: id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after, anchor_hash, file_hash, agent_draft, human_text, status, context, constraints, filed_by, stale_note, applied_hash, created_at, updated_at, applied_at, archived_at — with context and
|
|
106
|
+
"List entries in the review queue, ordered by ascending entry id (ulid, i.e. filing order) — the same order a cursor walk covers. Every filter is optional and they combine with AND: project (a slug with no project is refused: read tools never create one), status (draft, approved, applied, rejected), ids (exact entry ids), q (case-insensitive substring, ASCII-only case fold: a row matches when any of file, anchor_text, agent_draft, human_text contains it), limit (default 50, max 200 — a higher value is refused), cursor (keyset pagination: pass the previous page's next_cursor to continue from after the last entry returned). next_cursor is present only when the page returned exactly limit rows, meaning more may exist; absent means the walk is exhausted — stop paging then, never craft a cursor of your own. Each entry carries its stored fields as stored: id, project_id, batch_id, repo, file, title, anchor_text, anchor_before, anchor_after, anchor_hash, file_hash, agent_draft, human_text, status, context, constraints, filed_by, stale_note, applied_hash, images, created_at, updated_at, applied_at, archived_at — with context, constraints, and images as their stored JSON strings, not parsed objects. The human's own notes (human_notes) are dashboard-only and never appear in a listed entry.",
|
|
69
107
|
inputSchema: ListEntriesArgs,
|
|
70
108
|
outputSchema: ListEntriesOutput,
|
|
71
109
|
},
|
|
@@ -95,7 +133,7 @@ export function registerListEntriesTool(server: McpServer, store: Store): void {
|
|
|
95
133
|
// An exhausted walk carries no `next_cursor` key at all: absent is the signal to stop, so an
|
|
96
134
|
// empty page must never smuggle one in.
|
|
97
135
|
const output = {
|
|
98
|
-
entries: rows,
|
|
136
|
+
entries: rows.map(toWireEntry),
|
|
99
137
|
...(nextCursor === null ? {} : { next_cursor: nextCursor }),
|
|
100
138
|
};
|
|
101
139
|
return {
|
package/src/mcp/mark-applied.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function registerMarkAppliedTool(server: McpServer, store: Store): void {
|
|
|
81
81
|
refuse(
|
|
82
82
|
`entry "${args.id}"`,
|
|
83
83
|
"status rejected: rejected is terminal and silences the agent",
|
|
84
|
-
"file a new entry with a new anchor if the
|
|
84
|
+
"file a new entry with a new anchor if the passage still needs changing",
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
// The only pair left is an applied entry reported applied again: anchor_stale on an
|
package/src/mcp/route.ts
CHANGED
|
@@ -58,17 +58,14 @@ export function mountMcp(
|
|
|
58
58
|
store: Store,
|
|
59
59
|
notifier: Notifier = new Notifier({ config, baseUrl: config.baseUrl }),
|
|
60
60
|
): void {
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// `legacyVersions[0] ?? LATEST_PROTOCOL_VERSION`, and `LATEST_PROTOCOL_VERSION` is
|
|
67
|
-
// `"2025-11-25"`. A modern-only list empties `legacyVersions`, which is exactly what reaches
|
|
68
|
-
// that hardcoded fallback, so narrowing it is what would serve one.
|
|
61
|
+
// The native surface is the one revision `docs/mcp-contract.md` freezes, but `legacy: 'reject'`
|
|
62
|
+
// locked out the claude harness fleet, whose mcp client negotiates `2025-11-25` (measured
|
|
63
|
+
// 2026-08-21: 2.1.238 sends a legacy initialize and reads the refusal as a dead server).
|
|
64
|
+
// `legacy: 'stateless'` is the SDK default and answers 2025-era traffic from the same factory
|
|
65
|
+
// per request, so both legs stay stateless and no session is minted on either one.
|
|
69
66
|
// The store is the daemon's own open handle (`startDaemon` opens it before the port binds), so
|
|
70
67
|
// every tool call writes through the one connection the daemon will close on shutdown.
|
|
71
|
-
const handler = createMcpHandler(() => createMcpServer(config, store, notifier), { legacy: "
|
|
68
|
+
const handler = createMcpHandler(() => createMcpServer(config, store, notifier), { legacy: "stateless" });
|
|
72
69
|
|
|
73
70
|
// The `Origin` rung is `originGate`, mounted app-wide in `createApp`; it runs before this
|
|
74
71
|
// handler, so the pinned order (origin, then method, then credential) holds without a repeat of
|
package/src/mcp/server.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { NAME, VERSION } from "../version.ts";
|
|
|
16
16
|
*/
|
|
17
17
|
const INSTRUCTIONS = `reviewzy is a review queue for user-facing text. Agents draft, a human signs off.
|
|
18
18
|
|
|
19
|
-
File the
|
|
19
|
+
File the text you want to change as draft entries against a project. One entry is one edit: anchor_text is the whole passage being replaced and agent_draft the proposed replacement; either may be a single line or span many lines, and a multi-line block is ONE entry, never one entry per line. A human authors or approves the wording on the dashboard, then you fetch those words back and apply them in place. You can create a draft, move an approved entry to applied, and report an anchor that no longer matches. You can never approve or reject an entry, and a rejected anchor stays rejected: re-filing it returns the existing entry instead of proposing the passage again.`;
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* A fresh server per request, since the 2026-07-28 revision is stateless and `createMcpHandler`
|