reviewzy 0.3.0 → 0.4.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/README.md +3 -3
- package/package.json +2 -2
- package/src/dashboard/mount.ts +20 -3
- package/src/dashboard/static/components.css +1196 -451
- package/src/dashboard/static/cursor-rules.js +30 -34
- package/src/dashboard/static/cursor.css +118 -89
- package/src/dashboard/static/cursor.js +1636 -245
- package/src/dashboard/static/dashboard.css +275 -179
- package/src/dashboard/static/dashboard.js +330 -185
- package/src/dashboard/static/tokens.css +240 -54
- package/src/dashboard/static/ui.js +666 -171
- package/src/dashboard/views.tsx +1138 -841
- package/src/dashboard/word-diff.ts +18 -25
- package/src/db/migrations.ts +5 -6
- package/src/db/queries.ts +37 -0
- package/src/mcp/file-entries.ts +1 -1
- package/src/mcp/list-entries.ts +11 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
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.
|
|
4
|
-
*
|
|
3
|
+
* render time so the marks need no client js. The walk returns one unified stream in reading
|
|
4
|
+
* order: kept tokens once, removed and added tokens in place between them. Whitespace-token
|
|
5
|
+
* based: the split keeps whitespace runs as their own tokens, so the tokens re-join to their
|
|
6
|
+
* exact inputs, bytes included.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
/** One token of a diffed string, carrying its mark. Whitespace runs are tokens too, always `same` when both sides share them. */
|
|
@@ -10,13 +12,6 @@ export type WordDiffToken = {
|
|
|
10
12
|
readonly kind: "same" | "removed" | "added";
|
|
11
13
|
};
|
|
12
14
|
|
|
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
15
|
/** Splits on whitespace boundaries, keeping the runs: `"a b"` becomes `["a", " ", "b"]`. */
|
|
21
16
|
function tokenize(text: string): string[] {
|
|
22
17
|
return text.split(/(\s+)/).filter((token) => token !== "");
|
|
@@ -32,17 +27,17 @@ const MAX_LCS_CELLS = 1_000_000;
|
|
|
32
27
|
/**
|
|
33
28
|
* A longest-common-subsequence walk over the two token streams: a token both sides share is
|
|
34
29
|
* `same`, one only the anchor has is `removed`, one only the proposal has is `added`. Identical
|
|
35
|
-
* strings produce all-`same`
|
|
30
|
+
* strings produce all-`same` tokens, so nothing renders a mark.
|
|
36
31
|
*/
|
|
37
|
-
export function diffWords(before: string, after: string):
|
|
32
|
+
export function diffWords(before: string, after: string): readonly WordDiffToken[] {
|
|
38
33
|
const a = tokenize(before);
|
|
39
34
|
const b = tokenize(after);
|
|
40
35
|
|
|
41
36
|
if (a.length * b.length > MAX_LCS_CELLS) {
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
37
|
+
return [
|
|
38
|
+
...a.map((text) => ({ text, kind: "removed" as const })),
|
|
39
|
+
...b.map((text) => ({ text, kind: "added" as const })),
|
|
40
|
+
];
|
|
46
41
|
}
|
|
47
42
|
|
|
48
43
|
// dp[i][j] is the LCS length of a[i:] against b[j:], filled bottom-up so the walk can pick the
|
|
@@ -53,37 +48,35 @@ export function diffWords(before: string, after: string): WordDiff {
|
|
|
53
48
|
for (let j = b.length - 1; j >= 0; j--) {
|
|
54
49
|
const below = dp[(i + 1) * stride + j]!;
|
|
55
50
|
const right = dp[i * stride + j + 1]!;
|
|
56
|
-
dp[i * stride + j] = a[i] === b[j] ?
|
|
51
|
+
dp[i * stride + j] = a[i] === b[j] ? dp[(i + 1) * stride + j + 1]! + 1 : Math.max(below, right);
|
|
57
52
|
}
|
|
58
53
|
}
|
|
59
54
|
|
|
60
|
-
const
|
|
61
|
-
const afterTokens: WordDiffToken[] = [];
|
|
55
|
+
const tokens: WordDiffToken[] = [];
|
|
62
56
|
let i = 0;
|
|
63
57
|
let j = 0;
|
|
64
58
|
while (i < a.length && j < b.length) {
|
|
65
59
|
if (a[i] === b[j]) {
|
|
66
|
-
|
|
67
|
-
afterTokens.push({ text: b[j]!, kind: "same" });
|
|
60
|
+
tokens.push({ text: a[i]!, kind: "same" });
|
|
68
61
|
i++;
|
|
69
62
|
j++;
|
|
70
63
|
} else if (dp[(i + 1) * stride + j]! >= dp[i * stride + j + 1]!) {
|
|
71
64
|
// Skipping the anchor's token keeps at least as long a match: it is removed.
|
|
72
|
-
|
|
65
|
+
tokens.push({ text: a[i]!, kind: "removed" });
|
|
73
66
|
i++;
|
|
74
67
|
} else {
|
|
75
|
-
|
|
68
|
+
tokens.push({ text: b[j]!, kind: "added" });
|
|
76
69
|
j++;
|
|
77
70
|
}
|
|
78
71
|
}
|
|
79
72
|
while (i < a.length) {
|
|
80
|
-
|
|
73
|
+
tokens.push({ text: a[i]!, kind: "removed" });
|
|
81
74
|
i++;
|
|
82
75
|
}
|
|
83
76
|
while (j < b.length) {
|
|
84
|
-
|
|
77
|
+
tokens.push({ text: b[j]!, kind: "added" });
|
|
85
78
|
j++;
|
|
86
79
|
}
|
|
87
80
|
|
|
88
|
-
return
|
|
81
|
+
return tokens;
|
|
89
82
|
}
|
package/src/db/migrations.ts
CHANGED
|
@@ -55,7 +55,7 @@ export const MIGRATIONS: Migration[] = [
|
|
|
55
55
|
|
|
56
56
|
-- entry_id keeps a plain NO ACTION foreign key: a sweep that archives an entry without
|
|
57
57
|
-- first moving its revisions must fail loudly rather than orphan or destroy them
|
|
58
|
-
-- (
|
|
58
|
+
-- (mcp-contract "three more tables").
|
|
59
59
|
CREATE TABLE entry_revisions (
|
|
60
60
|
id TEXT PRIMARY KEY NOT NULL,
|
|
61
61
|
entry_id TEXT NOT NULL REFERENCES entries(id),
|
|
@@ -93,8 +93,7 @@ export const MIGRATIONS: Migration[] = [
|
|
|
93
93
|
);
|
|
94
94
|
|
|
95
95
|
-- Mirrors entry_revisions plus archived_at; a sweep moves an entry's revisions here in the
|
|
96
|
-
-- same transaction as the entry's own move, so the history has no expiry
|
|
97
|
-
-- (cloudy, 2026-08-15; docs/design.md "revisions").
|
|
96
|
+
-- same transaction as the entry's own move, so the history has no expiry.
|
|
98
97
|
CREATE TABLE entry_revisions_archive (
|
|
99
98
|
id TEXT PRIMARY KEY NOT NULL,
|
|
100
99
|
entry_id TEXT NOT NULL REFERENCES entries_archive(id),
|
|
@@ -138,8 +137,8 @@ export const MIGRATIONS: Migration[] = [
|
|
|
138
137
|
name: "mark-applied",
|
|
139
138
|
sql: `
|
|
140
139
|
-- mark_applied stores the hash of the text the agent applied; the contract's param table
|
|
141
|
-
-- listed applied_hash a day before the entry schema had a column to hold it
|
|
142
|
-
--
|
|
140
|
+
-- listed applied_hash a day before the entry schema had a column to hold it, so the column
|
|
141
|
+
-- lands now, with the doc edit in the same round.
|
|
143
142
|
ALTER TABLE entries ADD COLUMN applied_hash TEXT;
|
|
144
143
|
-- entries_archive mirrors entries column-for-column: the retention sweep copies applied
|
|
145
144
|
-- rows over whole, so it must not lose the new column.
|
|
@@ -151,7 +150,7 @@ export const MIGRATIONS: Migration[] = [
|
|
|
151
150
|
name: "entry-title",
|
|
152
151
|
sql: `
|
|
153
152
|
-- 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
|
|
153
|
+
-- row reads as what it changes rather than where it lives. Nullable:
|
|
155
154
|
-- an entry without one falls back to its anchor text at render. The archive mirrors entries
|
|
156
155
|
-- column-for-column, so it gains the column too.
|
|
157
156
|
ALTER TABLE entries ADD COLUMN title TEXT;
|
package/src/db/queries.ts
CHANGED
|
@@ -154,6 +154,20 @@ export function countEntries(store: Store): number {
|
|
|
154
154
|
return row.n;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The store's counts per status, zero-filled for every status the schema's CHECK admits. The
|
|
159
|
+
* dashboard's four stat cards read one number each; the sidebar's badge reads `draft`.
|
|
160
|
+
*/
|
|
161
|
+
export function countEntriesByStatus(store: Store): Record<EntryStatus, number> {
|
|
162
|
+
const rows = store.db.query("SELECT status, COUNT(*) AS n FROM entries GROUP BY status").all() as {
|
|
163
|
+
status: EntryStatus;
|
|
164
|
+
n: number;
|
|
165
|
+
}[];
|
|
166
|
+
const counts: Record<EntryStatus, number> = { draft: 0, approved: 0, applied: 0, rejected: 0 };
|
|
167
|
+
for (const row of rows) counts[row.status] = row.n;
|
|
168
|
+
return counts;
|
|
169
|
+
}
|
|
170
|
+
|
|
157
171
|
/**
|
|
158
172
|
* `INSERT ... ON CONFLICT DO NOTHING` followed by the read: two calls racing to auto-create the
|
|
159
173
|
* same slug both succeed, and the second takes the first's id rather than dying on `UNIQUE(slug)`.
|
|
@@ -312,6 +326,29 @@ export function listEntries(store: Store, filter: ListEntriesFilter): ListEntrie
|
|
|
312
326
|
return { rows, nextCursor };
|
|
313
327
|
}
|
|
314
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Splits ids a read could not return into the two answers a caller acts on differently: never
|
|
331
|
+
* filed (a typo, a renamed entry) and archived (the retention sweep holds it). A live row another
|
|
332
|
+
* filter hid is in neither list — it exists, and only the filter excluded it, so it is not missing.
|
|
333
|
+
*/
|
|
334
|
+
export function resolveEntryIds(
|
|
335
|
+
store: Store,
|
|
336
|
+
ids: readonly string[],
|
|
337
|
+
): { missing: string[]; archived: string[] } {
|
|
338
|
+
const live = store.db
|
|
339
|
+
.query(`SELECT id FROM entries WHERE id IN (${ids.map(() => "?").join(", ")})`)
|
|
340
|
+
.all(...ids) as { id: string }[];
|
|
341
|
+
const liveIds = new Set(live.map((row) => row.id));
|
|
342
|
+
const missing = ids.filter((id) => !liveIds.has(id));
|
|
343
|
+
if (missing.length === 0) return { missing, archived: [] };
|
|
344
|
+
|
|
345
|
+
const archived = store.db
|
|
346
|
+
.query(`SELECT id FROM entries_archive WHERE id IN (${missing.map(() => "?").join(", ")})`)
|
|
347
|
+
.all(...missing) as { id: string }[];
|
|
348
|
+
const archivedIds = new Set(archived.map((row) => row.id));
|
|
349
|
+
return { missing, archived: missing.filter((id) => archivedIds.has(id)) };
|
|
350
|
+
}
|
|
351
|
+
|
|
315
352
|
/**
|
|
316
353
|
* The entries immediately before and after `id` in the ascending-id walk the list renders, for the
|
|
317
354
|
* editor's full-height prev/next rails; null at either boundary. The `id` column is a primary key,
|
package/src/mcp/file-entries.ts
CHANGED
|
@@ -252,7 +252,7 @@ export function registerFileEntriesTool(server: McpServer, baseUrl: string, stor
|
|
|
252
252
|
{
|
|
253
253
|
title: "File entries for review",
|
|
254
254
|
description:
|
|
255
|
-
"File the text you want changed as draft entries for a human to author, approve or reject on the dashboard. One entry is one edit: a multi-line block is ONE entry, not one entry per line. Re-filing a known anchor is safe: a draft updates in place; an approved, applied or rejected entry comes back untouched. The dashboard groups entries by project
|
|
255
|
+
"File the text you want changed as draft entries for a human to author, approve or reject on the dashboard. One entry is one edit: a multi-line block is ONE entry, not one entry per line. Re-filing a known anchor is safe: a draft updates in place; an approved, applied or rejected entry comes back untouched. The dashboard groups entries by project.",
|
|
256
256
|
inputSchema: z.object({
|
|
257
257
|
project: z.string().min(1).describe("The project slug: 1-63 chars of lowercase letters, digits, and inner hyphens. An unknown slug auto-creates the project on first file."),
|
|
258
258
|
entries: z.array(FileEntryArgs).min(1).describe("The entries to file. One edit per entry."),
|
package/src/mcp/list-entries.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { listEntries, listProjects, projectIdBySlug } from "../db/queries.ts";
|
|
3
|
+
import { listEntries, listProjects, projectIdBySlug, resolveEntryIds } from "../db/queries.ts";
|
|
4
4
|
import type { EntryRow } from "../db/queries.ts";
|
|
5
5
|
import type { Store } from "../db/store.ts";
|
|
6
6
|
|
|
@@ -70,6 +70,8 @@ const Entry = z.object({
|
|
|
70
70
|
const ListEntriesOutput = z.object({
|
|
71
71
|
entries: z.array(Entry),
|
|
72
72
|
next_cursor: z.string().optional(),
|
|
73
|
+
missing_ids: z.array(z.string()).optional(),
|
|
74
|
+
archived_ids: z.array(z.string()).optional(),
|
|
73
75
|
});
|
|
74
76
|
|
|
75
77
|
/** 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. */
|
|
@@ -88,7 +90,7 @@ export function registerListEntriesTool(server: McpServer, store: Store): void {
|
|
|
88
90
|
{
|
|
89
91
|
title: "List entries for review",
|
|
90
92
|
description:
|
|
91
|
-
"List the review queue's entries, ordered by ascending entry id. Every filter is optional and combines with AND. Walk pages via next_cursor; the walk is exhausted when it stops coming back. Each row is a lean index: id, project slug, file, title, status, updated_at. Apply-back fields (anchor, hashes, human text, constraints) come back through fetch_approved, and rejected or applied entries expose no agent-readable text anywhere — the dashboard owns authoring.",
|
|
93
|
+
"List the review queue's entries, ordered by ascending entry id. Every filter is optional and combines with AND. Walk pages via next_cursor; the walk is exhausted when it stops coming back. Each row is a lean index: id, project slug, file, title, status, updated_at. Apply-back fields (anchor, hashes, human text, constraints) come back through fetch_approved, and rejected or applied entries expose no agent-readable text anywhere — the dashboard owns authoring. An ids call adds missing_ids for an id no live row holds and archived_ids for one the sweep archived.",
|
|
92
94
|
inputSchema: ListEntriesArgs,
|
|
93
95
|
outputSchema: ListEntriesOutput,
|
|
94
96
|
},
|
|
@@ -119,9 +121,16 @@ export function registerListEntriesTool(server: McpServer, store: Store): void {
|
|
|
119
121
|
// empty page must never smuggle one in. The slug map mirrors the dashboard's own
|
|
120
122
|
// project_id → slug resolution (the store row carries the fk, never the slug).
|
|
121
123
|
const slugByProjectId = new Map(listProjects(store).map((p) => [p.id, p.slug]));
|
|
124
|
+
// Only a caller that asked by exact id needs the ids it cannot see; a filtered walk reports
|
|
125
|
+
// neither field, so its absence stays the statement that nothing was asked by id.
|
|
126
|
+
const resolved = args.ids === undefined ? undefined : resolveEntryIds(store, args.ids);
|
|
122
127
|
const output = {
|
|
123
128
|
entries: rows.map((row) => toWireEntry(row, slugByProjectId)),
|
|
124
129
|
...(nextCursor === null ? {} : { next_cursor: nextCursor }),
|
|
130
|
+
...(resolved === undefined ? {} : {
|
|
131
|
+
missing_ids: resolved.missing,
|
|
132
|
+
archived_ids: resolved.archived,
|
|
133
|
+
}),
|
|
125
134
|
};
|
|
126
135
|
return {
|
|
127
136
|
content: [{ type: "text" as const, text: JSON.stringify(output) }],
|