@evandu/super-pi 0.1.145 → 0.1.146
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/super-pi-task/core.ts +15 -2
- package/agents/super-pi-task/index.ts +3 -0
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +9 -9
- package/dist/client/assets/{CodeMirrorEditor-A9KvbIkm.js → CodeMirrorEditor-CEwBCVM6.js} +2 -2
- package/dist/client/assets/{dist-Br-AuQUG.js → dist-76ArZ5M4.js} +1 -1
- package/dist/client/assets/{dist-DSHM-PAs.js → dist-9F1wL-T5.js} +1 -1
- package/dist/client/assets/{dist-UJ46aPS3.js → dist-B0RXZeXQ.js} +1 -1
- package/dist/client/assets/{dist-BAiV8I_O.js → dist-BCNaDgNW.js} +1 -1
- package/dist/client/assets/{dist-4JbFTwg2.js → dist-BXBSIuTD.js} +1 -1
- package/dist/client/assets/{dist-UPJPMjWv.js → dist-Bh8Q7Y_n.js} +1 -1
- package/dist/client/assets/{dist-CIgjifH-.js → dist-BoyICDlL.js} +1 -1
- package/dist/client/assets/{dist-2boAf_pN.js → dist-BuR5Iagd.js} +1 -1
- package/dist/client/assets/{dist-B1p4YF9C.js → dist-Bx6ZWYUW.js} +1 -1
- package/dist/client/assets/{dist-BTVbZigv.js → dist-CAOcjKAQ.js} +1 -1
- package/dist/client/assets/{dist-CjszAoXk.js → dist-CHt8PX8R.js} +1 -1
- package/dist/client/assets/{dist-bIDce5j-.js → dist-CSCUT1PQ.js} +1 -1
- package/dist/client/assets/{dist-Ds5gfeAi.js → dist-CUB_Chdh.js} +1 -1
- package/dist/client/assets/{dist-D-o7iQ8c.js → dist-CbVf2Cjn.js} +1 -1
- package/dist/client/assets/{dist-B9DE7sc5.js → dist-DBUqwiWG.js} +1 -1
- package/dist/client/assets/{dist-DM4Olwfx.js → dist-DC1IocrS.js} +1 -1
- package/dist/client/assets/{dist-CK2KFFtT.js → dist-DFMtP3wG.js} +1 -1
- package/dist/client/assets/{dist-BYOpvW02.js → dist-DN0GlnnZ.js} +1 -1
- package/dist/client/assets/{dist-CQFASxyb.js → dist-DftD4ibj.js} +1 -1
- package/dist/client/assets/{dist-BlxwDnWr.js → dist-Do52MRwm.js} +1 -1
- package/dist/client/assets/{dist-CQV5kOCE.js → dist-JvQIIdqB.js} +1 -1
- package/dist/client/assets/{dist-CRnm1rnO.js → dist-oLq6HXLG.js} +1 -1
- package/dist/client/assets/{index-CiIc734l.css → index-D-OxlUxN.css} +1 -1
- package/dist/client/assets/index-E7yaLa2C.js +102 -0
- package/dist/client/index.html +2 -2
- package/dist/server/agents.d.ts +1 -1
- package/dist/server/agents.js +3 -1
- package/dist/server/application/conversation-service.d.ts +1 -1
- package/dist/server/application/conversation-service.js +6 -4
- package/dist/server/application/run-service.d.ts +16 -2
- package/dist/server/application/run-service.js +121 -14
- package/dist/server/application/scheduled-task-service.d.ts +14 -1
- package/dist/server/application/scheduled-task-service.js +27 -2
- package/dist/server/application/session-orchestrator-service.d.ts +11 -2
- package/dist/server/application/session-orchestrator-service.js +103 -27
- package/dist/server/command-handler.js +1 -1
- package/dist/server/conversation-usage.d.ts +19 -0
- package/dist/server/conversation-usage.js +40 -0
- package/dist/server/file-diff.d.ts +3 -0
- package/dist/server/file-diff.js +4 -4
- package/dist/server/file-index.d.ts +4 -2
- package/dist/server/file-index.js +14 -9
- package/dist/server/file-name-index.d.ts +5 -2
- package/dist/server/file-name-index.js +22 -5
- package/dist/server/git.d.ts +3 -3
- package/dist/server/git.js +10 -9
- package/dist/server/http-server.js +195 -33
- package/dist/server/model-context-window.d.ts +46 -0
- package/dist/server/model-context-window.js +121 -0
- package/dist/server/pi-session.d.ts +6 -0
- package/dist/server/pi-session.js +15 -1
- package/dist/server/runtime.d.ts +3 -0
- package/dist/server/runtime.js +21 -6
- package/dist/server/session-cwd.d.ts +17 -0
- package/dist/server/session-cwd.js +43 -0
- package/dist/server/session-ledger.d.ts +7 -0
- package/dist/server/store.d.ts +22 -0
- package/dist/server/store.js +52 -0
- package/dist/server/task-tool.d.ts +2 -1
- package/dist/server/task-tool.js +3 -1
- package/dist/server/terminal.d.ts +1 -0
- package/dist/server/terminal.js +5 -3
- package/dist/server/turn-failure.js +16 -1
- package/dist/server/types.d.ts +18 -0
- package/dist/server/version.d.ts +25 -2
- package/dist/server/version.js +98 -26
- package/dist/server/ws-server.d.ts +1 -1
- package/dist/server/ws-server.js +7 -5
- package/package.json +20 -3
- package/dist/client/assets/index-BGsP8pt_.js +0 -100
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { capText } from "../workflow.js";
|
|
4
|
+
import { assertSessionCwdInsideWorkspace } from "../session-cwd.js";
|
|
4
5
|
export const SESSION_OUTPUT_CHAR_LIMIT = 64 * 1024;
|
|
5
6
|
export const sessionResultRelativePath = (conversationId) => `.super-pi/session-results/${conversationId}.md`;
|
|
6
7
|
/**
|
|
@@ -26,14 +27,15 @@ export class SessionOrchestratorService {
|
|
|
26
27
|
* Spawns a new sibling conversation and immediately dispatches its initial task.
|
|
27
28
|
*/
|
|
28
29
|
async spawn(options) {
|
|
29
|
-
|
|
30
|
-
if (!workspaceId && options.parentConversationId) {
|
|
31
|
-
const parent = this.store.conversation(options.parentConversationId);
|
|
32
|
-
workspaceId = parent?.workspaceId;
|
|
33
|
-
}
|
|
30
|
+
const workspaceId = options.workspaceId?.trim();
|
|
34
31
|
if (!workspaceId) {
|
|
35
|
-
throw new Error("
|
|
32
|
+
throw new Error("创建兄弟会话必须显式指定 workspaceId;父会话只记录树形关系,不能代替目标工作区。");
|
|
36
33
|
}
|
|
34
|
+
const workspace = this.store.workspace(workspaceId);
|
|
35
|
+
if (!workspace)
|
|
36
|
+
throw new Error("找不到这个工作区。");
|
|
37
|
+
await assertSessionCwdInsideWorkspace(workspace.path, options.cwd);
|
|
38
|
+
const cwd = options.cwd;
|
|
37
39
|
// THE PARENTAGE IS WRITTEN AT CREATION TIME, onto the conversation itself - not only into the
|
|
38
40
|
// in-memory ledger below. The ledger is task/status/progress metadata and dies with the process;
|
|
39
41
|
// the conversation's parentConversationId/origin is what the sidebar's session tree redraws
|
|
@@ -41,7 +43,7 @@ export class SessionOrchestratorService {
|
|
|
41
43
|
const conversationId = await this.conversationService.createConversation(workspaceId, {
|
|
42
44
|
parentConversationId: options.parentConversationId,
|
|
43
45
|
origin: options.parentConversationId ? "orchestrator" : "user",
|
|
44
|
-
});
|
|
46
|
+
}, cwd);
|
|
45
47
|
// Set title if requested or derive from task
|
|
46
48
|
if (options.title) {
|
|
47
49
|
await this.conversationService.renameConversation(conversationId, options.title);
|
|
@@ -88,7 +90,11 @@ export class SessionOrchestratorService {
|
|
|
88
90
|
this.ledger.update(conversationId, {
|
|
89
91
|
status: "running",
|
|
90
92
|
});
|
|
91
|
-
|
|
93
|
+
// THE ORDER IS THE SYSTEM'S, NOT THE READER'S. It used to go through `enqueueAndRun`, which
|
|
94
|
+
// writes the words under the reader's own name - so the child's transcript showed an
|
|
95
|
+
// instruction the reader never typed, wearing the reader's avatar, and a reader who looked at
|
|
96
|
+
// the child session saw their own name on somebody else's order.
|
|
97
|
+
const runPromise = this.runService.enqueueAndRunAsAgent(conversationId, content);
|
|
92
98
|
const completion = (async () => {
|
|
93
99
|
let failure;
|
|
94
100
|
try {
|
|
@@ -175,11 +181,22 @@ export class SessionOrchestratorService {
|
|
|
175
181
|
if (conversation.status === "running" || conversation.status === "waiting") {
|
|
176
182
|
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
|
177
183
|
if (lastAssistant?.toolCalls?.length) {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
184
|
+
const isCompleted = (t) => t.status === "ok" || t.status === "error" || t.status === "done" || t.status === "failed";
|
|
185
|
+
const isRunning = (t) => !isCompleted(t) && (Boolean(t.preparing) || Boolean(t.live) || !t.status || t.status === "running");
|
|
186
|
+
const runningCall = [...lastAssistant.toolCalls].reverse().find(isRunning)
|
|
187
|
+
?? (!isCompleted(lastAssistant.toolCalls[lastAssistant.toolCalls.length - 1])
|
|
188
|
+
? lastAssistant.toolCalls[lastAssistant.toolCalls.length - 1]
|
|
189
|
+
: undefined);
|
|
190
|
+
if (runningCall) {
|
|
191
|
+
activeTool = {
|
|
192
|
+
label: runningCall.label || runningCall.name || "tool",
|
|
193
|
+
detail: runningCall.detail || runningCall.live,
|
|
194
|
+
};
|
|
181
195
|
}
|
|
182
196
|
}
|
|
197
|
+
if (!activeTool && ledgerEntry?.activeTool) {
|
|
198
|
+
activeTool = { ...ledgerEntry.activeTool };
|
|
199
|
+
}
|
|
183
200
|
}
|
|
184
201
|
const hasPendingQuestion = this.store.pendingQuestions(conversationId).length > 0;
|
|
185
202
|
let output;
|
|
@@ -200,54 +217,113 @@ export class SessionOrchestratorService {
|
|
|
200
217
|
}
|
|
201
218
|
}
|
|
202
219
|
}
|
|
220
|
+
const effectiveStatus = conversation.error
|
|
221
|
+
? "failed"
|
|
222
|
+
: conversation.status;
|
|
223
|
+
const finalProgress = lastAssistantSnippet ?? ledgerEntry?.lastProgress ?? lastUserSnippet;
|
|
203
224
|
// Sync ledger status if present
|
|
204
|
-
if (ledgerEntry && ledgerEntry.status !==
|
|
225
|
+
if (ledgerEntry && (ledgerEntry.status !== effectiveStatus || ledgerEntry.activeTool !== activeTool || ledgerEntry.lastProgress !== finalProgress)) {
|
|
205
226
|
this.ledger.update(conversationId, {
|
|
206
|
-
status:
|
|
207
|
-
lastProgress:
|
|
227
|
+
status: effectiveStatus,
|
|
228
|
+
lastProgress: finalProgress,
|
|
229
|
+
activeTool,
|
|
230
|
+
...(conversation.error ? { error: conversation.error } : {}),
|
|
208
231
|
});
|
|
209
232
|
}
|
|
210
233
|
return {
|
|
211
234
|
conversationId: conversation.id,
|
|
212
235
|
workspaceId: conversation.workspaceId,
|
|
213
236
|
title: conversation.title,
|
|
214
|
-
status:
|
|
215
|
-
origin: ledgerEntry?.origin ?? "user",
|
|
237
|
+
status: effectiveStatus,
|
|
238
|
+
origin: ledgerEntry?.origin ?? conversation.origin ?? "user",
|
|
216
239
|
parentConversationId: conversation.parentConversationId ?? ledgerEntry?.parentConversationId,
|
|
217
|
-
task: ledgerEntry?.task,
|
|
240
|
+
task: ledgerEntry?.task ?? (conversation.title && conversation.title !== "新会话" ? conversation.title : undefined),
|
|
218
241
|
messageCount: messages.length,
|
|
219
242
|
turnCount,
|
|
220
243
|
lastAssistantSnippet,
|
|
221
244
|
lastUserSnippet,
|
|
222
245
|
hasPendingQuestion,
|
|
223
246
|
activeTool,
|
|
224
|
-
error: conversation.error,
|
|
247
|
+
error: conversation.error ?? ledgerEntry?.error,
|
|
225
248
|
updatedAt: conversation.updatedAt,
|
|
226
|
-
createdAt: ledgerEntry?.createdAt,
|
|
227
|
-
lastProgress:
|
|
249
|
+
createdAt: ledgerEntry?.createdAt ?? (conversation.messages?.[0]?.createdAt ? new Date(conversation.messages[0].createdAt).getTime() : (conversation.updatedAt ? new Date(conversation.updatedAt).getTime() : Date.now())),
|
|
250
|
+
lastProgress: finalProgress,
|
|
228
251
|
...(options?.includeOutput ? { outputPath: resultPath } : {}),
|
|
229
252
|
...(output !== undefined ? { output, outputTruncated } : {}),
|
|
230
253
|
};
|
|
231
254
|
}
|
|
232
255
|
/**
|
|
233
256
|
* Lists summaries of all sessions, optionally filtered by parent conversation.
|
|
257
|
+
*
|
|
258
|
+
* Persisted store conversations are the authoritative source for session tree
|
|
259
|
+
* parent-child relationships across server restarts, while SessionLedger
|
|
260
|
+
* enriches live in-memory task, progress, and activeTool state.
|
|
234
261
|
*/
|
|
235
262
|
async list(parentConversationId) {
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
263
|
+
const targetParentId = parentConversationId?.trim();
|
|
264
|
+
const candidateIds = new Set();
|
|
265
|
+
if (targetParentId) {
|
|
266
|
+
// 1. Check parent in store to know parent's workspace
|
|
267
|
+
const parentConv = this.store.conversation(targetParentId);
|
|
268
|
+
if (parentConv) {
|
|
269
|
+
const workspaceChildren = this.store.conversationsForWorkspace(parentConv.workspaceId);
|
|
270
|
+
for (const child of workspaceChildren) {
|
|
271
|
+
if (child.parentConversationId === targetParentId) {
|
|
272
|
+
candidateIds.add(child.id);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
// Fallback: check all conversations in store
|
|
278
|
+
const allConversations = this.store.snapshot().conversations;
|
|
279
|
+
for (const conv of allConversations) {
|
|
280
|
+
if (conv.parentConversationId === targetParentId) {
|
|
281
|
+
candidateIds.add(conv.id);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// 2. Also check in-memory ledger entries
|
|
286
|
+
const ledgerEntries = this.ledger.listByParent(targetParentId);
|
|
287
|
+
for (const entry of ledgerEntries) {
|
|
288
|
+
candidateIds.add(entry.conversationId);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
// No parent specified: list all orchestrator / child sessions
|
|
293
|
+
for (const conv of this.store.snapshot().conversations) {
|
|
294
|
+
if (conv.parentConversationId || conv.origin === "orchestrator") {
|
|
295
|
+
candidateIds.add(conv.id);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const entry of this.ledger.listAll()) {
|
|
299
|
+
candidateIds.add(entry.conversationId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const parentConv = targetParentId ? this.store.conversation(targetParentId) : undefined;
|
|
239
303
|
const summaries = [];
|
|
240
|
-
for (const
|
|
304
|
+
for (const conversationId of candidateIds) {
|
|
241
305
|
try {
|
|
242
|
-
const summary = await this.status(
|
|
306
|
+
const summary = await this.status(conversationId);
|
|
307
|
+
// Workspace boundary enforcement: do not leak sessions across different workspaces
|
|
308
|
+
if (parentConv && summary.workspaceId !== parentConv.workspaceId) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (targetParentId && summary.parentConversationId !== targetParentId) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
243
314
|
summaries.push(summary);
|
|
244
315
|
}
|
|
245
316
|
catch {
|
|
246
317
|
// Conversation was removed from store; prune ledger
|
|
247
|
-
this.ledger.delete(
|
|
318
|
+
this.ledger.delete(conversationId);
|
|
248
319
|
}
|
|
249
320
|
}
|
|
250
|
-
|
|
321
|
+
// Sort newest first by creation time or updated timestamp
|
|
322
|
+
return summaries.sort((a, b) => {
|
|
323
|
+
const timeA = a.createdAt ?? (a.updatedAt ? new Date(a.updatedAt).getTime() : 0);
|
|
324
|
+
const timeB = b.createdAt ?? (b.updatedAt ? new Date(b.updatedAt).getTime() : 0);
|
|
325
|
+
return timeB - timeA;
|
|
326
|
+
});
|
|
251
327
|
}
|
|
252
328
|
/**
|
|
253
329
|
* Stops a target session.
|
|
@@ -169,7 +169,7 @@ export class CommandHandler {
|
|
|
169
169
|
return;
|
|
170
170
|
}
|
|
171
171
|
if (command.type === "conversation.create") {
|
|
172
|
-
return await this.conversationService.createConversation(command.workspaceId);
|
|
172
|
+
return await this.conversationService.createConversation(command.workspaceId, undefined, command.cwd);
|
|
173
173
|
}
|
|
174
174
|
if (command.type === "conversation.autoRename") {
|
|
175
175
|
// Answered on a pushed event rather than through the return value: this spends real model time, and
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ConversationContextUsage, SubagentUsage } from "./types.js";
|
|
2
|
+
export type TurnUsageReadout = {
|
|
3
|
+
usage?: SubagentUsage;
|
|
4
|
+
costKnown: boolean;
|
|
5
|
+
contextUsage?: ConversationContextUsage;
|
|
6
|
+
};
|
|
7
|
+
/** Collect only completed assistant messages, never the repeated usage on streaming deltas. */
|
|
8
|
+
export declare class ConversationUsageCollector {
|
|
9
|
+
private readonly total;
|
|
10
|
+
private costKnown;
|
|
11
|
+
add(value: unknown): void;
|
|
12
|
+
readout(context: {
|
|
13
|
+
tokens: number | null;
|
|
14
|
+
contextWindow: number;
|
|
15
|
+
} | undefined, model: {
|
|
16
|
+
provider?: string;
|
|
17
|
+
id?: string;
|
|
18
|
+
} | undefined, agentDir: string): TurnUsageReadout;
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { effectiveContextWindow, readDeclaredWindows } from "./model-context-window.js";
|
|
2
|
+
const nonnegative = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
3
|
+
/** Collect only completed assistant messages, never the repeated usage on streaming deltas. */
|
|
4
|
+
export class ConversationUsageCollector {
|
|
5
|
+
total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
6
|
+
costKnown = true;
|
|
7
|
+
add(value) {
|
|
8
|
+
if (!value || typeof value !== "object") {
|
|
9
|
+
this.costKnown = false;
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const usage = value;
|
|
13
|
+
const keys = ["input", "output", "cacheRead", "cacheWrite"];
|
|
14
|
+
const cost = usage.cost?.total;
|
|
15
|
+
if (!nonnegative(cost))
|
|
16
|
+
this.costKnown = false;
|
|
17
|
+
if (!keys.some(key => nonnegative(usage[key])) && !nonnegative(cost))
|
|
18
|
+
return;
|
|
19
|
+
for (const key of keys)
|
|
20
|
+
if (nonnegative(usage[key]))
|
|
21
|
+
this.total[key] += usage[key];
|
|
22
|
+
if (nonnegative(cost))
|
|
23
|
+
this.total.cost += cost;
|
|
24
|
+
this.total.turns++;
|
|
25
|
+
}
|
|
26
|
+
readout(context, model, agentDir) {
|
|
27
|
+
let contextUsage;
|
|
28
|
+
if (context) {
|
|
29
|
+
const contextWindow = model?.provider && model.id
|
|
30
|
+
? effectiveContextWindow(readDeclaredWindows(agentDir), model.provider, model.id, context.contextWindow) ?? context.contextWindow
|
|
31
|
+
: context.contextWindow;
|
|
32
|
+
if (nonnegative(contextWindow) && contextWindow > 0) {
|
|
33
|
+
// Pi returns null after compaction until another assistant supplies a trustworthy token count.
|
|
34
|
+
const tokens = nonnegative(context.tokens) ? context.tokens : null;
|
|
35
|
+
contextUsage = { tokens, contextWindow, percent: tokens === null ? null : tokens / contextWindow * 100 };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ...(this.total.turns ? { usage: { ...this.total } } : {}), costKnown: this.costKnown, ...(contextUsage ? { contextUsage } : {}) };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -35,4 +35,7 @@ export declare function computeFileDiff(options: {
|
|
|
35
35
|
preferred?: "auto" | DiffBaselineKind;
|
|
36
36
|
tracker?: FileChangeTracker;
|
|
37
37
|
workspaceId?: string;
|
|
38
|
+
/** Repository root and tracker key when `root` is a session subdirectory. */
|
|
39
|
+
gitRoot?: string;
|
|
40
|
+
trackerPath?: string;
|
|
38
41
|
}): Promise<FileDiff>;
|
package/dist/server/file-diff.js
CHANGED
|
@@ -36,12 +36,12 @@ import { FilesError, resolveInsideRoot } from "./files.js";
|
|
|
36
36
|
* they are looking at and it must not silently become a different one.
|
|
37
37
|
*/
|
|
38
38
|
export async function computeFileDiff(options) {
|
|
39
|
-
const { root, path, preferred = "auto", tracker, workspaceId } = options;
|
|
39
|
+
const { root, path, preferred = "auto", tracker, workspaceId, gitRoot = root, trackerPath = path } = options;
|
|
40
40
|
const after = await readAfter(root, path);
|
|
41
41
|
// The agent's baseline. It is the RIGHT one whenever it exists, because it is the content the agent
|
|
42
42
|
// actually started from - which is not necessarily HEAD, and not necessarily what the file looked
|
|
43
43
|
// like when the server started.
|
|
44
|
-
const agentChange = tracker && workspaceId ? tracker.get(workspaceId,
|
|
44
|
+
const agentChange = tracker && workspaceId ? tracker.get(workspaceId, trackerPath) : undefined;
|
|
45
45
|
if ((preferred === "auto" || preferred === "agent") && agentChange) {
|
|
46
46
|
return {
|
|
47
47
|
path,
|
|
@@ -55,8 +55,8 @@ export async function computeFileDiff(options) {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
if (preferred === "auto" || preferred === "git") {
|
|
58
|
-
if (await isGitRepository(
|
|
59
|
-
const head = await gitHeadContent(
|
|
58
|
+
if (await isGitRepository(gitRoot)) {
|
|
59
|
+
const head = await gitHeadContent(gitRoot, trackerPath);
|
|
60
60
|
if (head !== null) {
|
|
61
61
|
return { path, baseline: "git", before: head, after, label: "Git 已提交的版本" };
|
|
62
62
|
}
|
|
@@ -10,6 +10,7 @@ export type IndexedFile = {
|
|
|
10
10
|
export type FileIndexEvent = {
|
|
11
11
|
type: "files.index";
|
|
12
12
|
workspaceId: string;
|
|
13
|
+
cwd?: string;
|
|
13
14
|
version: number;
|
|
14
15
|
changed: IndexedFile[];
|
|
15
16
|
removed: string[];
|
|
@@ -33,6 +34,7 @@ export type FileIndexSnapshot = {
|
|
|
33
34
|
export declare class FileIndex {
|
|
34
35
|
readonly workspaceId: string;
|
|
35
36
|
private readonly broadcast;
|
|
37
|
+
readonly cwd: string;
|
|
36
38
|
readonly entries: Map<string, IndexedFile>;
|
|
37
39
|
private readonly history;
|
|
38
40
|
private root;
|
|
@@ -42,7 +44,7 @@ export declare class FileIndex {
|
|
|
42
44
|
version: number;
|
|
43
45
|
builtAt: number;
|
|
44
46
|
complete: boolean;
|
|
45
|
-
constructor(workspaceId: string, broadcast: (event: FileIndexEvent) => void);
|
|
47
|
+
constructor(workspaceId: string, broadcast: (event: FileIndexEvent) => void, cwd?: string);
|
|
46
48
|
start(root: string): void;
|
|
47
49
|
stop(): void;
|
|
48
50
|
snapshot(since?: number): FileIndexSnapshot;
|
|
@@ -83,4 +85,4 @@ export declare class FileIndex {
|
|
|
83
85
|
* said out loud, and the turn carries on. The index is a convenience; it must never be able to end a
|
|
84
86
|
* conversation.
|
|
85
87
|
*/
|
|
86
|
-
export declare function refreshWorkspaceIndex(indexes: Map<string, FileIndex> | undefined, workspaceId: string, path: string): void;
|
|
88
|
+
export declare function refreshWorkspaceIndex(indexes: Map<string, FileIndex> | undefined, workspaceId: string, path: string, workspaceRoot?: string): void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
2
3
|
import { SEARCH_SKIP_DIRS, createRootGuard, fileMeta, isTextName, toRelative, toWorkspaceRelative } from "./files.js";
|
|
3
4
|
const MAX_DIRECTORIES = 4000;
|
|
4
5
|
const MAX_FILES = 30000;
|
|
@@ -12,6 +13,7 @@ const MAX_MS = 4000;
|
|
|
12
13
|
export class FileIndex {
|
|
13
14
|
workspaceId;
|
|
14
15
|
broadcast;
|
|
16
|
+
cwd;
|
|
15
17
|
entries = new Map();
|
|
16
18
|
history = [];
|
|
17
19
|
root = "";
|
|
@@ -21,9 +23,10 @@ export class FileIndex {
|
|
|
21
23
|
version = 0;
|
|
22
24
|
builtAt = 0;
|
|
23
25
|
complete = false;
|
|
24
|
-
constructor(workspaceId, broadcast) {
|
|
26
|
+
constructor(workspaceId, broadcast, cwd = "") {
|
|
25
27
|
this.workspaceId = workspaceId;
|
|
26
28
|
this.broadcast = broadcast;
|
|
29
|
+
this.cwd = cwd;
|
|
27
30
|
}
|
|
28
31
|
start(root) {
|
|
29
32
|
if (this.building || this.builtAt)
|
|
@@ -162,7 +165,7 @@ export class FileIndex {
|
|
|
162
165
|
if (!changed.length && !removed.length && !force)
|
|
163
166
|
return;
|
|
164
167
|
this.version += 1;
|
|
165
|
-
const event = { type: "files.index", workspaceId: this.workspaceId, version: this.version, changed, removed, complete: this.complete, skipped: this.skipped };
|
|
168
|
+
const event = { type: "files.index", workspaceId: this.workspaceId, ...(this.cwd ? { cwd: this.cwd } : {}), version: this.version, changed, removed, complete: this.complete, skipped: this.skipped };
|
|
166
169
|
this.history.push({ version: this.version, changed, removed });
|
|
167
170
|
if (this.history.length > 128)
|
|
168
171
|
this.history.shift();
|
|
@@ -185,11 +188,13 @@ export class FileIndex {
|
|
|
185
188
|
* said out loud, and the turn carries on. The index is a convenience; it must never be able to end a
|
|
186
189
|
* conversation.
|
|
187
190
|
*/
|
|
188
|
-
export function refreshWorkspaceIndex(indexes, workspaceId, path) {
|
|
189
|
-
const index
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
export function refreshWorkspaceIndex(indexes, workspaceId, path, workspaceRoot) {
|
|
192
|
+
for (const index of indexes?.values() ?? []) {
|
|
193
|
+
if (index.workspaceId !== workspaceId)
|
|
194
|
+
continue;
|
|
195
|
+
const target = workspaceRoot && index.cwd ? resolve(workspaceRoot, path) : path;
|
|
196
|
+
void index.refresh(target).catch((error) => {
|
|
197
|
+
console.warn(`Super Pi: the file index did not refresh ${path}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
195
200
|
}
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* the agent's writes - go through this one function, so there is exactly one index per workspace and
|
|
6
6
|
* the two never drift into answering from different maps.
|
|
7
7
|
*/
|
|
8
|
-
export declare function ensureNameIndex(indexes: Map<string, FileNameIndex>, workspaceId: string, root: string): FileNameIndex;
|
|
8
|
+
export declare function ensureNameIndex(indexes: Map<string, FileNameIndex>, workspaceId: string, root: string, cwd?: string): FileNameIndex;
|
|
9
|
+
/** Keeps every root/subdirectory index for a workspace in step with one observed write. */
|
|
10
|
+
export declare function recordWorkspacePath(indexes: Map<string, FileNameIndex>, workspaceId: string, workspaceRoot: string, path: string): void;
|
|
9
11
|
/** What the index knows about one path. Deliberately small: no size, no etag, no mtime. */
|
|
10
12
|
export type NameEntry = {
|
|
11
13
|
/** Workspace-relative, `/`-separated. */
|
|
@@ -60,6 +62,7 @@ export declare function compareNameHits(a: NameEntry, b: NameEntry, needle: stri
|
|
|
60
62
|
*/
|
|
61
63
|
export declare class FileNameIndex {
|
|
62
64
|
readonly workspaceId: string;
|
|
65
|
+
readonly cwd: string;
|
|
63
66
|
readonly entries: Map<string, NameEntry>;
|
|
64
67
|
private root;
|
|
65
68
|
private building;
|
|
@@ -67,7 +70,7 @@ export declare class FileNameIndex {
|
|
|
67
70
|
private scanned;
|
|
68
71
|
private stopped;
|
|
69
72
|
private incomplete;
|
|
70
|
-
constructor(workspaceId: string);
|
|
73
|
+
constructor(workspaceId: string, cwd?: string);
|
|
71
74
|
/**
|
|
72
75
|
* Whether the first pass has FINISHED.
|
|
73
76
|
*
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
* replaces.
|
|
33
33
|
*/
|
|
34
34
|
import { readdir } from "node:fs/promises";
|
|
35
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
35
36
|
import { createRootGuard, toRelative } from "./files.js";
|
|
37
|
+
import { sessionScopeKey } from "./session-cwd.js";
|
|
36
38
|
/**
|
|
37
39
|
* THE INDEX FOR ONE WORKSPACE, created on demand and then kept.
|
|
38
40
|
*
|
|
@@ -40,15 +42,28 @@ import { createRootGuard, toRelative } from "./files.js";
|
|
|
40
42
|
* the agent's writes - go through this one function, so there is exactly one index per workspace and
|
|
41
43
|
* the two never drift into answering from different maps.
|
|
42
44
|
*/
|
|
43
|
-
export function ensureNameIndex(indexes, workspaceId, root) {
|
|
44
|
-
|
|
45
|
+
export function ensureNameIndex(indexes, workspaceId, root, cwd = "") {
|
|
46
|
+
const key = sessionScopeKey(workspaceId, cwd);
|
|
47
|
+
let index = indexes.get(key);
|
|
45
48
|
if (!index) {
|
|
46
|
-
index = new FileNameIndex(workspaceId);
|
|
47
|
-
indexes.set(
|
|
49
|
+
index = new FileNameIndex(workspaceId, cwd);
|
|
50
|
+
indexes.set(key, index);
|
|
48
51
|
}
|
|
49
52
|
index.start(root);
|
|
50
53
|
return index;
|
|
51
54
|
}
|
|
55
|
+
/** Keeps every root/subdirectory index for a workspace in step with one observed write. */
|
|
56
|
+
export function recordWorkspacePath(indexes, workspaceId, workspaceRoot, path) {
|
|
57
|
+
const absolute = resolve(workspaceRoot, path);
|
|
58
|
+
for (const index of indexes.values()) {
|
|
59
|
+
if (index.workspaceId !== workspaceId)
|
|
60
|
+
continue;
|
|
61
|
+
const scoped = relative(resolve(workspaceRoot, index.cwd), absolute);
|
|
62
|
+
if (!scoped || scoped === ".." || scoped.startsWith(`..${sep}`) || isAbsolute(scoped))
|
|
63
|
+
continue;
|
|
64
|
+
index.upsert(scoped.split(sep).join("/"));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
52
67
|
const MAX_DIRECTORIES = 20000;
|
|
53
68
|
const MAX_FILES = 200000;
|
|
54
69
|
/**
|
|
@@ -92,6 +107,7 @@ function hitPosition(entry, needle) {
|
|
|
92
107
|
*/
|
|
93
108
|
export class FileNameIndex {
|
|
94
109
|
workspaceId;
|
|
110
|
+
cwd;
|
|
95
111
|
entries = new Map();
|
|
96
112
|
root = "";
|
|
97
113
|
building = false;
|
|
@@ -99,8 +115,9 @@ export class FileNameIndex {
|
|
|
99
115
|
scanned = 0;
|
|
100
116
|
stopped = false;
|
|
101
117
|
incomplete = false;
|
|
102
|
-
constructor(workspaceId) {
|
|
118
|
+
constructor(workspaceId, cwd = "") {
|
|
103
119
|
this.workspaceId = workspaceId;
|
|
120
|
+
this.cwd = cwd;
|
|
104
121
|
}
|
|
105
122
|
/**
|
|
106
123
|
* Whether the first pass has FINISHED.
|
package/dist/server/git.d.ts
CHANGED
|
@@ -13,8 +13,8 @@ export type GitFile = {
|
|
|
13
13
|
name: string;
|
|
14
14
|
xy: string;
|
|
15
15
|
};
|
|
16
|
-
export declare function gitStatus(root: string): Promise<GitWorkspace>;
|
|
17
|
-
export declare function gitLog(root: string, limit: number): Promise<{
|
|
16
|
+
export declare function gitStatus(root: string, cwd?: string): Promise<GitWorkspace>;
|
|
17
|
+
export declare function gitLog(root: string, limit: number, cwd?: string): Promise<{
|
|
18
18
|
commits: {
|
|
19
19
|
hash: string;
|
|
20
20
|
short: string;
|
|
@@ -138,4 +138,4 @@ export type GitBlameOptions = {
|
|
|
138
138
|
export declare function gitBlame(root: string, path: string, options?: GitBlameOptions): Promise<GitBlameResponse>;
|
|
139
139
|
/** Whether the directory is a git repository at all. The review view asks before it offers a git baseline. */
|
|
140
140
|
export declare function isGitRepository(root: string): Promise<boolean>;
|
|
141
|
-
export declare function gitMutate(root: string, op: string, path?: string, message?: string): Promise<GitWorkspace>;
|
|
141
|
+
export declare function gitMutate(root: string, op: string, path?: string, message?: string, cwd?: string): Promise<GitWorkspace>;
|
package/dist/server/git.js
CHANGED
|
@@ -5,8 +5,9 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { readFile, stat } from "node:fs/promises";
|
|
6
6
|
const exec = promisify(execFile);
|
|
7
7
|
async function git(cwd, args, options) { return exec("git", ["-c", "core.quotepath=false", ...args], { cwd, windowsHide: true, maxBuffer: options?.maxBuffer ?? 8 * 1024 * 1024 }); }
|
|
8
|
-
function files(raw) { const staged = [], unstaged = [], untracked = []; for (const line of raw.split("\0").filter(Boolean)) {
|
|
9
|
-
const xy = line.slice(0, 2),
|
|
8
|
+
function files(raw, cwd = "") { const staged = [], unstaged = [], untracked = []; for (const line of raw.split("\0").filter(Boolean)) {
|
|
9
|
+
const xy = line.slice(0, 2), repoPath = line.slice(3).split("\0")[0].replaceAll("\\", "/");
|
|
10
|
+
const path = cwd && repoPath.startsWith(`${cwd}/`) ? repoPath.slice(cwd.length + 1) : repoPath;
|
|
10
11
|
const item = { path, name: basename(path), xy };
|
|
11
12
|
if (xy === "??")
|
|
12
13
|
untracked.push(item);
|
|
@@ -17,14 +18,14 @@ function files(raw) { const staged = [], unstaged = [], untracked = []; for (con
|
|
|
17
18
|
unstaged.push(item);
|
|
18
19
|
}
|
|
19
20
|
} return { staged, unstaged, untracked }; }
|
|
20
|
-
export async function gitStatus(root) { try {
|
|
21
|
+
export async function gitStatus(root, cwd = "") { try {
|
|
21
22
|
await git(root, ["rev-parse", "--git-dir"]);
|
|
22
23
|
}
|
|
23
24
|
catch {
|
|
24
25
|
throw Object.assign(new Error("此工作区不是 Git 仓库"), { code: "not_a_repo" });
|
|
25
|
-
} const status = await git(root, ["status", "--porcelain=v1", "-z"]); const branch = await git(root, ["symbolic-ref", "--short", "-q", "HEAD"]).then(r => r.stdout.trim() || null).catch(() => null); const unborn = !(await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false)); const ahead = await git(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]).then(r => { const [a, b] = r.stdout.trim().split(/\s+/).map(Number); return Number.isFinite(a) ? a : null; }).catch(() => null); const behind = await git(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]).then(r => { const [a, b] = r.stdout.trim().split(/\s+/).map(Number); return Number.isFinite(b) ? b : null; }).catch(() => null); return { branch, detached: !branch, unborn, ahead, behind, ...files(status.stdout) }; }
|
|
26
|
-
export async function gitLog(root, limit) { const hasHead = await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false); if (!hasHead)
|
|
27
|
-
return { commits: [], truncated: false }; const out = await git(root, ["log", `-${Math.min(Math.max(limit, 1), 50)}`, "--format=%H%x00%h%x00%s%x00%an%x00%aI%x00"]); const parts = out.stdout.split("\0").filter(Boolean); const commits = []; for (let i = 0; i + 4 < parts.length; i += 5)
|
|
26
|
+
} const status = await git(root, ["status", "--porcelain=v1", "-z", ...(cwd ? ["--", cwd] : [])]); const branch = await git(root, ["symbolic-ref", "--short", "-q", "HEAD"]).then(r => r.stdout.trim() || null).catch(() => null); const unborn = !(await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false)); const ahead = await git(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]).then(r => { const [a, b] = r.stdout.trim().split(/\s+/).map(Number); return Number.isFinite(a) ? a : null; }).catch(() => null); const behind = await git(root, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]).then(r => { const [a, b] = r.stdout.trim().split(/\s+/).map(Number); return Number.isFinite(b) ? b : null; }).catch(() => null); return { branch, detached: !branch, unborn, ahead, behind, ...files(status.stdout, cwd) }; }
|
|
27
|
+
export async function gitLog(root, limit, cwd = "") { const hasHead = await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false); if (!hasHead)
|
|
28
|
+
return { commits: [], truncated: false }; const out = await git(root, ["log", `-${Math.min(Math.max(limit, 1), 50)}`, "--format=%H%x00%h%x00%s%x00%an%x00%aI%x00", ...(cwd ? ["--", cwd] : [])]); const parts = out.stdout.split("\0").filter(Boolean); const commits = []; for (let i = 0; i + 4 < parts.length; i += 5)
|
|
28
29
|
commits.push({ hash: parts[i], short: parts[i + 1], subject: parts[i + 2], author: parts[i + 3], date: new Date(parts[i + 4]).toISOString() }); return { commits, truncated: commits.length >= limit }; }
|
|
29
30
|
export async function gitDiff(root, path, staged) { const safe = resolve(root, path); if (!safe.startsWith(resolve(root) + sep) || !existsSync(safe))
|
|
30
31
|
throw Object.assign(new Error("文件路径无效"), { code: "path" }); const check = await git(root, ["ls-files", "--error-unmatch", "--", path]).then(() => true).catch(() => false); if (!check)
|
|
@@ -341,7 +342,7 @@ function safePath(root, path) {
|
|
|
341
342
|
throw Object.assign(new Error("文件路径无效"), { code: "path" });
|
|
342
343
|
return path;
|
|
343
344
|
}
|
|
344
|
-
export async function gitMutate(root, op, path, message) {
|
|
345
|
+
export async function gitMutate(root, op, path, message, cwd = "") {
|
|
345
346
|
if (op === "stage" || op === "unstage" || op === "discard") {
|
|
346
347
|
if (!path)
|
|
347
348
|
throw Object.assign(new Error("缺少文件路径"), { code: "path" });
|
|
@@ -356,7 +357,7 @@ export async function gitMutate(root, op, path, message) {
|
|
|
356
357
|
else if (op === "commit") {
|
|
357
358
|
if (!message?.trim())
|
|
358
359
|
throw Object.assign(new Error("提交消息不能为空"), { code: "bad_message" });
|
|
359
|
-
await git(root, ["commit", "-m", message.trim()]);
|
|
360
|
+
await git(root, ["commit", "-m", message.trim(), ...(cwd ? ["--", cwd] : [])]);
|
|
360
361
|
}
|
|
361
362
|
else if (op === "pull")
|
|
362
363
|
await git(root, ["pull", "--ff-only"]);
|
|
@@ -364,5 +365,5 @@ export async function gitMutate(root, op, path, message) {
|
|
|
364
365
|
await git(root, ["push"]);
|
|
365
366
|
else
|
|
366
367
|
throw Object.assign(new Error("未知 Git 操作"), { code: "bad_op" });
|
|
367
|
-
return gitStatus(root);
|
|
368
|
+
return gitStatus(root, cwd);
|
|
368
369
|
}
|