papilot 0.3.0 → 0.5.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 (34) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/bin/doctor.mjs +11 -0
  4. package/dist/assets/index-BqX4tIMz.js +217 -0
  5. package/dist/assets/index-C0hHDQdZ.css +1 -0
  6. package/dist/assets/{math-BTm2hvOz.js → math-CUuXX16h.js} +7 -7
  7. package/dist/assets/{pdf-view-BEn1EztO.js → pdf-view-CNYCI_LH.js} +2 -2
  8. package/dist/index.html +2 -2
  9. package/package.json +4 -2
  10. package/src/notes.ts +1 -1
  11. package/src/server/access.ts +170 -0
  12. package/src/server/accounts.ts +226 -0
  13. package/src/server/app.ts +345 -0
  14. package/src/server/changes.ts +295 -0
  15. package/src/server/frontend.ts +1 -1
  16. package/src/server/main.ts +22 -223
  17. package/src/server/manuscript.ts +40 -18
  18. package/src/server/paths.ts +4 -0
  19. package/src/server/security.ts +20 -0
  20. package/src/server/store.ts +5 -0
  21. package/src/server/workspace.ts +48 -18
  22. package/src/shared.ts +17 -4
  23. package/vendor/latex-review/dist/web/assets/index-Bq8CnbPr.js +19 -0
  24. package/vendor/latex-review/dist/web/assets/index-vftKrRf_.css +1 -0
  25. package/vendor/latex-review/dist/web/index.html +2 -2
  26. package/vendor/latex-review/src/render/pdf.ts +35 -1
  27. package/vendor/latex-review/src/web/main.tsx +8 -1
  28. package/vendor/latex-review/src/web/review.tsx +249 -109
  29. package/vendor/latex-review/src/web/style.css +6 -0
  30. package/vendor/latex-review/src/web/zh-CN.ts +35 -0
  31. package/dist/assets/index-BkowXmKr.js +0 -206
  32. package/dist/assets/index-Dq3s-vhH.css +0 -1
  33. package/vendor/latex-review/dist/web/assets/index-Bg-VqnPa.css +0 -1
  34. package/vendor/latex-review/dist/web/assets/index-xLeqYBJt.js +0 -19
@@ -37,14 +37,15 @@ export function parseStatus(output: string, numstat: string): Omit<GitStatus, 'h
37
37
  if (field.startsWith('# branch.head ')) status.branch = field.slice(14) === '(detached)' ? null : field.slice(14);
38
38
  else if (field.startsWith('# branch.upstream ')) status.upstream = field.slice(18);
39
39
  else if (field.startsWith('# branch.ab ')) { const [ahead, behind] = field.slice(12).split(' '); status.ahead = Number(ahead); status.behind = -Number(behind); }
40
- else if (field.startsWith('? ')) status.files.push({ path: field.slice(2), status: '?', staged: false });
40
+ else if (field.startsWith('? ')) status.files.push({ path: field.slice(2), status: '?', staged: false, unstaged: true });
41
41
  else if (/^[12u] /.test(field)) {
42
42
  const parts = field.split(' '), xy = parts[1], kind = field[0];
43
43
  const file = parts.slice(kind === '1' ? 8 : kind === '2' ? 9 : 10).join(' ');
44
44
  // A rename or copy is followed by its original path.
45
45
  const from = kind === '2' ? fields[++i] : undefined;
46
46
  const code = kind === 'u' ? 'U' : xy[1] !== '.' ? xy[1] : xy[0];
47
- status.files.push({ path: file, status: code, staged: xy[0] !== '.', lines: lines.get(file), ...(from ? { from } : {}) });
47
+ // X is the index against HEAD, Y the working tree against the index.
48
+ status.files.push({ path: file, status: code, staged: xy[0] !== '.', unstaged: xy[1] !== '.', lines: lines.get(file), ...(from ? { from } : {}) });
48
49
  }
49
50
  }
50
51
  return status;
@@ -91,23 +92,26 @@ export async function gitDiff(root: string, file: string) {
91
92
  const diff = await fileDiff(root, entry);
92
93
  return { path: file, diff: diff.slice(0, DIFF_LIMIT), truncated: diff.length > DIFF_LIMIT };
93
94
  }
94
- function fileDiff(root: string, entry: GitFile) {
95
+ // A file's diff against HEAD, or with staged, what its index holds.
96
+ function fileDiff(root: string, entry: GitFile, staged = false) {
97
+ const names = ['--', ...(entry.from ? [entry.from] : []), entry.path];
98
+ if (staged) return git(root, ['diff', '--cached', '--no-color', '--find-renames', ...names], literal);
95
99
  return entry.status === '?'
96
100
  // `git diff --no-index` exits with 1 when the files differ.
97
101
  ? exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', entry.path], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
98
- : git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), entry.path], literal);
102
+ : git(root, ['diff', '--no-color', '--find-renames', 'HEAD', ...names], literal);
99
103
  }
100
104
 
101
- // What a commit message is written from: the chosen files' diffs, each
102
- // shortened so that a large change still fits, and the repository's recent
103
- // commit subjects, whose style and language the message follows.
104
- export async function commitChanges(root: string, paths: string[]) {
105
- const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path));
106
- if (!files.length) throw new Error('Select at least one changed file.');
105
+ // What a commit message is written from: the chosen files' diffs, or what is
106
+ // staged in them, each shortened so that a large change still fits, and the
107
+ // repository's recent commit subjects, whose style and language the message follows.
108
+ export async function commitChanges(root: string, paths: string[], staged = false) {
109
+ const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path) && (!staged || item.staged));
110
+ if (!files.length) throw new Error(staged ? 'Stage at least one change.' : 'Select at least one changed file.');
107
111
  let patch = '';
108
112
  for (const [index, file] of files.entries()) {
109
113
  if (patch.length > 40_000) { patch += `\n[${files.length - index} more changed files are not shown.]\n`; break; }
110
- const diff = await fileDiff(root, file);
114
+ const diff = await fileDiff(root, file, staged);
111
115
  patch += diff.length > 8_000 ? `${diff.slice(0, 8_000)}\n[The rest of this file's diff is not shown.]\n` : diff;
112
116
  }
113
117
  const recent = (await git(root, ['log', '-8', '--format=%s']).catch(() => '')).split('\n').filter(Boolean);
@@ -128,11 +132,29 @@ export async function gitIgnore(root: string, paths: string[]) {
128
132
  return gitStatus(root);
129
133
  }
130
134
 
131
- // Commit exactly the chosen files as they are on disk. Changes staged
132
- // elsewhere for other files stay staged and out of this commit.
133
- export async function gitCommit(root: string, message: string, paths: string[]) {
135
+ // Stage or unstage whole files, as the Git panel's checkboxes do. A rename
136
+ // moves with both of its paths.
137
+ export async function gitStage(root: string, paths: string[], stage: boolean) {
134
138
  const status = await gitStatus(root), files = status.files.filter(item => paths.includes(item.path));
139
+ if (status.merging) throw new Error('Finish or abort the merge before staging other changes.');
140
+ if (!files.length) throw new Error('Select at least one changed file.');
141
+ const names = [...new Set(files.flatMap(item => item.from ? [item.from, item.path] : [item.path]))];
142
+ if (stage) await gitAction(root, ['add', '-A', '--', ...names]);
143
+ else await gitAction(root, status.head ? ['reset', '-q', 'HEAD', '--', ...names] : ['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...names]);
144
+ return gitStatus(root);
145
+ }
146
+
147
+ // Commit what is staged, or with paths, exactly those files as they are on
148
+ // disk; changes staged for other files then stay staged and out of the commit.
149
+ export async function gitCommit(root: string, message: string, paths: string[] | null) {
150
+ const status = await gitStatus(root);
135
151
  if (status.merging) throw new Error('Finish or abort the merge before committing other changes.');
152
+ if (!paths) {
153
+ if (!status.files.some(item => item.staged)) throw new Error('Stage at least one change to commit.');
154
+ await gitAction(root, ['commit', '-m', message]);
155
+ return (await git(root, ['rev-parse', '--short', 'HEAD'])).trim();
156
+ }
157
+ const files = status.files.filter(item => paths.includes(item.path));
136
158
  if (!files.length) throw new Error('Select at least one changed file to commit.');
137
159
  if (files.some(item => item.status === 'U')) throw new Error('Resolve the merge conflicts in the selected files before committing.');
138
160
  // A rename is already staged, so only its new path is added; the commit
@@ -210,13 +232,13 @@ export async function workingTree(root: string) {
210
232
  }
211
233
 
212
234
  async function engine() {
213
- const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter }] = await Promise.all([
235
+ const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter, walkText }] = await Promise.all([
214
236
  import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
215
237
  import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
216
238
  import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
217
239
  import(pathToFileURL(path.join(ENGINE, 'src/render/pdf.ts')).href),
218
240
  ]);
219
- return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter };
241
+ return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter, walkText };
220
242
  }
221
243
 
222
244
  // `synctex edit` maps a PDF position (big points from the top-left corner) to
@@ -293,7 +315,7 @@ export function sourceExcerpt(file: string, [start, end]: [number, number], limi
293
315
  // the page's top left. pdf.js lays a whole line out in a stand-in font and
294
316
  // drifts from the printed words mid-line; per-word runs cannot drift.
295
317
  export async function pdfWords(pdf: string, page: number) {
296
- const { PdfDocument } = await engine();
318
+ const { PdfDocument, walkText } = await engine();
297
319
  const document = new PdfDocument(pdf);
298
320
  try {
299
321
  if (!Number.isInteger(page) || page < 0 || page >= document.length) throw Object.assign(new Error('That page is not part of the manuscript.'), { statusCode: 400 });
@@ -301,7 +323,7 @@ export async function pdfWords(pdf: string, page: number) {
301
323
  const words: { text: string; left: number; right: number; baseline: number; size: number; eol: boolean }[] = [];
302
324
  let word: (typeof words)[number] | null = null;
303
325
  const text = raw.toStructuredText('preserve-whitespace');
304
- text.walk({
326
+ walkText(text, {
305
327
  beginLine: () => { word = null; },
306
328
  onChar: (c: string, origin: [number, number], _font: unknown, size: number, quad: number[]) => {
307
329
  if (/\s/u.test(c)) { word = null; return; }
@@ -8,4 +8,8 @@ export const VERSION: string = JSON.parse(fs.readFileSync(path.join(ROOT, 'packa
8
8
  export const PORT = Number(process.env.PAPILOT_PORT || 7274);
9
9
  export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || '';
10
10
  export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || '';
11
+ // The public URL where invited people sign in with passkeys, and the local
12
+ // port its proxy (such as Tailscale Funnel) forwards to.
13
+ export const ACCOUNT_ORIGIN = (process.env.PAPILOT_ACCOUNT_ORIGIN || '').replace(/\/+$/, '');
14
+ export const ACCOUNT_PORT = Number(process.env.PAPILOT_ACCOUNT_PORT || 7275);
11
15
  export const CODEX = process.env.PAPILOT_CODEX_BIN || (fs.existsSync(path.join(ROOT, 'node_modules/.bin/codex')) ? path.join(ROOT, 'node_modules/.bin/codex') : 'codex');
@@ -13,3 +13,23 @@ export function authorize(headers: IncomingHttpHeaders, remoteAddress: string |
13
13
  if (headers.origin && headers.origin !== publicOrigin) return 'The request origin does not match.';
14
14
  return null;
15
15
  }
16
+
17
+ // The public sign-in URL needs its own port, an exact HTTPS origin (plain HTTP
18
+ // only on localhost, for testing), and must not be the tailnet endpoint, whose
19
+ // requests are trusted as the owner.
20
+ export function accountProblem(accountOrigin: string, accountPort: number, port: number, publicOrigin: string): string | null {
21
+ if (!accountOrigin) return null;
22
+ let url: URL;
23
+ try { url = new URL(accountOrigin); } catch { return `PAPILOT_ACCOUNT_ORIGIN is not a URL: ${accountOrigin}`; }
24
+ if (url.origin !== accountOrigin) return `PAPILOT_ACCOUNT_ORIGIN must be an origin without a path, such as ${url.origin}.`;
25
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost')) return 'PAPILOT_ACCOUNT_ORIGIN must use https://.';
26
+ if (!Number.isInteger(accountPort) || accountPort < 1 || accountPort > 65535 || accountPort === port) return `PAPILOT_ACCOUNT_PORT must be a free port other than ${port}.`;
27
+ if (publicOrigin && new URL(publicOrigin).host === url.host) return 'PAPILOT_ACCOUNT_ORIGIN must differ from PAPILOT_PUBLIC_ORIGIN, the tailnet address; use another port, such as :10000.';
28
+ return null;
29
+ }
30
+ // HSTS on the public address covers every port of its host name, so it is
31
+ // left off when the tailnet endpoint on that host uses plain HTTP.
32
+ export function accountHsts(accountOrigin: string, publicOrigin: string) {
33
+ const account = new URL(accountOrigin);
34
+ return account.protocol === 'https:' && !(publicOrigin.startsWith('http:') && new URL(publicOrigin).hostname === account.hostname);
35
+ }
@@ -17,6 +17,11 @@ export class Store extends EventEmitter {
17
17
  CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, task_id TEXT NOT NULL, created INTEGER NOT NULL, data TEXT NOT NULL);
18
18
  CREATE TABLE IF NOT EXISTS deleted_tasks(id TEXT PRIMARY KEY, deleted INTEGER NOT NULL);
19
19
  CREATE TABLE IF NOT EXISTS annotations(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL);
20
+ CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, data TEXT NOT NULL);
21
+ CREATE TABLE IF NOT EXISTS credentials(id TEXT PRIMARY KEY, user_id TEXT NOT NULL, data TEXT NOT NULL);
22
+ CREATE TABLE IF NOT EXISTS invites(token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
23
+ CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, id TEXT UNIQUE NOT NULL, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
24
+ CREATE TABLE IF NOT EXISTS members(project_id TEXT NOT NULL, user_id TEXT NOT NULL, PRIMARY KEY(project_id, user_id));
20
25
  CREATE INDEX IF NOT EXISTS task_items ON items(task_id,created);
21
26
  CREATE INDEX IF NOT EXISTS task_runs ON runs(task_id,status);`);
22
27
  }
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import fs from 'node:fs';
@@ -13,7 +13,7 @@ import { findSkill, type SkillFile } from './skills.ts';
13
13
  import { buildFixPrompt, reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
14
14
  import { commitMessageFrom } from '../commit.ts';
15
15
  import { commitChanges, conflictMarkers, gitCommit, gitRemote, gitStatus, mergeConflicts, mergeInProgress } from './manuscript.ts';
16
- import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure } from '../shared.ts';
16
+ import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure, Author } from '../shared.ts';
17
17
 
18
18
  const execute = promisify(execFile);
19
19
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -43,6 +43,10 @@ const FIXABLE = /^(Some edits are outside the task scope|The revised document co
43
43
  const BUILD_FIX = 'build-fix-';
44
44
 
45
45
  const AGENTS: AgentId[] = ['codex', 'claude', 'antigravity'];
46
+ // The small model an agent offers for short text such as a commit message,
47
+ // by the names agents give them (GPT Luna and mini, Claude Haiku, Gemini Flash).
48
+ const LIGHT = /\b(?:luna|mini|nano|haiku|flash|lite)\b/i;
49
+ export const lightModel = (models: AgentModel[]) => models.find(model => LIGHT.test(model.id) || LIGHT.test(model.name));
46
50
  // A commit message's agent sees the changes in its prompt and needs no tools.
47
51
  const commitInstructions = 'You write Git commit messages for the repository of a LaTeX paper. Reply with the commit message only, without a preamble, quotes or a code fence: a subject line in the imperative mood under 72 characters and, only when the change needs more explanation, a blank line and a body of a few short plain sentences. Describe what changed in the paper, such as its wording, sections, equations, figures or references, rather than listing files. Follow the style and language of the recent commit subjects, or write in English when there are none. Everything you need is in the message: do not run commands, read files or edit anything.';
48
52
  // A merge's agent edits the conflicted files in the manuscript checkout and nothing else.
@@ -72,6 +76,9 @@ export class Workspace extends EventEmitter {
72
76
  private flushTimer: NodeJS.Timeout | null = null;
73
77
  private connecting: Promise<void> | null = null;
74
78
  private usageTimers = new Map<AgentId, NodeJS.Timeout>();
79
+ // Commit messages by agent, model and prompt, kept or still being written,
80
+ // so one written ahead for a finished review is there when the dialog opens.
81
+ private messages = new Map<string, Promise<{ message: string; agent: AgentId }>>();
75
82
  constructor(public store: Store, public codex: AgentRuntime = new Codex(), private concurrency = 2, private connectReview = reviewClient, others: Partial<Record<AgentId, AgentRuntime>> = {}) {
76
83
  super();
77
84
  this.agents = { codex, claude: others.claude || new ClaudeAgent(), antigravity: others.antigravity || new AntigravityAgent() };
@@ -137,10 +144,12 @@ export class Workspace extends EventEmitter {
137
144
  this.store.saveProject(project); return project;
138
145
  }
139
146
  // A task can start from a skill; its instructions are then the author's own words, possibly none.
140
- async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string }) {
147
+ async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string; author?: Author }) {
141
148
  if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.');
142
149
  if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.');
143
- const existing = this.store.tasks().find(t => t.id === input.id); if (existing) return existing;
150
+ // A retried request returns its task; an ID from another paper is refused.
151
+ const existing = this.store.tasks().find(t => t.id === input.id);
152
+ if (existing) { if (existing.projectId !== input.projectId) throw new Error('Invalid task ID.'); return existing; }
144
153
  if (this.preparing.has(input.id)) throw new Error('This task is being created. Refresh in a moment.');
145
154
  this.preparing.add(input.id);
146
155
  try {
@@ -153,14 +162,14 @@ export class Workspace extends EventEmitter {
153
162
  worktree: info.worktree, threadId: null, turnId: null, agent: input.agent || 'codex', model: input.model || null, effort: input.effort || null, status: 'idle',
154
163
  createdAt: Date.now(), updatedAt: Date.now(), feedbackCursor: 0, error: null, review: null };
155
164
  this.store.saveTask(task);
156
- await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill });
165
+ await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill, author: input.author });
157
166
  return this.store.task(task.id);
158
167
  } finally { this.preparing.delete(input.id); }
159
168
  }
160
169
  // The author's message, a skill run with or without one, the review
161
170
  // feedback alone, or a build the agent broke. The chat keeps what happened;
162
171
  // the agent gets the prompt.
163
- async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure } = {}) {
172
+ async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure; author?: Author } = {}) {
164
173
  const task = this.store.task(taskId);
165
174
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
166
175
  const existing = this.store.runByKey(requestKey);
@@ -179,7 +188,7 @@ export class Workspace extends EventEmitter {
179
188
  if (task.error) prompt += '\n\nLatest task/review error:\n' + task.error + '\nThe latest accepted manuscript is at ' + this.store.project(task.projectId).path + '. You may read it to understand accepted changes, but must only edit your isolated task worktree. Prepare a revised proposal for another review; never write to or accept changes in the manuscript checkout.';
180
189
  }
181
190
  const run: Run = { id: randomUUID(), taskId, requestKey, prompt, feedbackCursor: cursor, status: 'queued', createdAt: Date.now(), finishedAt: null };
182
- const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}) };
191
+ const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}), ...(options.author ? { author: options.author } : {}) };
183
192
  const saved = this.store.transaction(() => {
184
193
  // Feedback loading yields to other requests. Recheck identity, queue, and
185
194
  // task status together before committing; none of the earlier reads own a lock.
@@ -535,13 +544,13 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
535
544
  }
536
545
  // The agent that writes a commit message or resolves a merge: the one the
537
546
  // author chose, or else the first that is signed in, with the chosen model
538
- // or the default one.
539
- private helper(choice: { agent?: AgentId; model?: string }, task: string) {
547
+ // or the default one; light asks for the agent's small model when it has one.
548
+ private helper(choice: { agent?: AgentId; model?: string }, task: string, light = false) {
540
549
  const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
541
550
  const id = choice.agent && usable(choice.agent) ? choice.agent : AGENTS.find(usable);
542
551
  if (!id) throw new Error(`Sign in to Codex, Claude or Antigravity to ${task}.`);
543
552
  const models = this.runtimes[id].models;
544
- return { id, model: models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
553
+ return { id, model: (light ? lightModel(models) : undefined) || models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
545
554
  }
546
555
  // One turn on a thread the agent does not keep, outside any task: its
547
556
  // reply is returned and no chat shows it.
@@ -568,10 +577,12 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
568
577
  });
569
578
  }
570
579
  // Writes a commit message for the chosen manuscript files, like Cursor's
571
- // generate button: one read-only turn with the lightest reasoning the model offers.
572
- async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; model?: string } = {}) {
573
- const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message');
574
- const { files, patch, recent } = await commitChanges(project.path, paths);
580
+ // generate button: one read-only turn on the agent's small model, with the
581
+ // lightest reasoning it offers. The same changes get the message already
582
+ // written, or being written, for them unless the author asks for a fresh one.
583
+ async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; fresh?: boolean; staged?: boolean } = {}) {
584
+ const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message', true);
585
+ const { files, patch, recent } = await commitChanges(project.path, paths, choice.staged);
575
586
  const tasks = this.store.tasks().filter(task => task.projectId === projectId && task.review?.files?.some(file => paths.includes(file))).map(task => task.title);
576
587
  const prompt = [
577
588
  recent.length ? `Recent commit subjects in this repository:\n${recent.map(subject => `- ${subject}`).join('\n')}` : 'This repository has no commits yet.',
@@ -579,10 +590,29 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
579
590
  `Changed files:\n${files.map(file => `${file.status === '?' ? 'A' : file.status} ${file.from ? `${file.from} -> ` : ''}${file.path}`).join('\n')}`,
580
591
  `Diff:\n${patch}`,
581
592
  ].filter(Boolean).join('\n\n');
582
- const reply = await this.oneOff(id, { cwd: project.path, sandbox: 'read-only', instructions: commitInstructions, prompt, model, effort: model?.efforts[0], timeout: 120000, what: 'write a commit message' });
583
- const message = commitMessageFrom(reply);
584
- if (!message) throw new Error(`${AGENT_NAMES[id]} did not return a commit message. Try again.`);
585
- return { message, agent: id };
593
+ const key = createHash('sha256').update(`${id}\0${model?.id ?? ''}\0${prompt}`).digest('hex'), kept = this.messages.get(key);
594
+ if (kept && !choice.fresh) return kept;
595
+ const written = this.oneOff(id, { cwd: project.path, sandbox: 'read-only', instructions: commitInstructions, prompt, model, effort: model?.efforts[0], timeout: 120000, what: 'write a commit message' }).then(reply => {
596
+ const message = commitMessageFrom(reply);
597
+ if (!message) throw new Error(`${AGENT_NAMES[id]} did not return a commit message. Try again.`);
598
+ return { message, agent: id };
599
+ });
600
+ // Only the latest few messages are kept, and never a failed one.
601
+ this.messages.delete(key); this.messages.set(key, written);
602
+ for (const old of this.messages.keys()) { if (this.messages.size <= 20) break; this.messages.delete(old); }
603
+ written.catch(() => { if (this.messages.get(key) === written) this.messages.delete(key); });
604
+ return written;
605
+ }
606
+ // A finished review offers to commit what it accepted, so after the
607
+ // author's last decision its commit message is written ahead.
608
+ async prepareCommitMessage(taskId: string) {
609
+ try {
610
+ const task = this.store.task(taskId), files = task.review?.files || [];
611
+ if (task.status !== 'reviewing' || task.review?.counts?.pending || !files.length) return;
612
+ const project = this.store.project(task.projectId), git = await gitStatus(project.path);
613
+ const paths = git.merging ? [] : files.filter(file => git.files.some(item => item.path === file));
614
+ if (paths.length) await this.commitMessage(project.id, paths, { agent: this.agentOf(task) });
615
+ } catch { /* The commit dialog writes the message when it opens. */ }
586
616
  }
587
617
  // Resolves a merge's conflicts in the manuscript checkout itself: the agent
588
618
  // edits only the conflicted files, at the model's default reasoning, and
package/src/shared.ts CHANGED
@@ -20,7 +20,9 @@ export interface TimelineItem { id: string; taskId: string; type: string; text:
20
20
  // A user message can run a skill or carry review feedback; the chat shows
21
21
  // those as what they are rather than as the prompt the agent receives.
22
22
  // Papilot itself sends the agent a build its own edits broke.
23
- export interface MessageMeta { skill?: { id: string; name: string }; feedback?: FeedbackSummary; sendsFeedback?: boolean; build?: BuildFailure }
23
+ export interface MessageMeta { skill?: { id: string; name: string }; feedback?: FeedbackSummary; sendsFeedback?: boolean; build?: BuildFailure; author?: Author }
24
+ // Who wrote a message or note; none means the owner.
25
+ export interface Author { id: string; name: string }
24
26
  // A failed review build as the engine reports it; log is the compiler log's path.
25
27
  export interface BuildFailure { error: string; details: string[]; log?: string }
26
28
  export interface FeedbackSummary { round: number | null; accepted: number; rejected: number; undone: number; comments: { where: string; text: string }[]; buildError?: boolean }
@@ -32,11 +34,22 @@ export interface AgentModel { id: string; name: string; isDefault: boolean; effo
32
34
  export interface UsageWindow { usedPercent: number; minutes: number | null; resetsAt: number | null }
33
35
  export interface Usage { plan: string | null; windows: UsageWindow[]; credits: { unlimited: boolean; balance: number | null } | null; reached: boolean }
34
36
  export interface RuntimeStatus { connected: boolean; authenticated: boolean; account: string | null; error: string | null; models: AgentModel[]; usage?: Usage | null }
35
- export interface Snapshot { projects: Project[]; tasks: Task[]; requests: PendingRequest[]; runtime: RuntimeStatus; runtimes?: Partial<Record<AgentId, RuntimeStatus>>; version: string }
36
- export interface GitFile { path: string; status: string; staged: boolean; lines?: [number, number]; from?: string }
37
+ export interface Snapshot { projects: Project[]; tasks: Task[]; requests: PendingRequest[]; runtime: RuntimeStatus; runtimes?: Partial<Record<AgentId, RuntimeStatus>>; version: string; viewer?: Viewer }
38
+ // Who is looking: the owner on this host or Tailscale (local), or a person
39
+ // signed in with a passkey at the public URL (account). Older servers send none.
40
+ // owner names the owner, whose messages carry no author.
41
+ export interface Viewer { id: string; name: string; role: 'owner' | 'member'; listener: 'local' | 'account'; owner: string }
42
+ // A person the owner invited, as the People dialog shows them. joined means
43
+ // they have registered at least one passkey; projects are the shared papers.
44
+ export interface Person { id: string; name: string; role: 'owner' | 'member'; joined: boolean; projects: string[]; lastSeenAt: number | null; devices: Device[] }
45
+ // A passkey or a signed-in browser session, either of which the owner can revoke.
46
+ export interface Device { id: string; kind: 'passkey' | 'session'; label: string; createdAt: number; lastUsedAt: number | null }
47
+ export interface People { origin: string | null; people: Person[] }
48
+ // staged: the index has changes to this file; unstaged: the working tree has more.
49
+ export interface GitFile { path: string; status: string; staged: boolean; unstaged: boolean; lines?: [number, number]; from?: string }
37
50
  // merging is a merge in progress, with the files whose conflict markers remain.
38
51
  export interface GitStatus { branch: string | null; upstream: string | null; ahead: number; behind: number; files: GitFile[]; head: { hash: string; subject: string; date: number } | null; remote?: boolean; merging?: { message: string; conflicts: string[] } | null }
39
52
  export interface GitDiff { path: string; diff: string; truncated: boolean }
40
53
  export interface ManuscriptBuild { status: 'building' | 'ready' | 'failed'; key?: string; pages?: [number, number][]; builtAt?: number; error?: string; current: boolean }
41
- export interface Annotation { id: string; projectId: string; key: string; page: number; rect: [number, number, number, number]; rects?: [number, number, number, number][]; comment: string; quote: string; source: { file: string; lines: [number, number]; excerpt?: string } | null; createdAt: number; taskId?: string }
54
+ export interface Annotation { id: string; projectId: string; key: string; page: number; rect: [number, number, number, number]; rects?: [number, number, number, number][]; comment: string; quote: string; source: { file: string; lines: [number, number]; excerpt?: string } | null; createdAt: number; taskId?: string; author?: Author }
42
55
  export interface Manuscript { git: GitStatus; build: ManuscriptBuild; annotations: Annotation[] }