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.
@@ -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. Whitespace-token based: the split keeps whitespace
4
- * runs as their own tokens, so both streams re-join to their exact input, bytes included.
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` streams, so nothing renders a mark.
30
+ * strings produce all-`same` tokens, so nothing renders a mark.
36
31
  */
37
- export function diffWords(before: string, after: string): WordDiff {
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
- before: a.map((text) => ({ text, kind: "removed" as const })),
44
- after: b.map((text) => ({ text, kind: "added" as const })),
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] ? below + 1 : Math.max(below, right);
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 beforeTokens: WordDiffToken[] = [];
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
- beforeTokens.push({ text: a[i]!, kind: "same" });
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
- beforeTokens.push({ text: a[i]!, kind: "removed" });
65
+ tokens.push({ text: a[i]!, kind: "removed" });
73
66
  i++;
74
67
  } else {
75
- afterTokens.push({ text: b[j]!, kind: "added" });
68
+ tokens.push({ text: b[j]!, kind: "added" });
76
69
  j++;
77
70
  }
78
71
  }
79
72
  while (i < a.length) {
80
- beforeTokens.push({ text: a[i]!, kind: "removed" });
73
+ tokens.push({ text: a[i]!, kind: "removed" });
81
74
  i++;
82
75
  }
83
76
  while (j < b.length) {
84
- afterTokens.push({ text: b[j]!, kind: "added" });
77
+ tokens.push({ text: b[j]!, kind: "added" });
85
78
  j++;
86
79
  }
87
80
 
88
- return { before: beforeTokens, after: afterTokens };
81
+ return tokens;
89
82
  }
@@ -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
- -- (cloudy, 2026-08-15; mcp-contract "three more tables").
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 (cloudy,
142
- -- 2026-08-16), so the column lands now, with the doc edit in the same round.
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 (cloudy, 2026-08-20). Nullable:
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,
@@ -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 and batch, newest batches first.",
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."),
@@ -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) }],