@evandu/super-pi 0.1.128 → 0.1.129

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 (46) hide show
  1. package/agents/super-pi-knowledge/index.ts +4 -3
  2. package/agents/super-pi-memory/index.ts +5 -4
  3. package/agents/super-pi-task/core.ts +8 -7
  4. package/dist/.super-pi-check-inputs.json +2 -2
  5. package/dist/client/.super-pi-client-sources.json +5 -5
  6. package/dist/client/assets/{CodeMirrorEditor-BhxCTVTL.js → CodeMirrorEditor-BNVBZZSS.js} +2 -2
  7. package/dist/client/assets/{dist-DERv1ty2.js → dist-B2nPVdKh.js} +1 -1
  8. package/dist/client/assets/{dist-BAmT9jUI.js → dist-B61RSa-K.js} +1 -1
  9. package/dist/client/assets/{dist-BPBlgusB.js → dist-BBW2SE_R.js} +1 -1
  10. package/dist/client/assets/{dist-BQK4xccN.js → dist-BDygQgZD.js} +1 -1
  11. package/dist/client/assets/{dist-C-hIztMW.js → dist-BKp4AWEJ.js} +1 -1
  12. package/dist/client/assets/{dist-Cl87QYqE.js → dist-BhJGK8-b.js} +1 -1
  13. package/dist/client/assets/{dist-Bplw4N5j.js → dist-BykVFExd.js} +1 -1
  14. package/dist/client/assets/{dist-K96Y1Tut.js → dist-C90MPTnk.js} +1 -1
  15. package/dist/client/assets/{dist-DVBpWkDV.js → dist-CMnvChDX.js} +1 -1
  16. package/dist/client/assets/{dist-BfwwrK33.js → dist-CRzRB9k3.js} +1 -1
  17. package/dist/client/assets/{dist-BeYwU1xB.js → dist-CWYRjnj-.js} +1 -1
  18. package/dist/client/assets/{dist-AtHUPHQe.js → dist-CZ9I2JCd.js} +1 -1
  19. package/dist/client/assets/{dist-8-xcbQPp.js → dist-ChszWGDv.js} +1 -1
  20. package/dist/client/assets/{dist-Bof_dYLf.js → dist-CiSGSVBZ.js} +1 -1
  21. package/dist/client/assets/{dist-Dc6xSTJL.js → dist-Cxerc7Bb.js} +1 -1
  22. package/dist/client/assets/{dist-DobX-p1U.js → dist-DB5M5fZG.js} +1 -1
  23. package/dist/client/assets/{dist-YUFYdtGJ.js → dist-DZnUERoW.js} +1 -1
  24. package/dist/client/assets/{dist-BJZUzzzf.js → dist-DmAOqr0M.js} +1 -1
  25. package/dist/client/assets/{dist-Bw8QOdGP.js → dist-Dq4-QFAJ.js} +1 -1
  26. package/dist/client/assets/{dist-icIMVISS.js → dist-GkIV1LTX.js} +1 -1
  27. package/dist/client/assets/{dist-DTXHzd8a.js → dist-HdeIBl2J.js} +1 -1
  28. package/dist/client/assets/{dist-5LsaarOt.js → dist-Ur37340K.js} +1 -1
  29. package/dist/client/assets/index-CXB6U5mz.css +1 -0
  30. package/dist/client/assets/{index-BvQ3umhR.js → index-D_c-CYAr.js} +35 -37
  31. package/dist/client/index.html +2 -2
  32. package/dist/server/active-memory.d.ts +8 -3
  33. package/dist/server/active-memory.js +44 -1
  34. package/dist/server/application/run-service.js +10 -1
  35. package/dist/server/git.d.ts +87 -0
  36. package/dist/server/git.js +301 -2
  37. package/dist/server/http-server.js +39 -7
  38. package/dist/server/pi-session.d.ts +9 -0
  39. package/dist/server/pi-session.js +12 -3
  40. package/dist/server/runtime.d.ts +12 -0
  41. package/dist/server/runtime.js +6 -0
  42. package/dist/server/store.js +16 -5
  43. package/dist/server/ws-server.js +3 -0
  44. package/package.json +2 -1
  45. package/scripts/diagnose.mjs +8 -3
  46. package/dist/client/assets/index-B8GInEf1.css +0 -1
@@ -8,8 +8,8 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-BvQ3umhR.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-B8GInEf1.css">
11
+ <script type="module" crossorigin src="/assets/index-D_c-CYAr.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-CXB6U5mz.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -24,6 +24,9 @@ export declare class ActiveMemory {
24
24
  readonly directory: string;
25
25
  private store;
26
26
  private runtime;
27
+ private readonly inverted;
28
+ private readonly indexedWorkspaces;
29
+ private readonly indexedMessages;
27
30
  readonly notes: MemoryStore;
28
31
  private capabilities;
29
32
  private reviews;
@@ -62,10 +65,12 @@ export declare class ActiveMemory {
62
65
  conversationId: string;
63
66
  title: string;
64
67
  messageId: string;
65
- role: import("./types.js").MessageRole;
68
+ role: string;
66
69
  score: number;
67
70
  content: string;
68
71
  }[];
72
+ private ensureIndex;
73
+ private addToIndex;
69
74
  tool(token: string, body: {
70
75
  kind?: string;
71
76
  query?: string;
@@ -77,7 +82,7 @@ export declare class ActiveMemory {
77
82
  conversationId: string;
78
83
  title: string;
79
84
  messageId: string;
80
- role: import("./types.js").MessageRole;
85
+ role: string;
81
86
  score: number;
82
87
  content: string;
83
88
  }[] | import("./memory.js").MemoryEntry | {
@@ -91,7 +96,7 @@ export declare class ActiveMemory {
91
96
  conversationId: string;
92
97
  title: string;
93
98
  messageId: string;
94
- role: import("./types.js").MessageRole;
99
+ role: string;
95
100
  score: number;
96
101
  content: string;
97
102
  }[];
@@ -6,6 +6,9 @@ export class ActiveMemory {
6
6
  directory;
7
7
  store;
8
8
  runtime;
9
+ inverted = new Map();
10
+ indexedWorkspaces = new Set();
11
+ indexedMessages = new Set();
9
12
  notes;
10
13
  capabilities = new Map();
11
14
  reviews = new Map();
@@ -105,7 +108,44 @@ export class ActiveMemory {
105
108
  const words = query.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu)?.slice(0, 12) ?? [];
106
109
  if (!words.length)
107
110
  return [];
108
- return this.store.snapshot().conversations.filter(c => c.workspaceId === workspaceId && (!sessionId || c.id === sessionId)).flatMap(c => c.messages.filter(m => m.role === "user" || m.role === "assistant").map(m => ({ conversationId: c.id, title: c.title, messageId: m.id, role: m.role, content: m.content, score: words.reduce((n, word) => n + (m.content.toLocaleLowerCase().includes(word) ? 1 : 0), 0) }))).filter(m => m.score > 0).sort((a, b) => b.score - a.score).slice(0, 6).map(m => ({ ...m, content: m.content.slice(0, 1200) }));
111
+ this.ensureIndex(workspaceId);
112
+ const candidates = new Map();
113
+ for (const word of words)
114
+ for (const [id, value] of this.inverted.get(workspaceId)?.get(word) ?? [])
115
+ void id, candidates.set(value.messageId, value);
116
+ return [...candidates.values()].filter(m => !sessionId || m.conversationId === sessionId).map(m => ({ ...m, score: words.reduce((n, word) => n + (m.content.toLocaleLowerCase().includes(word) ? 1 : 0), 0) })).filter(m => m.score > 0).sort((a, b) => b.score - a.score).slice(0, 6).map(m => ({ ...m, content: m.content.slice(0, 1200) }));
117
+ }
118
+ ensureIndex(workspaceId) {
119
+ if (this.indexedWorkspaces.has(workspaceId))
120
+ return;
121
+ const index = new Map();
122
+ for (const conversation of this.store.snapshot().conversations.filter(c => c.workspaceId === workspaceId))
123
+ for (const message of conversation.messages) {
124
+ if (message.role !== "user" && message.role !== "assistant")
125
+ continue;
126
+ const value = { conversationId: conversation.id, title: conversation.title, messageId: message.id, role: message.role, content: message.content };
127
+ this.indexedMessages.add(message.id);
128
+ for (const word of message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? []) {
129
+ let bucket = index.get(word);
130
+ if (!bucket)
131
+ index.set(word, bucket = new Map());
132
+ bucket.set(message.id, value);
133
+ }
134
+ }
135
+ this.inverted.set(workspaceId, index);
136
+ this.indexedWorkspaces.add(workspaceId);
137
+ }
138
+ addToIndex(conversation, message) {
139
+ if (!this.indexedWorkspaces.has(conversation.workspaceId) || (message.role !== "user" && message.role !== "assistant"))
140
+ return;
141
+ let terms = this.inverted.get(conversation.workspaceId);
142
+ const value = { conversationId: conversation.id, title: conversation.title, messageId: message.id, role: message.role, content: message.content };
143
+ for (const term of message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? []) {
144
+ let bucket = terms.get(term);
145
+ if (!bucket)
146
+ terms.set(term, bucket = new Map());
147
+ bucket.set(message.id, value);
148
+ }
109
149
  }
110
150
  async tool(token, body) {
111
151
  const identity = this.identity(token);
@@ -202,6 +242,9 @@ export class ActiveMemory {
202
242
  this.cancel(id); }
203
243
  async waitForIdle() { await Promise.allSettled([...this.workspaceReviews.values()]); await this.serial; }
204
244
  async completed(conversation) {
245
+ for (const message of conversation.messages)
246
+ if (!this.indexedMessages.has(message.id))
247
+ this.addToIndex(conversation, message);
205
248
  const settings = await this.configuration();
206
249
  if (!settings.enabled || !settings.reviewInterval)
207
250
  return;
@@ -239,7 +239,16 @@ export class RunService {
239
239
  // processes the result" contract, and the auto-triggered turn goes through the same
240
240
  // `composePrompt` path the success case does (so a helper failure surfaces as `state="error"`
241
241
  // prose the agent can react to).
242
- if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
242
+ const custom = this.runtime.sendCustomMessage;
243
+ if (custom) {
244
+ await custom.call(this.runtime, task.conversationId, {
245
+ customType: "super-pi-task-result",
246
+ content: body,
247
+ display: "后台任务结果",
248
+ details: { taskId: sessionId, source: "exec", state: exitCode === 0 ? "completed" : "error", summary: task.title },
249
+ });
250
+ }
251
+ else if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
243
252
  void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
244
253
  console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
245
254
  });
@@ -50,5 +50,92 @@ export declare function gitDiff(root: string, path: string, staged: boolean): Pr
50
50
  * version to compare against" - and the third is a refusal, which the caller reports.
51
51
  */
52
52
  export declare function gitHeadContent(root: string, path: string): Promise<string | null>;
53
+ /**
54
+ * A file larger than this is refused for blame (`too_large`) rather than blamed. Blame's cost scales
55
+ * with a text file's LINE count, and a file this size can carry millions of them, so the bound is
56
+ * checked from `stat` (working tree) or `git cat-file -s` (a revision) BEFORE any content is read -
57
+ * an oversized file costs one stat, never a buffer.
58
+ */
59
+ export declare const MAX_BLAME_FILE_BYTES: number;
60
+ /**
61
+ * The most lines one blame response may carry. `--line-porcelain` emits roughly 300-500 bytes of
62
+ * metadata per line, so this cap bounds the response and the parse to single-digit megabytes. A
63
+ * whole-file request for a longer file is answered with the FIRST `MAX_BLAME_LINES` entries and
64
+ * `truncated: true`; a caller that wants more asks for an explicit `startLine`/`endLine` window -
65
+ * which is what the gutter does anyway, and a window may not exceed this cap either.
66
+ */
67
+ export declare const MAX_BLAME_LINES = 20000;
68
+ export type GitBlameLine = {
69
+ /** 1-based line number in the file AS BLAMED - the working tree by default, or the requested revision. */
70
+ line: number;
71
+ commit: string | null;
72
+ shortCommit: string | null;
73
+ author: string | null;
74
+ authorEmail: string | null;
75
+ /** Author time as an ISO instant, or `null` when git did not supply a parseable one. */
76
+ date: string | null;
77
+ /** First line of the commit message. */
78
+ summary: string | null;
79
+ /** The line differs from the blame anchor and exists only in the working tree - git reported the
80
+ * all-zero commit for it. Such lines carry `null` for every other field: there is no commit to
81
+ * name, and inventing "Not Committed Yet" placeholders would put git's English prose into a
82
+ * structured contract the client is meant to phrase in its own words. */
83
+ isUncommitted: boolean;
84
+ };
85
+ /** Why blame is not available for a file that otherwise exists. Reported as `supported: false` inside
86
+ * a 200, NOT thrown: these are facts about the file or repository a gutter renders as a state word,
87
+ * not request errors. (`not_a_repo` is deliberately NOT one of these - it is thrown, so it maps to
88
+ * the same 404 the other /api/git ops return, per the endpoint's one shared error vocabulary.) */
89
+ export type GitBlameUnsupportedReason = "untracked" | "binary" | "too_large" | "no_head" | "not_in_revision";
90
+ export type GitBlameResponse = {
91
+ /** Workspace-relative path, always `/`-separated: the canonical form of what was requested, not an
92
+ * echo of it - `sub\inner.txt` and `sub/../sub/inner.txt` both come back as `sub/inner.txt`. */
93
+ path: string;
94
+ /** The revision blame was anchored to: the requested one, or `"HEAD"` for the default - which blames
95
+ * the WORKING TREE, which is why uncommitted lines can appear (flagged `isUncommitted`). */
96
+ revision: string;
97
+ lines: GitBlameLine[];
98
+ /** Lines in the file as blamed, including lines outside a requested window. */
99
+ totalLines: number;
100
+ /** True when a WHOLE-FILE request was cut at `MAX_BLAME_LINES`. A windowed request is never
101
+ * `truncated`: the caller asked for exactly what it got. */
102
+ truncated: boolean;
103
+ supported: boolean;
104
+ reason?: GitBlameUnsupportedReason;
105
+ };
106
+ export type GitBlameOptions = {
107
+ /** A commit-ish to blame instead of the working tree. Resolved through `rev-parse --verify` before
108
+ * anything else uses it; a revision git cannot resolve is a `bad_revision` error. */
109
+ revision?: string;
110
+ /** 1-based inclusive window. Both or neither; positive integers; start <= end; end <= totalLines;
111
+ * the span may not exceed `MAX_BLAME_LINES`. Violations throw `bad_range` with the bound named. */
112
+ startLine?: number;
113
+ endLine?: number;
114
+ /** RESERVED, currently a no-op: blame follows the requested path name only and does not aggregate
115
+ * history across renames. The field exists so a caller can state its intent without this contract
116
+ * growing a second shape on the day rename support lands. */
117
+ follow?: boolean;
118
+ };
119
+ /**
120
+ * Line-by-line provenance for one file: who last touched each line, in which commit.
121
+ *
122
+ * SECURITY BOUNDARY. `path` arrives from a browser and is contained exactly as `gitDiff` and
123
+ * `gitHeadContent` contain it - resolve against the workspace root, refuse anything that lands
124
+ * outside. Backslashes are normalised to `/` FIRST, so a Windows-spelled relative path resolves like
125
+ * the same request spelled with `/` and the git invocation and the response share the one separator
126
+ * this API speaks. `revision` arrives from the same untrusted place: it may not start with `-` (that
127
+ * is how an argument becomes a git FLAG) and may not carry whitespace or NUL. Every git call here is
128
+ * `execFile` with an argument array - no shell, no string interpolation into a command line - and the
129
+ * path always travels after a bare `--`, so no path can be read as an option.
130
+ *
131
+ * FAILURE SHAPE. Facts about the file or repository (`untracked`, `binary`, `too_large`, `no_head`,
132
+ * `not_in_revision`) come back as `supported: false` - the gutter renders them, it does not error on
133
+ * them. Request mistakes (path escapes, a missing file, a bad revision, an illegal range) throw with
134
+ * a stable `code` for the HTTP layer to map. Git's raw stderr never reaches a caller: expected
135
+ * failures are thrown as written sentences, and an unexpected git failure is logged here and thrown
136
+ * as one stable sentence.
137
+ */
138
+ export declare function gitBlame(root: string, path: string, options?: GitBlameOptions): Promise<GitBlameResponse>;
53
139
  /** Whether the directory is a git repository at all. The review view asks before it offers a git baseline. */
54
140
  export declare function isGitRepository(root: string): Promise<boolean>;
141
+ export declare function gitMutate(root: string, op: string, path?: string, message?: string): Promise<GitWorkspace>;
@@ -1,9 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { basename, resolve, sep } from "node:path";
3
+ import { basename, relative, resolve, sep } from "node:path";
4
4
  import { existsSync } from "node:fs";
5
+ import { readFile, stat } from "node:fs/promises";
5
6
  const exec = promisify(execFile);
6
- async function git(cwd, args) { return exec("git", ["-c", "core.quotepath=false", ...args], { cwd, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }); }
7
+ async function git(cwd, args, options) { return exec("git", ["-c", "core.quotepath=false", ...args], { cwd, windowsHide: true, maxBuffer: options?.maxBuffer ?? 8 * 1024 * 1024 }); }
7
8
  function files(raw) { const staged = [], unstaged = [], untracked = []; for (const line of raw.split("\0").filter(Boolean)) {
8
9
  const xy = line.slice(0, 2), path = line.slice(3).split("\0")[0].replaceAll("\\", "/");
9
10
  const item = { path, name: basename(path), xy };
@@ -63,7 +64,305 @@ export async function gitHeadContent(root, path) {
63
64
  return null;
64
65
  }
65
66
  }
67
+ // ---------------------------------------------------------------------------------------------
68
+ // Git blame.
69
+ //
70
+ // The blame gutter needs, for one file: who last touched each line, and in which commit. Git's
71
+ // `--line-porcelain` output is the only source for that, and this module is the only place allowed
72
+ // to parse it - the caller receives the structured `GitBlameLine[]` and never sees porcelain text,
73
+ // the same boundary `gitLog` already draws for its own format.
74
+ // ---------------------------------------------------------------------------------------------
75
+ /**
76
+ * A file larger than this is refused for blame (`too_large`) rather than blamed. Blame's cost scales
77
+ * with a text file's LINE count, and a file this size can carry millions of them, so the bound is
78
+ * checked from `stat` (working tree) or `git cat-file -s` (a revision) BEFORE any content is read -
79
+ * an oversized file costs one stat, never a buffer.
80
+ */
81
+ export const MAX_BLAME_FILE_BYTES = 2 * 1024 * 1024;
82
+ /**
83
+ * The most lines one blame response may carry. `--line-porcelain` emits roughly 300-500 bytes of
84
+ * metadata per line, so this cap bounds the response and the parse to single-digit megabytes. A
85
+ * whole-file request for a longer file is answered with the FIRST `MAX_BLAME_LINES` entries and
86
+ * `truncated: true`; a caller that wants more asks for an explicit `startLine`/`endLine` window -
87
+ * which is what the gutter does anyway, and a window may not exceed this cap either.
88
+ */
89
+ export const MAX_BLAME_LINES = 20000;
90
+ /** Output-buffer headroom for the blame process itself: MAX_BLAME_LINES records at ~500 bytes each is
91
+ * ~10MB, and 32MB leaves slack for long author names and summaries without being unbounded. */
92
+ const BLAME_MAX_BUFFER = 32 * 1024 * 1024;
93
+ /** How many leading bytes are sniffed for a NUL byte to classify content as binary - the same
94
+ * heuristic git's own diff machinery uses. A NUL byte cannot appear in UTF-8 text; a binary file's
95
+ * "lines" are meaningless to a gutter, and git blame happily produces metadata for them, so the
96
+ * refusal has to happen here, before the porcelain is ever asked for. */
97
+ const BLAME_SNIFF_BYTES = 8192;
98
+ function unsupportedBlame(path, revision, reason) {
99
+ return { path, revision, lines: [], totalLines: 0, truncated: false, supported: false, reason };
100
+ }
101
+ /** `\n`-delimited line count: a trailing newline does not open a line of its own, an empty file has
102
+ * none, and a lone `\n` is one (empty) line - matching what git blame itself will report. */
103
+ function countTextLines(text) {
104
+ if (text.length === 0)
105
+ return 0;
106
+ let count = 1;
107
+ for (let i = 0; i < text.length; i++)
108
+ if (text.charCodeAt(i) === 10)
109
+ count++;
110
+ if (text.charCodeAt(text.length - 1) === 10)
111
+ count--;
112
+ return count;
113
+ }
114
+ /**
115
+ * One porcelain record per line: `<40-hex sha> <orig-line> <final-line> [<count>]`, followed by the
116
+ * full header (`author`, `author-mail`, `author-time`, `summary`, `filename`, ...), followed by the
117
+ * line's own content prefixed with TAB. `--line-porcelain` repeats the full header for EVERY line, so
118
+ * parsing is stateless per record - no carry-over of "the commit from the previous record", which is
119
+ * the bug plain `--porcelain` invites. Two rules make the parse safe:
120
+ *
121
+ * 1. A record header is a 40-hex sha followed by two decimal numbers, and nothing else in the stream
122
+ * matches that - every other line is either `key value` metadata or TAB-prefixed content. The
123
+ * optional fourth number is the porcelain GROUP size (a run of consecutive lines from one commit);
124
+ * under `--line-porcelain` every line still gets its own record, so it is parsed and discarded.
125
+ * 2. The TAB-prefixed content line is skipped UNREAD. It is the one part of the stream that can hold
126
+ * arbitrary file bytes - NULs, line-splitting illusions, huge runs - and this API's contract is
127
+ * metadata only.
128
+ */
129
+ function parseBlamePorcelain(raw) {
130
+ const lines = [];
131
+ let current = null;
132
+ for (const line of raw.split("\n")) {
133
+ if (line.startsWith("\t"))
134
+ continue;
135
+ const header = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/.exec(line);
136
+ if (header) {
137
+ const sha = header[1];
138
+ const uncommitted = /^0{40}$/.test(sha);
139
+ current = {
140
+ line: Number(header[3]),
141
+ commit: uncommitted ? null : sha,
142
+ shortCommit: uncommitted ? null : sha.slice(0, 8),
143
+ author: null, authorEmail: null, date: null, summary: null,
144
+ isUncommitted: uncommitted,
145
+ };
146
+ lines.push(current);
147
+ continue;
148
+ }
149
+ if (!current)
150
+ continue;
151
+ // An uncommitted record still carries git's placeholders in its metadata ("Not Committed Yet", a
152
+ // pseudo address, the time of the diff run); the contract nulls every field but the flag, so that
153
+ // metadata is skipped rather than copied into the response.
154
+ if (current.isUncommitted)
155
+ continue;
156
+ const space = line.indexOf(" ");
157
+ const key = space === -1 ? line : line.slice(0, space);
158
+ const value = space === -1 ? "" : line.slice(space + 1);
159
+ if (key === "author")
160
+ current.author = value;
161
+ else if (key === "author-mail")
162
+ current.authorEmail = value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value;
163
+ else if (key === "author-time") {
164
+ const ms = Number(value) * 1000;
165
+ if (Number.isFinite(ms))
166
+ current.date = new Date(ms).toISOString();
167
+ }
168
+ else if (key === "summary")
169
+ current.summary = value;
170
+ }
171
+ return lines;
172
+ }
173
+ /**
174
+ * Line-by-line provenance for one file: who last touched each line, in which commit.
175
+ *
176
+ * SECURITY BOUNDARY. `path` arrives from a browser and is contained exactly as `gitDiff` and
177
+ * `gitHeadContent` contain it - resolve against the workspace root, refuse anything that lands
178
+ * outside. Backslashes are normalised to `/` FIRST, so a Windows-spelled relative path resolves like
179
+ * the same request spelled with `/` and the git invocation and the response share the one separator
180
+ * this API speaks. `revision` arrives from the same untrusted place: it may not start with `-` (that
181
+ * is how an argument becomes a git FLAG) and may not carry whitespace or NUL. Every git call here is
182
+ * `execFile` with an argument array - no shell, no string interpolation into a command line - and the
183
+ * path always travels after a bare `--`, so no path can be read as an option.
184
+ *
185
+ * FAILURE SHAPE. Facts about the file or repository (`untracked`, `binary`, `too_large`, `no_head`,
186
+ * `not_in_revision`) come back as `supported: false` - the gutter renders them, it does not error on
187
+ * them. Request mistakes (path escapes, a missing file, a bad revision, an illegal range) throw with
188
+ * a stable `code` for the HTTP layer to map. Git's raw stderr never reaches a caller: expected
189
+ * failures are thrown as written sentences, and an unexpected git failure is logged here and thrown
190
+ * as one stable sentence.
191
+ */
192
+ export async function gitBlame(root, path, options = {}) {
193
+ const relativePath = path.replaceAll("\\", "/");
194
+ if (!relativePath || relativePath.endsWith("/") || relativePath.includes("\0")) {
195
+ throw Object.assign(new Error("文件路径无效"), { code: "path" });
196
+ }
197
+ const safe = resolve(root, relativePath);
198
+ if (!safe.startsWith(resolve(root) + sep))
199
+ throw Object.assign(new Error("文件路径无效"), { code: "path" });
200
+ // The canonical `/`-separated form the response echoes - `sub\inner.txt` and `sub/../sub/inner.txt`
201
+ // both normalise to `sub/inner.txt`, so the client cannot learn a second spelling of one file.
202
+ const canonical = relative(resolve(root), safe).split(sep).join("/");
203
+ const isRepo = await git(root, ["rev-parse", "--git-dir"]).then(() => true).catch(() => false);
204
+ if (!isRepo)
205
+ throw Object.assign(new Error("此工作区不是 Git 仓库"), { code: "not_a_repo" });
206
+ const revision = options.revision?.trim() || undefined;
207
+ if (revision && (revision.startsWith("-") || /[\s\0]/.test(revision))) {
208
+ throw Object.assign(new Error("无效的 revision"), { code: "bad_revision" });
209
+ }
210
+ // An unborn repository has no commit for ANY line to point at, so blame is uniformly unavailable
211
+ // (`no_head`, a supported:false fact) rather than an error - the repository itself is fine, it just
212
+ // has no history yet. Checked before trackedness: the answer does not depend on the file.
213
+ if (!revision) {
214
+ const hasHead = await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false);
215
+ if (!hasHead)
216
+ return unsupportedBlame(canonical, "HEAD", "no_head");
217
+ }
218
+ // Resolve the anchor to a concrete commit BEFORE anything else uses it - this is what splits "the
219
+ // revision is bogus" (bad_revision) from "the file is not in that revision" (not_in_revision):
220
+ // both are git exit 128, and only ordering gives them distinct, stable codes.
221
+ let revSha;
222
+ if (revision) {
223
+ const resolved = await git(root, ["rev-parse", "--verify", `${revision}^{commit}`]).then((r) => r.stdout.trim()).catch(() => null);
224
+ if (!resolved)
225
+ throw Object.assign(new Error(`无法解析 revision:${revision}`), { code: "bad_revision" });
226
+ revSha = resolved;
227
+ }
228
+ // Trackedness is judged against the index, exactly as `gitDiff` judges it, so every op answers the
229
+ // same question the same way. A file on disk that the index has never heard of is `untracked` (a
230
+ // supported:false fact - the file is real and the gutter may well be showing it). A path that does
231
+ // not exist at all is an ERROR (`not_found`), deliberately distinct from `untracked`: "git does not
232
+ // know this file" and "there is no such file" are different sentences. A file tracked but deleted
233
+ // from the working tree is `not_found` for the default anchor - which reads the working tree, and
234
+ // git itself refuses with `Cannot lstat` there - while an explicit revision does not need the
235
+ // working-tree file at all and is allowed through.
236
+ const tracked = await git(root, ["ls-files", "--error-unmatch", "--", relativePath]).then(() => true).catch(() => false);
237
+ const onDisk = existsSync(safe);
238
+ if (!tracked && !onDisk)
239
+ throw Object.assign(new Error(`文件不存在:${canonical}`), { code: "not_found" });
240
+ if (!tracked)
241
+ return unsupportedBlame(canonical, revision ?? "HEAD", "untracked");
242
+ if (!onDisk && !revision)
243
+ throw Object.assign(new Error(`文件不存在:${canonical}(已从工作区删除)`), { code: "not_found" });
244
+ // Size, binary sniff and line count, from the content blame will actually read: the committed blob
245
+ // for an explicit revision, the working-tree file for the default anchor. One bounded read (capped
246
+ // above at MAX_BLAME_FILE_BYTES) yields all three, so no request ever streams an unbounded file
247
+ // into memory just to count it.
248
+ let content;
249
+ if (revSha) {
250
+ const size = await git(root, ["cat-file", "-s", `${revSha}:${relativePath}`]).then((r) => Number(r.stdout.trim())).catch(() => null);
251
+ if (size === null)
252
+ return unsupportedBlame(canonical, revision ?? "HEAD", "not_in_revision");
253
+ if (size > MAX_BLAME_FILE_BYTES)
254
+ return unsupportedBlame(canonical, revision ?? "HEAD", "too_large");
255
+ const blob = await git(root, ["cat-file", "blob", `${revSha}:${relativePath}`]).catch(() => null);
256
+ if (blob === null)
257
+ return unsupportedBlame(canonical, revision ?? "HEAD", "not_in_revision");
258
+ content = Buffer.from(blob.stdout, "utf8");
259
+ }
260
+ else {
261
+ try {
262
+ const info = await stat(safe);
263
+ if (info.size > MAX_BLAME_FILE_BYTES)
264
+ return unsupportedBlame(canonical, "HEAD", "too_large");
265
+ content = await readFile(safe);
266
+ }
267
+ catch {
268
+ // The file vanished between the existsSync above and this read - the same not_found, reached
269
+ // through a race instead of a pre-check.
270
+ throw Object.assign(new Error(`文件不存在:${canonical}`), { code: "not_found" });
271
+ }
272
+ }
273
+ const sniffEnd = Math.min(content.length, BLAME_SNIFF_BYTES);
274
+ for (let i = 0; i < sniffEnd; i++)
275
+ if (content[i] === 0)
276
+ return unsupportedBlame(canonical, revision ?? "HEAD", "binary");
277
+ const totalLines = countTextLines(content.toString("utf8"));
278
+ // The window. Validated strictly and AFTER totalLines is known, so each rejection can name the real
279
+ // bound ("the file has 12 lines") instead of a guess.
280
+ const startRaw = options.startLine;
281
+ const endRaw = options.endLine;
282
+ if ((startRaw === undefined) !== (endRaw === undefined)) {
283
+ throw Object.assign(new Error("startLine 与 endLine 必须同时提供"), { code: "bad_range" });
284
+ }
285
+ let start = 1;
286
+ let end = totalLines;
287
+ let windowed = false;
288
+ if (startRaw !== undefined && endRaw !== undefined) {
289
+ windowed = true;
290
+ if (!Number.isInteger(startRaw) || startRaw < 1)
291
+ throw Object.assign(new Error(`startLine 必须是正整数,收到 ${startRaw}`), { code: "bad_range" });
292
+ if (!Number.isInteger(endRaw) || endRaw < 1)
293
+ throw Object.assign(new Error(`endLine 必须是正整数,收到 ${endRaw}`), { code: "bad_range" });
294
+ if (startRaw > endRaw)
295
+ throw Object.assign(new Error(`startLine (${startRaw}) 不能大于 endLine (${endRaw})`), { code: "bad_range" });
296
+ if (endRaw > totalLines)
297
+ throw Object.assign(new Error(`endLine (${endRaw}) 超出文件行数 (${totalLines})`), { code: "bad_range" });
298
+ if (endRaw - startRaw + 1 > MAX_BLAME_LINES)
299
+ throw Object.assign(new Error(`行范围过大:单次最多 ${MAX_BLAME_LINES} 行`), { code: "bad_range" });
300
+ start = startRaw;
301
+ end = endRaw;
302
+ }
303
+ else if (totalLines > MAX_BLAME_LINES) {
304
+ // A whole-file request on an over-cap file is answered with the head of the file and `truncated`.
305
+ // The gutter asks in windows and never hits this; the prefix exists so a naive caller gets useful
306
+ // data rather than a refusal or an unbounded response.
307
+ end = MAX_BLAME_LINES;
308
+ }
309
+ const args = ["blame", "--line-porcelain"];
310
+ if (start !== 1 || end !== totalLines)
311
+ args.push("-L", `${start},${end}`);
312
+ if (revSha)
313
+ args.push(revSha);
314
+ args.push("--", relativePath);
315
+ let raw;
316
+ try {
317
+ raw = (await git(root, args, { maxBuffer: BLAME_MAX_BUFFER })).stdout;
318
+ }
319
+ catch (error) {
320
+ // Git's stderr is for this server's log, not for the user: an unexpected blame failure surfaces
321
+ // as one stable sentence with a stable code, and the raw output stays server-side.
322
+ console.error(`git blame failed for ${canonical}:`, error);
323
+ throw Object.assign(new Error("git blame 执行失败"), { code: "git_error" });
324
+ }
325
+ return {
326
+ path: canonical,
327
+ revision: revision ?? "HEAD",
328
+ lines: parseBlamePorcelain(raw),
329
+ totalLines,
330
+ truncated: !windowed && totalLines > MAX_BLAME_LINES,
331
+ supported: true,
332
+ };
333
+ }
66
334
  /** Whether the directory is a git repository at all. The review view asks before it offers a git baseline. */
67
335
  export async function isGitRepository(root) {
68
336
  return git(root, ["rev-parse", "--git-dir"]).then(() => true).catch(() => false);
69
337
  }
338
+ function safePath(root, path) {
339
+ const safe = resolve(root, path);
340
+ if (safe !== resolve(root) && !safe.startsWith(resolve(root) + sep))
341
+ throw Object.assign(new Error("文件路径无效"), { code: "path" });
342
+ return path;
343
+ }
344
+ export async function gitMutate(root, op, path, message) {
345
+ if (op === "stage" || op === "unstage" || op === "discard") {
346
+ if (!path)
347
+ throw Object.assign(new Error("缺少文件路径"), { code: "path" });
348
+ safePath(root, path);
349
+ if (op === "stage")
350
+ await git(root, ["add", "--", path]);
351
+ else if (op === "unstage")
352
+ await git(root, ["restore", "--staged", "--", path]);
353
+ else
354
+ await git(root, ["restore", "--", path]);
355
+ }
356
+ else if (op === "commit") {
357
+ if (!message?.trim())
358
+ throw Object.assign(new Error("提交消息不能为空"), { code: "bad_message" });
359
+ await git(root, ["commit", "-m", message.trim()]);
360
+ }
361
+ else if (op === "pull")
362
+ await git(root, ["pull", "--ff-only"]);
363
+ else if (op === "push")
364
+ await git(root, ["push"]);
365
+ else
366
+ throw Object.assign(new Error("未知 Git 操作"), { code: "bad_op" });
367
+ return gitStatus(root);
368
+ }
@@ -7,7 +7,7 @@ import { basename, dirname, extname, join, relative, resolve } from "node:path";
7
7
  import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, toRelative, writeWorkspaceFile, } from "./files.js";
8
8
  import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
9
9
  import { checkDiskVersion } from "./version.js";
10
- import { gitDiff, gitLog, gitStatus } from "./git.js";
10
+ import { gitBlame, gitDiff, gitLog, gitMutate, gitStatus } from "./git.js";
11
11
  import { computeFileDiff } from "./file-diff.js";
12
12
  import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
13
13
  import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
@@ -797,10 +797,6 @@ export function createHttpServer(options) {
797
797
  if (url.pathname === "/api/git") {
798
798
  const json = (status, body) => response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
799
799
  try {
800
- if (request.method !== "GET") {
801
- json(405, { error: "Git 视图需要 GET", code: "method" });
802
- return;
803
- }
804
800
  const workspaceId = url.searchParams.get("workspaceId");
805
801
  const workspace = context.store.snapshot().workspaces.find((item) => item.id === workspaceId);
806
802
  if (!workspace) {
@@ -808,6 +804,24 @@ export function createHttpServer(options) {
808
804
  return;
809
805
  }
810
806
  const op = url.searchParams.get("op") ?? "status";
807
+ // Blame is a read with a GET shape: every other verb is refused HERE, before the POST branch
808
+ // could ever route the op into gitMutate and before any body is read.
809
+ if (op === "blame" && request.method !== "GET") {
810
+ json(405, { error: "Git 请求方法不支持", code: "method" });
811
+ return;
812
+ }
813
+ if (request.method === "POST") {
814
+ let raw = "";
815
+ for await (const chunk of request)
816
+ raw += chunk;
817
+ const body = raw ? JSON.parse(raw) : {};
818
+ json(200, await gitMutate(workspace.path, op, body.path, body.message));
819
+ return;
820
+ }
821
+ if (request.method !== "GET") {
822
+ json(405, { error: "Git 请求方法不支持", code: "method" });
823
+ return;
824
+ }
811
825
  if (op === "status")
812
826
  json(200, await gitStatus(workspace.path));
813
827
  else if (op === "log")
@@ -820,6 +834,20 @@ export function createHttpServer(options) {
820
834
  }
821
835
  json(200, await gitDiff(workspace.path, path, url.searchParams.get("staged") === "1"));
822
836
  }
837
+ else if (op === "blame") {
838
+ const path = url.searchParams.get("path");
839
+ if (!path) {
840
+ json(400, { error: "blame 需要 path", code: "bad_path" });
841
+ return;
842
+ }
843
+ const startLine = url.searchParams.get("startLine");
844
+ const endLine = url.searchParams.get("endLine");
845
+ json(200, await gitBlame(workspace.path, path, {
846
+ revision: url.searchParams.get("revision") ?? undefined,
847
+ startLine: startLine === null ? undefined : Number(startLine),
848
+ endLine: endLine === null ? undefined : Number(endLine),
849
+ }));
850
+ }
823
851
  else
824
852
  json(400, { error: "未知 Git 操作", code: "bad_op" });
825
853
  }
@@ -830,6 +858,10 @@ export function createHttpServer(options) {
830
858
  json(404, { error: "此工作区不是 git 仓库", code: "not_a_repo" });
831
859
  else if (code === "path")
832
860
  json(400, { error: message, code: "bad_path" });
861
+ else if (code === "not_found")
862
+ json(404, { error: message, code: "not_found" });
863
+ else if (code === "bad_range" || code === "bad_revision")
864
+ json(400, { error: message, code });
833
865
  else
834
866
  json(400, { error: message, code: "git_error" });
835
867
  }
@@ -900,7 +932,7 @@ export function createHttpServer(options) {
900
932
  json(400, { error: "请求体不是合法的 JSON。" });
901
933
  return;
902
934
  }
903
- const { cmd, cwd, conversationId, capability, toolCallId } = body;
935
+ const { cmd, description, cwd, conversationId, capability, toolCallId } = body;
904
936
  if (!cmd || !cwd || !conversationId) {
905
937
  json(400, { error: "需要 cmd、cwd 和 conversationId。" });
906
938
  return;
@@ -929,7 +961,7 @@ export function createHttpServer(options) {
929
961
  await store.setBackgroundTask({
930
962
  id: sessionId,
931
963
  conversationId,
932
- title: cmd.length > 60 ? cmd.slice(0, 60) + "…" : cmd,
964
+ title: description?.trim() || (cmd.length > 60 ? cmd.slice(0, 60) + "…" : cmd),
933
965
  agentId: "exec",
934
966
  status: "running",
935
967
  startedAt: new Date().toISOString(),