papilot 0.6.0 → 0.8.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.
Files changed (85) hide show
  1. package/README.md +58 -30
  2. package/README.zh-CN.md +58 -30
  3. package/bin/doctor.mjs +53 -6
  4. package/bin/installation.d.mts +8 -0
  5. package/bin/installation.mjs +54 -0
  6. package/bin/papilot.mjs +6 -2
  7. package/dist/assets/_virtual_papilot-licenses-D7ro6ecN.js +7597 -0
  8. package/dist/assets/index-Cc-awdNn.css +1 -0
  9. package/dist/assets/index-D_VX51no.js +265 -0
  10. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  11. package/dist/assets/{math-DY-H5SfX.js → math-V9uUaUzA.js} +1 -1
  12. package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
  13. package/dist/index.html +2 -2
  14. package/examples/papilot-paper/main.tex +1 -1
  15. package/examples/papilot-paper/sections/02-using.tex +3 -1
  16. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  17. package/package.json +15 -7
  18. package/scripts/service.ts +1 -0
  19. package/skills/ai-review.md +18 -0
  20. package/skills/latex-warnings.md +1 -1
  21. package/src/conversation.ts +6 -0
  22. package/src/note-groups.ts +30 -0
  23. package/src/notes.ts +34 -10
  24. package/src/server/acp.ts +354 -0
  25. package/src/server/agents.ts +237 -47
  26. package/src/server/ai-review-routes.ts +72 -0
  27. package/src/server/ai-review.ts +38 -0
  28. package/src/server/annotation-placement.ts +99 -0
  29. package/src/server/app.ts +216 -21
  30. package/src/server/attachments.ts +128 -0
  31. package/src/server/build-problems.ts +329 -0
  32. package/src/server/changes.ts +28 -2
  33. package/src/server/codex.ts +2 -1
  34. package/src/server/cursor.ts +153 -0
  35. package/src/server/diagnostics.ts +45 -0
  36. package/src/server/git-overview.ts +69 -0
  37. package/src/server/imports.ts +205 -0
  38. package/src/server/latex-installation.ts +43 -0
  39. package/src/server/main.ts +8 -42
  40. package/src/server/manuscript.ts +212 -12
  41. package/src/server/note-results.ts +65 -0
  42. package/src/server/paths.ts +1 -1
  43. package/src/server/references.ts +70 -0
  44. package/src/server/review.ts +6 -5
  45. package/src/server/skill-library.ts +113 -0
  46. package/src/server/skill-routes.ts +19 -0
  47. package/src/server/skills.ts +29 -19
  48. package/src/server/start.ts +51 -0
  49. package/src/server/store.ts +44 -4
  50. package/src/server/update-install.ts +124 -0
  51. package/src/server/update-worker.ts +78 -0
  52. package/src/server/updates.ts +152 -0
  53. package/src/server/workspace.ts +450 -60
  54. package/src/shared.ts +120 -10
  55. package/src/skill-icons.ts +18 -0
  56. package/src/skill-metadata.ts +45 -0
  57. package/src/task-caption.ts +20 -0
  58. package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
  59. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  60. package/vendor/latex-review/dist/web/index.html +2 -2
  61. package/vendor/latex-review/src/core/bib.ts +692 -0
  62. package/vendor/latex-review/src/core/build.ts +5 -1
  63. package/vendor/latex-review/src/core/changes.ts +39 -0
  64. package/vendor/latex-review/src/core/delivery.ts +109 -0
  65. package/vendor/latex-review/src/core/review.ts +12 -4
  66. package/vendor/latex-review/src/core/store.ts +3 -0
  67. package/vendor/latex-review/src/core/tasks.ts +5 -2
  68. package/vendor/latex-review/src/core/verify.ts +784 -0
  69. package/vendor/latex-review/src/core/view.ts +204 -0
  70. package/vendor/latex-review/src/render/round.ts +11 -0
  71. package/vendor/latex-review/src/server.ts +81 -1
  72. package/vendor/latex-review/src/types.ts +141 -0
  73. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  74. package/vendor/latex-review/src/web/i18n.ts +1 -1
  75. package/vendor/latex-review/src/web/overview.tsx +1 -5
  76. package/vendor/latex-review/src/web/references.tsx +499 -0
  77. package/vendor/latex-review/src/web/review.tsx +381 -230
  78. package/vendor/latex-review/src/web/shortcuts.ts +53 -0
  79. package/vendor/latex-review/src/web/style.css +295 -0
  80. package/vendor/latex-review/src/web/zh-CN.ts +95 -26
  81. package/dist/assets/index-BNGqA0_L.css +0 -1
  82. package/dist/assets/index-Bt9UXsGX.js +0 -222
  83. package/dist/assets/pdf-view-BGp3W-RG.js +0 -598
  84. package/vendor/latex-review/dist/web/assets/index-AFBj4gQ9.js +0 -19
  85. package/vendor/latex-review/dist/web/assets/index-DfC0GURl.css +0 -1
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { createHash } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Repo } from "./git.ts";
6
6
  import { listFiles, readJson, writeJson, withLock, now } from "./files.ts";
@@ -197,6 +197,7 @@ export class Builder {
197
197
  rev: string,
198
198
  targets = ["full"],
199
199
  seed: BuildInfo | null = null,
200
+ options: { force?: boolean } = {},
200
201
  ): Promise<BuildInfo> {
201
202
  const p = this.project,
202
203
  partial = JSON.stringify(targets) !== '["full"]';
@@ -223,6 +224,9 @@ export class Builder {
223
224
  version,
224
225
  date,
225
226
  hash(fs.readFileSync(fileURLToPath(import.meta.url))),
227
+ // Manual recompilation gets a fresh output directory. Other previews
228
+ // and review rounds may still be reading the previous cached PDF.
229
+ ...(options.force ? [randomUUID()] : []),
226
230
  ]),
227
231
  ).slice(0, 32),
228
232
  folder = path.join(this.cache, key);
@@ -1,5 +1,6 @@
1
1
  import { Repo, within } from "./git.ts";
2
2
  import { groups, opcodes } from "./diff.ts";
3
+ import { entrySpans } from "./bib.ts";
3
4
  import type { Scope, Unit, Op } from "../types.ts";
4
5
  export const EXCERPT = 6000;
5
6
  export function textOf(data: Buffer | null): string | null {
@@ -34,6 +35,8 @@ export function diffUnits(
34
35
  const left = binary ? [] : splitLines(a!),
35
36
  right = binary ? [] : splitLines(b!);
36
37
  let hunks = binary ? [] : groups(opcodes(left, right));
38
+ if (!binary && file.endsWith(".bib"))
39
+ hunks = byEntry(hunks, entrySpans(a!), entrySpans(b!));
37
40
  const whole =
38
41
  binary || status !== "modified" || isFigure(file) || !hunks.length;
39
42
  if (whole) hunks = [[["replace", 0, left.length, 0, right.length]]];
@@ -61,6 +64,42 @@ export function diffUnits(
61
64
  }
62
65
  return units;
63
66
  }
67
+ // A bibliography entry is decided whole: hunks that touch the same entry,
68
+ // before or after, or the same key on either side, become one change, so
69
+ // accepting part of an entry cannot mix its old and new fields.
70
+ export function byEntry(
71
+ hunks: Op[][],
72
+ before: { key: string; span: [number, number] }[],
73
+ after: { key: string; span: [number, number] }[],
74
+ ) {
75
+ // An empty range touches an entry it falls strictly inside.
76
+ const touches = ([a, b]: [number, number], [s, e]: [number, number]) =>
77
+ a === b ? s < a && a < e : a < e && s < b;
78
+ const root = hunks.map((_, i) => i),
79
+ find = (i: number): number => (root[i] === i ? i : (root[i] = find(root[i]))),
80
+ owner = new Map<string, number>();
81
+ hunks.forEach((hunk, i) => {
82
+ const old: [number, number] = [hunk[0][1], hunk.at(-1)![2]],
83
+ next: [number, number] = [hunk[0][3], hunk.at(-1)![4]],
84
+ keys = [
85
+ ...before.filter((e) => touches(old, e.span)).map((e) => e.key),
86
+ ...after.filter((e) => touches(next, e.span)).map((e) => e.key),
87
+ ];
88
+ for (const key of keys) {
89
+ const j = owner.get(key);
90
+ if (j === undefined) owner.set(key, i);
91
+ else root[find(i)] = find(j);
92
+ }
93
+ });
94
+ const merged = new Map<number, Op[]>();
95
+ hunks.forEach((hunk, i) => {
96
+ const r = find(i);
97
+ merged.set(r, [...(merged.get(r) ?? []), ...hunk]);
98
+ });
99
+ return [...merged.values()].map((ops) =>
100
+ ops.sort((p, q) => p[1] - q[1] || p[3] - q[3]),
101
+ );
102
+ }
64
103
  function edits(units: Unit[], index: 1 | 3): Op[] {
65
104
  return units.flatMap((u) => u.ops).sort((a, b) => b[index] - a[index]);
66
105
  }
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { App } from "../server.ts";
4
+ import type { Delivery } from "../types.ts";
5
+ import { merge3 } from "./git.ts";
6
+ import { readBytes, same, writeOrDelete } from "./files.ts";
7
+ import { safePath } from "./review.ts";
8
+
9
+ const encode = (data: Buffer | null) => data?.toString("base64") ?? null;
10
+ const decode = (data: string | null) => data === null ? null : Buffer.from(data, "base64");
11
+
12
+ // Acceptance stays in Git objects until the author chooses a destination.
13
+ // Persist the exact intended result before touching files or a branch so a
14
+ // lost response or a restart can finish the same operation without duplication.
15
+ export class Deliveries {
16
+ constructor(public app: App) {}
17
+ get(name: string) {
18
+ return this.app.store.one<{ data: Delivery }>("SELECT data FROM deliveries WHERE task=?", name)?.data;
19
+ }
20
+ save(name: string, data: Delivery) {
21
+ this.app.store.execute("INSERT OR REPLACE INTO deliveries(task,data) VALUES(?,?)", name, JSON.stringify(data));
22
+ }
23
+ assertReady(name: string) {
24
+ const last = this.get(name);
25
+ if (last && !last.done) throw new Error("Finish the pending delivery before changing this review. Retry the same destination.");
26
+ }
27
+ deliver(name: string, mode: Delivery["mode"], message = "") {
28
+ const app = this.app, task = app.tasks.get(name), last = this.get(name);
29
+ if (!task.deferred) throw new Error("This task already applies accepted changes to the manuscript.");
30
+ if (mode !== "workspace" && mode !== "branch") throw new Error("Choose a working directory or task branch.");
31
+ if (last && last.mode !== mode) throw new Error("Continue using this task's original destination, or start a new task.");
32
+ if (last && (!last.done || last.accepted === task.accepted)) return this.finish(name, last);
33
+ if (app.publisher.busy(name) || app.review.counts(name).pending) throw new Error("Finish reviewing every change before choosing a destination.");
34
+ const before = last?.accepted ?? task.base;
35
+ const files = app.repo.changedFiles(before, task.accepted);
36
+ if (!files.length) throw new Error("This task has no accepted changes left to deliver.");
37
+ const base = app.repo.blobs(before, files), accepted = app.repo.blobs(task.accepted, files);
38
+ const data: Delivery = { mode, accepted: task.accepted, seq: app.store.one<{ seq: number }>("SELECT MAX(seq) AS seq FROM actions WHERE task=?", name)!.seq, done: false, writes: [] };
39
+ const contents: Record<string, Buffer | null> = {};
40
+ let current: Record<string, Buffer | null>;
41
+ if (mode === "branch") {
42
+ if (!message.trim() || message.length > 5000) throw new Error("Write a commit message (at most 5,000 characters).");
43
+ data.branch = `papilot/${name}`;
44
+ data.previous = last?.commit;
45
+ // The base snapshot may include the author's uncommitted edits. Apply
46
+ // only the accepted delta onto its original HEAD, never commit those edits.
47
+ data.parent = last?.commit ?? app.repo.rev(`${task.base}^`);
48
+ current = app.repo.blobs(data.parent, files);
49
+ const ref = app.repo.text(["show-ref", "--verify", "--hash", `refs/heads/${data.branch}`], { check: false });
50
+ if (ref !== (data.previous ?? "")) throw new Error("The task branch was changed outside Papilot. Nothing was committed.");
51
+ this.assertBranchAvailable(data.branch);
52
+ } else {
53
+ this.assertNoMerge();
54
+ current = Object.fromEntries(files.map(file => [file, readBytes(safePath(app.root, file))]));
55
+ }
56
+ for (const file of files) {
57
+ const [merged, conflict] = merge3(current[file], base[file], accepted[file]);
58
+ if (conflict) throw new Error(`Cannot deliver ${file}: it conflicts with the destination. Nothing was written. Revise the task and try again.`);
59
+ contents[file] = merged;
60
+ if (mode === "workspace" && !same(current[file], merged)) data.writes.push({ file, before: encode(current[file]), after: encode(merged) });
61
+ }
62
+ if (mode === "branch") {
63
+ const tree = app.repo.treeWith(data.parent!, contents);
64
+ data.commit = app.repo.text(["commit-tree", tree, "-p", data.parent!, "-m", message.trim()]);
65
+ }
66
+ this.save(name, data);
67
+ return this.finish(name, data);
68
+ }
69
+ private assertBranchAvailable(branch: string) {
70
+ if (this.app.repo.text(["worktree", "list", "--porcelain"]).split("\n").includes(`branch refs/heads/${branch}`))
71
+ throw new Error("The task branch is checked out. Switch that worktree to another branch before committing here.");
72
+ }
73
+ private assertNoMerge() {
74
+ if (fs.existsSync(path.resolve(this.app.root, this.app.repo.text(["rev-parse", "--git-path", "MERGE_HEAD"]))))
75
+ throw new Error("Finish or abort the merge before applying changes.");
76
+ }
77
+ finish(name: string, data: Delivery) {
78
+ if (data.done) return this.result(data);
79
+ const app = this.app;
80
+ if (data.mode === "branch") {
81
+ const ref = `refs/heads/${data.branch}`;
82
+ const current = app.repo.text(["show-ref", "--verify", "--hash", ref], { check: false });
83
+ if (current !== data.commit) {
84
+ this.assertBranchAvailable(data.branch!);
85
+ app.repo.run(["update-ref", ref, data.commit!, data.previous ?? "0".repeat(data.commit!.length)]);
86
+ }
87
+ } else {
88
+ this.assertNoMerge();
89
+ const writes = data.writes.map(write => ({ ...write, target: safePath(app.root, write.file) }));
90
+ for (const write of writes) {
91
+ const current = readBytes(write.target);
92
+ if (!same(current, decode(write.before)) && !same(current, decode(write.after)))
93
+ throw new Error(`Cannot finish delivery: ${write.file} changed. Restore it to its state before delivery and retry.`);
94
+ }
95
+ for (const write of writes) writeOrDelete(write.target, decode(write.after));
96
+ }
97
+ this.save(name, { ...data, done: true, writes: [] });
98
+ return this.result(data);
99
+ }
100
+ result(data: Delivery) {
101
+ const { mode, accepted, branch, commit } = data;
102
+ return { mode, accepted, ...(branch ? { branch, commit } : {}) };
103
+ }
104
+ recover() {
105
+ for (const row of this.app.store.all<{ task: string; data: Delivery }>("SELECT * FROM deliveries")) {
106
+ if (!row.data.done) try { this.finish(row.task, row.data); } catch { /* Retain the journal for an explicit retry. */ }
107
+ }
108
+ }
109
+ }
@@ -121,7 +121,8 @@ export class Review {
121
121
  "SELECT * FROM actions WHERE task=? AND status='applied' ORDER BY seq DESC LIMIT 1",
122
122
  name,
123
123
  );
124
- return action && latest && action.round === latest.number ? action : null;
124
+ const delivered = this.app.delivery.get(name);
125
+ return action && latest && action.round === latest.number && (!delivered || action.seq > delivered.seq) ? action : null;
125
126
  }
126
127
  summary(name: string): Summary {
127
128
  const task = this.app.tasks.get(name),
@@ -199,6 +200,10 @@ export class Review {
199
200
  closed: Boolean(task.closed),
200
201
  state: task.closed ? null : this.summary(name).state,
201
202
  latest: latest?.number ?? null,
203
+ history: this.app.store.all<{ number: number; created: number }>(
204
+ "SELECT number,created FROM rounds WHERE task=? ORDER BY number DESC",
205
+ name,
206
+ ),
202
207
  round: number ?? null,
203
208
  revision: round?.after ?? null,
204
209
  decisions: round ? this.decisions(name, round) : {},
@@ -256,6 +261,7 @@ export class Review {
256
261
  loose: render.loose,
257
262
  warnings: round.summary.warnings ?? [],
258
263
  files: `/files/${name}/${number}/`,
264
+ ...(render.references ? { references: render.references } : {}),
259
265
  };
260
266
  }
261
267
  history(name: string, round: Round): Record<string, History[]> {
@@ -360,6 +366,7 @@ export class Review {
360
366
  ) {
361
367
  const task = this.app.tasks.get(name),
362
368
  kind = data.action;
369
+ this.app.delivery.assertReady(name);
363
370
  if (!Object.hasOwn(DECIDED, kind))
364
371
  throw new Error("The action must be accept, reject or comment.");
365
372
  const identity = data.id || randomUUID();
@@ -440,8 +447,8 @@ export class Review {
440
447
  records = app.repo.blobs(task.accepted, files);
441
448
  for (const [file, group] of Object.entries(groups)) {
442
449
  const proposed = applyUnits(before[file], after[file], group),
443
- target = safePath(app.root, file),
444
- current = readBytes(target),
450
+ target = task.deferred ? "" : safePath(app.root, file),
451
+ current = task.deferred ? records[file] : readBytes(target),
445
452
  [merged, conflict] = merge3(current, before[file], proposed),
446
453
  [recorded, recordedConflict] = merge3(
447
454
  records[file],
@@ -449,7 +456,7 @@ export class Review {
449
456
  proposed,
450
457
  );
451
458
  if (conflict || recordedConflict) conflicts.push(file);
452
- else if (!same(merged, current)) writes.push([target, current, merged]);
459
+ else if (!task.deferred && !same(merged, current)) writes.push([target, current, merged]);
453
460
  accepted[file] = recorded;
454
461
  }
455
462
  if (conflicts.length)
@@ -672,6 +679,7 @@ export class Review {
672
679
  return this.pageState(name);
673
680
  }
674
681
  undo(name: string, identity: string) {
682
+ this.app.delivery.assertReady(name);
675
683
  const app = this.app,
676
684
  task = app.tasks.get(name),
677
685
  action = this.undoEntry(name);
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  const SCHEMA = `
5
5
  CREATE TABLE IF NOT EXISTS tasks(name TEXT PRIMARY KEY,title TEXT NOT NULL,worktree TEXT NOT NULL,base TEXT NOT NULL,accepted TEXT NOT NULL,scope TEXT NOT NULL,created REAL NOT NULL,closed REAL);
6
+ CREATE TABLE IF NOT EXISTS deliveries(task TEXT PRIMARY KEY,data TEXT NOT NULL);
6
7
  CREATE TABLE IF NOT EXISTS rounds(task TEXT NOT NULL,number INTEGER NOT NULL,before TEXT NOT NULL,after TEXT NOT NULL,units TEXT NOT NULL,summary TEXT NOT NULL,created REAL NOT NULL,PRIMARY KEY(task,number));
7
8
  CREATE TABLE IF NOT EXISTS actions(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,task TEXT NOT NULL,round INTEGER NOT NULL,kind TEXT NOT NULL,changes TEXT NOT NULL,body TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,detail TEXT NOT NULL DEFAULT '{}',created REAL NOT NULL);
8
9
  CREATE TABLE IF NOT EXISTS writes(action TEXT NOT NULL,phase TEXT NOT NULL,path TEXT NOT NULL,expected BLOB,target BLOB,PRIMARY KEY(action,phase,path));
@@ -36,6 +37,8 @@ export class Store {
36
37
  this.db = new DatabaseSync(file);
37
38
  this.db.exec("PRAGMA busy_timeout=30000; PRAGMA journal_mode=WAL;");
38
39
  this.db.exec(SCHEMA);
40
+ if (!this.all<{ name: string }>("PRAGMA table_info(tasks)").some(column => column.name === "deferred"))
41
+ this.db.exec("ALTER TABLE tasks ADD COLUMN deferred INTEGER NOT NULL DEFAULT 0");
39
42
  }
40
43
  transaction<T>(work: () => T): T {
41
44
  this.db.exec("BEGIN IMMEDIATE");
@@ -78,6 +78,7 @@ export class Tasks {
78
78
  title?: string,
79
79
  sections: string[] = [],
80
80
  files: string[] = [],
81
+ deferred = false,
81
82
  ): Task {
82
83
  const { app } = this,
83
84
  project = app.project;
@@ -154,10 +155,10 @@ export class Tasks {
154
155
  name,
155
156
  );
156
157
  app.store.execute("DELETE FROM tasks WHERE name=?", name);
157
- for (const table of ["rounds", "actions", "messages", "jobs"])
158
+ for (const table of ["rounds", "actions", "messages", "jobs", "deliveries"])
158
159
  app.store.execute(`DELETE FROM ${table} WHERE task=?`, name);
159
160
  app.store.execute(
160
- "INSERT INTO tasks(name,title,worktree,base,accepted,scope,created) VALUES(?,?,?,?,?,?,?)",
161
+ "INSERT INTO tasks(name,title,worktree,base,accepted,scope,created,deferred) VALUES(?,?,?,?,?,?,?,?)",
161
162
  name,
162
163
  title,
163
164
  worktree,
@@ -165,6 +166,7 @@ export class Tasks {
165
166
  base,
166
167
  JSON.stringify(scope),
167
168
  now(),
169
+ Number(deferred),
168
170
  );
169
171
  });
170
172
  } catch (error) {
@@ -196,6 +198,7 @@ export class Tasks {
196
198
  );
197
199
  }
198
200
  async finish(name: string, force = false) {
201
+ this.app.delivery.assertReady(name);
199
202
  const app = this.app,
200
203
  task = this.get(name);
201
204
  if (app.publisher.busy(name))