reviewzy 0.1.0 → 0.3.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 +6780 -534
- package/src/dashboard/static/cursor-rules.js +83 -0
- package/src/dashboard/static/cursor.css +105 -0
- package/src/dashboard/static/cursor.js +358 -0
- package/src/dashboard/static/dashboard.css +139 -77
- package/src/dashboard/static/dashboard.js +147 -29
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/icons.svg +184 -0
- package/src/dashboard/static/tokens.css +421 -110
- package/src/dashboard/static/ui.js +1992 -0
- package/src/dashboard/views.tsx +600 -282
- 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 +36 -13
- package/src/db/sweep.ts +2 -2
- package/src/mcp/await-approved.ts +17 -10
- package/src/mcp/fetch-approved.ts +11 -6
- package/src/mcp/file-entries.ts +95 -20
- package/src/mcp/list-entries.ts +54 -29
- package/src/mcp/mark-applied.ts +6 -6
- package/src/mcp/route.ts +6 -9
- package/src/mcp/server.ts +8 -3
- 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 = {
|
|
@@ -41,13 +43,14 @@ export type FiledBatch = {
|
|
|
41
43
|
type ProjectRow = { id: string };
|
|
42
44
|
type IdentityRow = { id: string; status: EntryStatus };
|
|
43
45
|
|
|
44
|
-
/** One entry row as
|
|
46
|
+
/** One entry row as the store's shared list query returns it: the columns `docs/mcp-contract.md`'s entry schema lists, `archived_at` included (every v1 row carries null — the archive sweep is not built yet). The dashboard consumes the full row; the mcp `list_entries` tool projects it down to the contract's lean index row. */
|
|
45
47
|
export type EntryRow = {
|
|
46
48
|
readonly id: string;
|
|
47
49
|
readonly project_id: string;
|
|
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],
|
|
@@ -25,8 +25,8 @@ export function clampTimeoutMs(ms: number): number {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const AwaitApprovedArgs = z.object({
|
|
28
|
-
ids: z.array(z.string().min(1)).min(1),
|
|
29
|
-
timeout_ms: z.number().int().min(1).default(AWAIT_TIMEOUT_DEFAULT_MS),
|
|
28
|
+
ids: z.array(z.string().min(1)).min(1).describe("The exact entry ids to wait for; duplicates are dropped."),
|
|
29
|
+
timeout_ms: z.number().int().min(1).default(AWAIT_TIMEOUT_DEFAULT_MS).describe("How long to block (max: 600000, default 60000). A larger value is clamped."),
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
const AwaitApprovedOutput = z.object({
|
|
@@ -34,12 +34,14 @@ const AwaitApprovedOutput = z.object({
|
|
|
34
34
|
statuses: z.record(z.string(), z.enum(["draft", "approved", "applied", "rejected"])),
|
|
35
35
|
entries: z.array(ApprovedEntry).optional(),
|
|
36
36
|
poll_again_after_ms: z.number().int().min(1).optional(),
|
|
37
|
+
missing_ids: z.array(z.string()).optional(),
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
type AwaitOutcome = {
|
|
40
41
|
readonly resolved: boolean;
|
|
41
42
|
readonly statuses: Record<string, EntryStatus>;
|
|
42
43
|
readonly entries: z.infer<typeof ApprovedEntry>[];
|
|
44
|
+
readonly missingIds: string[];
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
/** A refusal is an ordinary thrown Error: the SDK answers it as a tool result with `isError: true`, never as a json-rpc protocol error. */
|
|
@@ -77,16 +79,16 @@ function waitForEntries(store: Store, ids: readonly string[], timeoutMs: number)
|
|
|
77
79
|
statuses[row.id] = row.status;
|
|
78
80
|
if (row.status === "approved") approved.push(row);
|
|
79
81
|
}
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
82
|
+
// A row that vanished since the up-front check (the retention sweep moves rows to the
|
|
83
|
+
// archive) must not resolve the wait, and its id must not sit silently absent from the
|
|
84
|
+
// statuses map on a non-resolved reply: the gap is named in missing_ids instead.
|
|
85
|
+
const missingIds = ids.filter((id) => statuses[id] === undefined);
|
|
84
86
|
const done =
|
|
85
87
|
rows.length === ids.length &&
|
|
86
88
|
rows.every((row) => row.status === "approved" || row.status === "rejected");
|
|
87
89
|
return done
|
|
88
|
-
? { resolved: true, statuses, entries: approvedRowsToWire(approved) }
|
|
89
|
-
: { resolved: false, statuses, entries: [] };
|
|
90
|
+
? { resolved: true, statuses, entries: approvedRowsToWire(approved), missingIds: [] }
|
|
91
|
+
: { resolved: false, statuses, entries: [], missingIds };
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
function recheck(): void {
|
|
@@ -125,7 +127,7 @@ export function registerAwaitApprovedTool(server: McpServer, store: Store): void
|
|
|
125
127
|
{
|
|
126
128
|
title: "Wait for entries to be approved or rejected",
|
|
127
129
|
description:
|
|
128
|
-
"
|
|
130
|
+
"Blocks until every named entry reaches approved or rejected; the client may park the call and deliver the result when it lands. This is the long-poll for a live session that filed entries and wants to sleep until the human signs off, rather than poll `fetch_approved`. Only approved or rejected resolves the wait; applied does not.",
|
|
129
131
|
inputSchema: AwaitApprovedArgs,
|
|
130
132
|
outputSchema: AwaitApprovedOutput,
|
|
131
133
|
},
|
|
@@ -146,7 +148,12 @@ export function registerAwaitApprovedTool(server: McpServer, store: Store): void
|
|
|
146
148
|
const outcome = await waitForEntries(store, ids, timeoutMs);
|
|
147
149
|
const output = outcome.resolved
|
|
148
150
|
? { resolved: true, statuses: outcome.statuses, entries: outcome.entries }
|
|
149
|
-
: {
|
|
151
|
+
: {
|
|
152
|
+
resolved: false,
|
|
153
|
+
statuses: outcome.statuses,
|
|
154
|
+
poll_again_after_ms: AWAIT_POLL_AGAIN_MS,
|
|
155
|
+
missing_ids: outcome.missingIds.length > 0 ? outcome.missingIds : undefined,
|
|
156
|
+
};
|
|
150
157
|
return {
|
|
151
158
|
content: [{ type: "text" as const, text: JSON.stringify(output) }],
|
|
152
159
|
structuredContent: output,
|
|
@@ -9,9 +9,9 @@ import type { Store } from "../db/store.ts";
|
|
|
9
9
|
* style guide is per-project, and the applying agent needs the voice of the project it writes into.
|
|
10
10
|
*/
|
|
11
11
|
const FetchApprovedArgs = z.object({
|
|
12
|
-
project: z.string().min(1),
|
|
13
|
-
ids: z.array(z.string().min(1)).min(1).optional(),
|
|
14
|
-
since: z.string().min(1).optional(),
|
|
12
|
+
project: z.string().min(1).describe("The project slug to fetch from."),
|
|
13
|
+
ids: z.array(z.string().min(1)).min(1).optional().describe("Only these exact entry ids. Combines with since: both filters apply."),
|
|
14
|
+
since: z.string().min(1).optional().describe("Resume a walk: pass the previous page's next_since to get the rows after it."),
|
|
15
15
|
});
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -42,7 +42,7 @@ export const ApprovedEntry = z.object({
|
|
|
42
42
|
|
|
43
43
|
const FetchApprovedOutput = z.object({
|
|
44
44
|
entries: z.array(ApprovedEntry),
|
|
45
|
-
style_guide: z.string(),
|
|
45
|
+
style_guide: z.string().optional(),
|
|
46
46
|
next_since: z.string().optional(),
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -100,7 +100,7 @@ export function registerFetchApprovedTool(server: McpServer, store: Store): void
|
|
|
100
100
|
{
|
|
101
101
|
title: "Fetch approved entries to apply",
|
|
102
102
|
description:
|
|
103
|
-
"Fetch
|
|
103
|
+
"Fetch a project's approved entries, ready to apply: the human-signed text, the anchor to locate and replace it by, and the constraints the replacement must keep. Only approved entries are returned: an id naming a draft, applied or rejected entry contributes nothing. At most 50 per page, ordered by ascending id; when next_since comes back, pass it back as since to continue. The merged style guide rides the first page of a walk (neither ids nor since) so bulk rewrites pay it once.",
|
|
104
104
|
inputSchema: FetchApprovedArgs,
|
|
105
105
|
outputSchema: FetchApprovedOutput,
|
|
106
106
|
},
|
|
@@ -118,7 +118,12 @@ export function registerFetchApprovedTool(server: McpServer, store: Store): void
|
|
|
118
118
|
|
|
119
119
|
const output = {
|
|
120
120
|
entries: approvedRowsToWire(rows),
|
|
121
|
-
|
|
121
|
+
// The guide rides a walk's first page only — a call carrying neither ids nor since.
|
|
122
|
+
// Continuation pages and targeted id fetches omit it: the same text is the style-guide
|
|
123
|
+
// resource, so a bulk rewrite pays it once per walk.
|
|
124
|
+
...(args.ids === undefined && args.since === undefined
|
|
125
|
+
? { style_guide: mergedStyleGuide(store, args.project) }
|
|
126
|
+
: {}),
|
|
122
127
|
// An exhausted walk carries no `next_since` key at all: absent is the signal to stop, so an
|
|
123
128
|
// empty page must never smuggle one in.
|
|
124
129
|
...(nextSince === null ? {} : { next_since: nextSince }),
|