@evandu/super-pi 0.1.104 → 0.1.106
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/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +8 -7
- package/dist/client/assets/index-DBR_t9lF.js +79 -0
- package/dist/client/assets/index-mo5Dvh_v.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/catalog-service.d.ts +9 -0
- package/dist/server/application/catalog-service.js +11 -0
- package/dist/server/application/provider-service.d.ts +77 -0
- package/dist/server/application/provider-service.js +165 -0
- package/dist/server/application/run-service.d.ts +19 -0
- package/dist/server/application/run-service.js +78 -0
- package/dist/server/command-handler.d.ts +16 -1
- package/dist/server/command-handler.js +40 -1
- package/dist/server/content-search.d.ts +32 -0
- package/dist/server/content-search.js +155 -0
- package/dist/server/file-changes.d.ts +119 -0
- package/dist/server/file-changes.js +278 -0
- package/dist/server/file-diff.d.ts +38 -0
- package/dist/server/file-diff.js +97 -0
- package/dist/server/file-index.d.ts +56 -0
- package/dist/server/file-index.js +150 -0
- package/dist/server/files.d.ts +8 -0
- package/dist/server/files.js +3 -1
- package/dist/server/git.d.ts +17 -0
- package/dist/server/git.js +39 -0
- package/dist/server/http-server.js +121 -1
- package/dist/server/index.js +28 -1
- package/dist/server/pi-session-events.d.ts +9 -0
- package/dist/server/pi-session-events.js +12 -0
- package/dist/server/pi-session-pool.d.ts +19 -1
- package/dist/server/pi-session-pool.js +13 -1
- package/dist/server/pi-session.d.ts +10 -0
- package/dist/server/pi-session.js +11 -0
- package/dist/server/providers.d.ts +160 -0
- package/dist/server/providers.js +396 -0
- package/dist/server/runtime.d.ts +67 -0
- package/dist/server/runtime.js +54 -2
- package/dist/server/server-context.d.ts +15 -0
- package/dist/server/types.d.ts +143 -0
- package/package.json +14 -1
- package/dist/client/assets/index-DDkbPYLz.css +0 -1
- package/dist/client/assets/index-mAu39Bra.js +0 -74
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { KNOWN_APIS, publicProviders, validateProvider } from "../providers.js";
|
|
2
|
+
/**
|
|
3
|
+
* THE PROVIDERS A PERSON ADDED, as a service.
|
|
4
|
+
*
|
|
5
|
+
* The service owns the sequence, and the sequence is the whole point: a provider is not "saved"
|
|
6
|
+
* when it is in Super Pi's registry, it is saved when Pi can use it. So every change goes through
|
|
7
|
+
* the same four steps, in this order:
|
|
8
|
+
*
|
|
9
|
+
* 1. WRITE PI'S FILES. The definitions go into Pi's `models.json` and the key into Pi's
|
|
10
|
+
* `auth.json`, merged into whatever is already there.
|
|
11
|
+
* 2. WRITE SUPER PI'S REGISTRY. It is the truth source, and it is written AFTER Pi's files so
|
|
12
|
+
* that a failure to write them leaves Super Pi claiming nothing it has not done.
|
|
13
|
+
* 3. INVALIDATE THE CATALOG. Pi's model list is cached - the SDK's is remembered for the life of
|
|
14
|
+
* the process and the runtime's has a ten-minute TTL - so a provider added now would not
|
|
15
|
+
* appear in the picker until the cache expired. Ten minutes of a picker that cannot see the
|
|
16
|
+
* model the person just added is exactly the "decorative control" this project refuses to ship.
|
|
17
|
+
* 4. INVALIDATE THE LIVE SESSIONS. A session holds its own `ModelRuntime`, created when the
|
|
18
|
+
* session was built, and that runtime does not know a provider added a minute ago. Pi's
|
|
19
|
+
* refusal to switch to an unknown model is swallowed by the session pool into a warning - the
|
|
20
|
+
* turn then runs on the OLD model while the picker says the new one. So the sessions are made
|
|
21
|
+
* stale instead, and each one is rebuilt at its next turn, which is a moment when throwing the
|
|
22
|
+
* session away costs nothing.
|
|
23
|
+
*/
|
|
24
|
+
export class ProviderService {
|
|
25
|
+
registry;
|
|
26
|
+
runtime;
|
|
27
|
+
broadcaster;
|
|
28
|
+
catalogService;
|
|
29
|
+
store;
|
|
30
|
+
constructor(registry, runtime, broadcaster, catalogService, store) {
|
|
31
|
+
this.registry = registry;
|
|
32
|
+
this.runtime = runtime;
|
|
33
|
+
this.broadcaster = broadcaster;
|
|
34
|
+
this.catalogService = catalogService;
|
|
35
|
+
this.store = store;
|
|
36
|
+
}
|
|
37
|
+
/** The providers Super Pi manages, as the browser may see them: no keys, only the fact of one. */
|
|
38
|
+
async list() {
|
|
39
|
+
return publicProviders(await this.registry.load());
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Adds a provider, or adds a model to one, or updates one.
|
|
43
|
+
*
|
|
44
|
+
* ONE CALL, THREE THINGS, because the form collects a provider and a model together and a person
|
|
45
|
+
* saving a second model under the same provider should not have to retype the API address. The
|
|
46
|
+
* merge is by id: the provider's fields are replaced by what the form said, and the model with
|
|
47
|
+
* the same id is replaced while the provider's other models stay.
|
|
48
|
+
*
|
|
49
|
+
* THE KEY IS LEFT ALONE UNLESS THE FORM SAYS OTHERWISE. An empty key field means "I did not type
|
|
50
|
+
* one", not "remove the key": the key is never shown again once it is stored, so an empty field
|
|
51
|
+
* is the normal state of a form for a provider that already has one. `clearKey` is how a person
|
|
52
|
+
* asks for it to go.
|
|
53
|
+
*/
|
|
54
|
+
async set(input) {
|
|
55
|
+
const requested = validateProvider(input);
|
|
56
|
+
const raw = (input ?? {});
|
|
57
|
+
const previous = await this.registry.load();
|
|
58
|
+
const existing = previous.find((provider) => provider.id === requested.id);
|
|
59
|
+
const apiKey = requested.apiKey
|
|
60
|
+
? requested.apiKey
|
|
61
|
+
: (raw.clearKey === true ? undefined : existing?.apiKey);
|
|
62
|
+
// A MODEL WITH THE SAME ID IS REPLACED WHERE IT STANDS, not moved to the end: the list a person
|
|
63
|
+
// sees in the panel is the list they added, and re-ordering it because they edited its first
|
|
64
|
+
// entry would be a change nobody asked for. A model with a NEW id is appended.
|
|
65
|
+
const incoming = requested.models[0];
|
|
66
|
+
const models = !existing
|
|
67
|
+
? requested.models
|
|
68
|
+
: (existing.models.some((model) => model.id === incoming.id)
|
|
69
|
+
? existing.models.map((model) => (model.id === incoming.id ? incoming : model))
|
|
70
|
+
: [...existing.models, incoming]);
|
|
71
|
+
const merged = {
|
|
72
|
+
id: requested.id,
|
|
73
|
+
...(requested.name ? { name: requested.name } : {}),
|
|
74
|
+
baseUrl: requested.baseUrl,
|
|
75
|
+
api: requested.api,
|
|
76
|
+
...(apiKey ? { apiKey } : {}),
|
|
77
|
+
models,
|
|
78
|
+
};
|
|
79
|
+
const next = existing
|
|
80
|
+
? [...previous.filter((provider) => provider.id !== requested.id), merged]
|
|
81
|
+
: [...previous, merged];
|
|
82
|
+
await this.registry.applyToPi(previous, next);
|
|
83
|
+
try {
|
|
84
|
+
await this.registry.save(next);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// The registry is the truth source, so a failure to write it means Super Pi no longer knows
|
|
88
|
+
// what it put into Pi's files. Pi's files are put back the way they were, and the failure is
|
|
89
|
+
// reported rather than swallowed: a registry that disagrees with Pi's files is a state the
|
|
90
|
+
// next write would trip over.
|
|
91
|
+
await this.registry.applyToPi(next, previous).catch(() => undefined);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
await this.afterChange();
|
|
95
|
+
return publicProviders(next);
|
|
96
|
+
}
|
|
97
|
+
/** Removes a provider Super Pi manages, and only one it manages. */
|
|
98
|
+
async remove(providerId) {
|
|
99
|
+
const previous = await this.registry.load();
|
|
100
|
+
const existing = previous.find((provider) => provider.id === providerId);
|
|
101
|
+
if (!existing)
|
|
102
|
+
throw new Error(`Super Pi 没有管理供应商「${providerId}」`);
|
|
103
|
+
const next = previous.filter((provider) => provider.id !== providerId);
|
|
104
|
+
await this.registry.applyToPi(previous, next);
|
|
105
|
+
await this.registry.save(next);
|
|
106
|
+
await this.afterChange();
|
|
107
|
+
return publicProviders(next);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Removes ONE model from a provider, leaving the provider and its other models alone.
|
|
111
|
+
*
|
|
112
|
+
* WHY THIS IS NOT JUST A DELETE-AND-RE-ADD. A provider is a group of models sharing one address,
|
|
113
|
+
* one API type and one key, so removing a model must not touch any of those - and the key in
|
|
114
|
+
* particular is not something to re-type. The provider's entry in Pi's file is rewritten with the
|
|
115
|
+
* remaining models, and its credential stays exactly where it was.
|
|
116
|
+
*
|
|
117
|
+
* THE LAST MODEL GOES WITH THE PROVIDER. A provider with no models offers nothing, and Pi's file
|
|
118
|
+
* would keep an entry that can never be selected - so the provider is removed, and with it the
|
|
119
|
+
* credential, because a credential for a provider that no longer exists is a secret left lying in
|
|
120
|
+
* a file for nothing.
|
|
121
|
+
*/
|
|
122
|
+
async removeModel(providerId, modelId) {
|
|
123
|
+
const previous = await this.registry.load();
|
|
124
|
+
const existing = previous.find((provider) => provider.id === providerId);
|
|
125
|
+
if (!existing)
|
|
126
|
+
throw new Error(`Super Pi 没有管理供应商「${providerId}」`);
|
|
127
|
+
const remaining = existing.models.filter((model) => model.id !== modelId);
|
|
128
|
+
if (remaining.length === existing.models.length)
|
|
129
|
+
throw new Error(`供应商「${providerId}」里没有模型「${modelId}」`);
|
|
130
|
+
if (!remaining.length)
|
|
131
|
+
return this.remove(providerId);
|
|
132
|
+
const next = previous.map((provider) => (provider.id === providerId ? { ...provider, models: remaining } : provider));
|
|
133
|
+
await this.registry.applyToPi(previous, next);
|
|
134
|
+
await this.registry.save(next);
|
|
135
|
+
await this.afterChange();
|
|
136
|
+
return publicProviders(next);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* What has to happen after the files are written, for the change to be REAL rather than pending.
|
|
140
|
+
*
|
|
141
|
+
* The catalog is re-read for every live conversation - the same loop the model rules use - rather
|
|
142
|
+
* than broadcast once without a conversation, because a catalog without a conversation is the
|
|
143
|
+
* UNFILTERED one, and handing that to a conversation that has model rules would offer it models
|
|
144
|
+
* those rules forbid.
|
|
145
|
+
*/
|
|
146
|
+
async afterChange() {
|
|
147
|
+
this.runtime.invalidateCatalog?.();
|
|
148
|
+
this.runtime.bumpModelEpoch?.();
|
|
149
|
+
for (const conversation of this.store.snapshot().conversations) {
|
|
150
|
+
if (conversation.archivedAt)
|
|
151
|
+
continue;
|
|
152
|
+
try {
|
|
153
|
+
await this.catalogService.broadcastCatalog(conversation.model, conversation.id);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.warn(`供应商已保存,刷新会话 ${conversation.id} 的模型目录失败:${String(error)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// The settings panel's OWN list, which is unfiltered and therefore has to be asked for
|
|
160
|
+
// separately. Without this the person who just added a provider would not see their own model
|
|
161
|
+
// in the default-model dropdown until they closed the window and opened it again.
|
|
162
|
+
await this.catalogService.broadcastAllCatalog?.();
|
|
163
|
+
this.broadcaster.broadcast({ type: "providers.list", providers: await this.list(), apis: [...KNOWN_APIS] });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { Conversation, Message, MessageImage, PiQueueSnapshot, QuestionAnsw
|
|
|
5
5
|
import type { EventBroadcaster } from "../server-context.js";
|
|
6
6
|
import type { ExtensionUiService } from "./extension-ui-service.js";
|
|
7
7
|
import type { WorkflowEngine } from "../workflow-engine.js";
|
|
8
|
+
import { FileChangeTracker } from "../file-changes.js";
|
|
8
9
|
export interface SubagentDispatchRequestBody {
|
|
9
10
|
conversationId?: string;
|
|
10
11
|
subagent_type?: string;
|
|
@@ -62,6 +63,16 @@ export declare class RunService {
|
|
|
62
63
|
private readonly workflows;
|
|
63
64
|
private memory?;
|
|
64
65
|
setMemory(memory: import("../active-memory.js").ActiveMemory): void;
|
|
66
|
+
/**
|
|
67
|
+
* WHAT THE AGENT CHANGED, and what each file looked like before.
|
|
68
|
+
*
|
|
69
|
+
* The tracker is a SETTER rather than a constructor argument, for the same reason `memory` is: it is
|
|
70
|
+
* optional, and a service that must be handed one cannot be built before the thing that builds it.
|
|
71
|
+
*/
|
|
72
|
+
private fileChanges?;
|
|
73
|
+
private fileIndexes?;
|
|
74
|
+
setFileChanges(tracker: FileChangeTracker): void;
|
|
75
|
+
setFileIndexes(indexes: Map<string, import("../file-index.js").FileIndex>): void;
|
|
65
76
|
reviewMemoryBoundary(conversationId: string): void;
|
|
66
77
|
private readonly activeRuns;
|
|
67
78
|
/**
|
|
@@ -129,6 +140,14 @@ export declare class RunService {
|
|
|
129
140
|
private readonly detached;
|
|
130
141
|
constructor(store: Store, runtime: AgentRuntime, subagents: SubagentRunRecorder, broadcaster: EventBroadcaster, extensionUiService: ExtensionUiService, agentsDir: string | string[], workflows: WorkflowEngine);
|
|
131
142
|
setAgentsDir(dir: string | string[]): void;
|
|
143
|
+
/**
|
|
144
|
+
* TELLS THE BROWSER WHICH FILES THE AGENT CHANGED.
|
|
145
|
+
*
|
|
146
|
+
* The whole list goes every time rather than just the newest file, because the event is a SNAPSHOT
|
|
147
|
+
* (like `state` and `subagent.event`): a browser that missed one rebuilds the list from the next
|
|
148
|
+
* one, instead of holding a list assembled from a partial sequence of deltas.
|
|
149
|
+
*/
|
|
150
|
+
broadcastFileChanges(workspaceId: string): void;
|
|
132
151
|
/**
|
|
133
152
|
* RECORDS WHAT THE PERSON CHOSE, ON THE `question` CALL THAT ASKED.
|
|
134
153
|
*
|
|
@@ -137,6 +137,16 @@ export class RunService {
|
|
|
137
137
|
workflows;
|
|
138
138
|
memory;
|
|
139
139
|
setMemory(memory) { this.memory = memory; }
|
|
140
|
+
/**
|
|
141
|
+
* WHAT THE AGENT CHANGED, and what each file looked like before.
|
|
142
|
+
*
|
|
143
|
+
* The tracker is a SETTER rather than a constructor argument, for the same reason `memory` is: it is
|
|
144
|
+
* optional, and a service that must be handed one cannot be built before the thing that builds it.
|
|
145
|
+
*/
|
|
146
|
+
fileChanges;
|
|
147
|
+
fileIndexes;
|
|
148
|
+
setFileChanges(tracker) { this.fileChanges = tracker; }
|
|
149
|
+
setFileIndexes(indexes) { this.fileIndexes = indexes; }
|
|
140
150
|
reviewMemoryBoundary(conversationId) {
|
|
141
151
|
const conversation = this.store.snapshot().conversations.find(c => c.id === conversationId);
|
|
142
152
|
if (conversation)
|
|
@@ -317,6 +327,31 @@ export class RunService {
|
|
|
317
327
|
setAgentsDir(dir) {
|
|
318
328
|
this.agentsDir = dir;
|
|
319
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* TELLS THE BROWSER WHICH FILES THE AGENT CHANGED.
|
|
332
|
+
*
|
|
333
|
+
* The whole list goes every time rather than just the newest file, because the event is a SNAPSHOT
|
|
334
|
+
* (like `state` and `subagent.event`): a browser that missed one rebuilds the list from the next
|
|
335
|
+
* one, instead of holding a list assembled from a partial sequence of deltas.
|
|
336
|
+
*/
|
|
337
|
+
broadcastFileChanges(workspaceId) {
|
|
338
|
+
const tracker = this.fileChanges;
|
|
339
|
+
if (!tracker)
|
|
340
|
+
return;
|
|
341
|
+
const { changes, dropped } = tracker.list(workspaceId);
|
|
342
|
+
this.broadcaster.broadcast({
|
|
343
|
+
type: "files.changed",
|
|
344
|
+
workspaceId,
|
|
345
|
+
dropped,
|
|
346
|
+
changes: changes.map((change) => ({
|
|
347
|
+
path: change.path,
|
|
348
|
+
baseline: change.before,
|
|
349
|
+
...(change.beforeUnknown ? { baselineUnknown: true } : {}),
|
|
350
|
+
...(change.conversationId ? { conversationId: change.conversationId } : {}),
|
|
351
|
+
at: change.at,
|
|
352
|
+
})),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
320
355
|
/**
|
|
321
356
|
* RECORDS WHAT THE PERSON CHOSE, ON THE `question` CALL THAT ASKED.
|
|
322
357
|
*
|
|
@@ -867,6 +902,36 @@ export class RunService {
|
|
|
867
902
|
}));
|
|
868
903
|
preparingRows.set(call.contentIndex, { row, name: call.name });
|
|
869
904
|
};
|
|
905
|
+
/**
|
|
906
|
+
* THE AGENT IS ABOUT TO WRITE A FILE.
|
|
907
|
+
*
|
|
908
|
+
* The baseline is captured HERE, at the announcement, because this is the last moment the old content
|
|
909
|
+
* is still on the disk. By the time the tool has finished, the file holds the new content and the
|
|
910
|
+
* "before" is gone - which is why a diff cannot be reconstructed afterwards from the file itself.
|
|
911
|
+
*
|
|
912
|
+
* The read inside `record` is SYNCHRONOUS on purpose (see `file-changes.ts` for the race it
|
|
913
|
+
* closes), and it is bounded by a size cap, so it costs a fraction of a millisecond for an
|
|
914
|
+
* ordinary source file.
|
|
915
|
+
*/
|
|
916
|
+
/**
|
|
917
|
+
* THE PATHS THE AGENT IS WRITING, keyed by tool call id.
|
|
918
|
+
*
|
|
919
|
+
* The key is what lets the tool's END confirm the baseline of the right file. The alternative - the
|
|
920
|
+
* tool card's one-line summary - is a DISPLAY string: whitespace-collapsed and length-capped, so
|
|
921
|
+
* it is not a path the server can read a file by, and a path with a run of spaces in it would come
|
|
922
|
+
* back as a path with one space.
|
|
923
|
+
*/
|
|
924
|
+
const fileWritePaths = new Map();
|
|
925
|
+
const onFileWrite = (path, _toolName, toolCallId) => {
|
|
926
|
+
const tracker = this.fileChanges;
|
|
927
|
+
if (!tracker)
|
|
928
|
+
return;
|
|
929
|
+
tracker.record(workspace.id, path, { root: workspace.path, conversationId: conversation.id });
|
|
930
|
+
void this.fileIndexes?.get(workspace.id)?.refresh(path);
|
|
931
|
+
if (toolCallId)
|
|
932
|
+
fileWritePaths.set(toolCallId, path);
|
|
933
|
+
this.broadcastFileChanges(workspace.id);
|
|
934
|
+
};
|
|
870
935
|
const onToolCall = (call) => {
|
|
871
936
|
const at = spoken;
|
|
872
937
|
// THE PLACEHOLDER THAT BELONGS TO THIS CALL, IF ONE IS ON SCREEN.
|
|
@@ -920,6 +985,18 @@ export class RunService {
|
|
|
920
985
|
enqueueTools("a tool call update", () => this.store.updateToolCall(conversation.id, assistant.id, index, { ...(call.args ? { args: call.args } : {}), ...(call.live !== undefined ? { live: call.live } : {}) }));
|
|
921
986
|
};
|
|
922
987
|
const onToolEnd = (call) => {
|
|
988
|
+
// THE WRITE IS FINISHED, so the baseline can be CHECKED. The tracker recorded the file's etag when
|
|
989
|
+
// the write was announced; if the file's etag now differs, the baseline really is the content
|
|
990
|
+
// from before the write. If it does not, the baseline was taken too late to be shown, and the
|
|
991
|
+
// tracker says so rather than letting the diff view claim two identical versions are the same.
|
|
992
|
+
const writtenPath = call.id ? fileWritePaths.get(call.id) : undefined;
|
|
993
|
+
if (writtenPath) {
|
|
994
|
+
this.fileChanges?.confirm(workspace.id, writtenPath, workspace.path);
|
|
995
|
+
void this.fileIndexes?.get(workspace.id)?.refresh(writtenPath);
|
|
996
|
+
if (call.id)
|
|
997
|
+
fileWritePaths.delete(call.id);
|
|
998
|
+
this.broadcastFileChanges(workspace.id);
|
|
999
|
+
}
|
|
923
1000
|
const index = call.id ? rowByToolCallId.get(call.id) : undefined;
|
|
924
1001
|
if (index === undefined)
|
|
925
1002
|
return;
|
|
@@ -992,6 +1069,7 @@ export class RunService {
|
|
|
992
1069
|
onUserEntry: (entryId) => this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId),
|
|
993
1070
|
onSessionFile: (path) => this.store.setConversationPiSessionFile(conversation.id, path),
|
|
994
1071
|
onToolCall,
|
|
1072
|
+
onFileWrite,
|
|
995
1073
|
onToolPreparing,
|
|
996
1074
|
onToolUpdate,
|
|
997
1075
|
onToolEnd,
|
|
@@ -4,6 +4,7 @@ import type { ConversationService } from "./application/conversation-service.js"
|
|
|
4
4
|
import type { RunService } from "./application/run-service.js";
|
|
5
5
|
import type { ExtensionUiService } from "./application/extension-ui-service.js";
|
|
6
6
|
import type { CatalogService } from "./application/catalog-service.js";
|
|
7
|
+
import type { ProviderService } from "./application/provider-service.js";
|
|
7
8
|
import type { TerminalService } from "./terminal.js";
|
|
8
9
|
export type SocketSender = (event: ServerEvent) => void;
|
|
9
10
|
/**
|
|
@@ -17,6 +18,13 @@ export declare class CommandHandler {
|
|
|
17
18
|
private readonly catalogService;
|
|
18
19
|
/** Path to the Super Pi repository root, used to spawn the restart script. */
|
|
19
20
|
private readonly rootDir?;
|
|
21
|
+
/**
|
|
22
|
+
* The providers a person added through the settings window.
|
|
23
|
+
*
|
|
24
|
+
* Optional because the command handler is constructed by tests that do not care about providers,
|
|
25
|
+
* and a missing service has to say so rather than take the whole handler down.
|
|
26
|
+
*/
|
|
27
|
+
private readonly providerService?;
|
|
20
28
|
/**
|
|
21
29
|
* The built-in terminal service.
|
|
22
30
|
*
|
|
@@ -25,7 +33,14 @@ export declare class CommandHandler {
|
|
|
25
33
|
private readonly terminalService?;
|
|
26
34
|
constructor(workspaceService: WorkspaceService, conversationService: ConversationService, runService: RunService, extensionUiService: ExtensionUiService, catalogService: CatalogService, terminalService?: TerminalService,
|
|
27
35
|
/** Path to the Super Pi repository root, used to spawn the restart script. */
|
|
28
|
-
rootDir?: string | undefined
|
|
36
|
+
rootDir?: string | undefined,
|
|
37
|
+
/**
|
|
38
|
+
* The providers a person added through the settings window.
|
|
39
|
+
*
|
|
40
|
+
* Optional because the command handler is constructed by tests that do not care about providers,
|
|
41
|
+
* and a missing service has to say so rather than take the whole handler down.
|
|
42
|
+
*/
|
|
43
|
+
providerService?: ProviderService | undefined);
|
|
29
44
|
/**
|
|
30
45
|
* Processes a client command.
|
|
31
46
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { KNOWN_APIS } from "./providers.js";
|
|
3
4
|
import { packageUpdateArgs } from "./update.js";
|
|
4
5
|
/**
|
|
5
6
|
* Routes client commands to appropriate application services and converts results or errors.
|
|
@@ -11,6 +12,7 @@ export class CommandHandler {
|
|
|
11
12
|
extensionUiService;
|
|
12
13
|
catalogService;
|
|
13
14
|
rootDir;
|
|
15
|
+
providerService;
|
|
14
16
|
/**
|
|
15
17
|
* The built-in terminal service.
|
|
16
18
|
*
|
|
@@ -19,13 +21,21 @@ export class CommandHandler {
|
|
|
19
21
|
terminalService;
|
|
20
22
|
constructor(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService,
|
|
21
23
|
/** Path to the Super Pi repository root, used to spawn the restart script. */
|
|
22
|
-
rootDir
|
|
24
|
+
rootDir,
|
|
25
|
+
/**
|
|
26
|
+
* The providers a person added through the settings window.
|
|
27
|
+
*
|
|
28
|
+
* Optional because the command handler is constructed by tests that do not care about providers,
|
|
29
|
+
* and a missing service has to say so rather than take the whole handler down.
|
|
30
|
+
*/
|
|
31
|
+
providerService) {
|
|
23
32
|
this.workspaceService = workspaceService;
|
|
24
33
|
this.conversationService = conversationService;
|
|
25
34
|
this.runService = runService;
|
|
26
35
|
this.extensionUiService = extensionUiService;
|
|
27
36
|
this.catalogService = catalogService;
|
|
28
37
|
this.rootDir = rootDir;
|
|
38
|
+
this.providerService = providerService;
|
|
29
39
|
this.terminalService = terminalService;
|
|
30
40
|
}
|
|
31
41
|
/**
|
|
@@ -69,6 +79,35 @@ export class CommandHandler {
|
|
|
69
79
|
await this.catalogService.setDefaultModel(command.model);
|
|
70
80
|
return;
|
|
71
81
|
}
|
|
82
|
+
// THE PROVIDERS A PERSON ADDED. The list is answered to the ASKING socket only - it is a panel's
|
|
83
|
+
// private list, and broadcasting it would put a provider's API address in every other tab's state
|
|
84
|
+
// for no gain. The set and the remove broadcast the new list themselves, because everybody who
|
|
85
|
+
// has the settings window open has to see the change.
|
|
86
|
+
if (command.type === "providers.set") {
|
|
87
|
+
if (!this.providerService)
|
|
88
|
+
throw new Error("供应商功能不可用");
|
|
89
|
+
await this.providerService.set(command.provider);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (command.type === "providers.remove") {
|
|
93
|
+
if (!this.providerService)
|
|
94
|
+
throw new Error("供应商功能不可用");
|
|
95
|
+
await this.providerService.remove(command.providerId);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (command.type === "providers.removeModel") {
|
|
99
|
+
if (!this.providerService)
|
|
100
|
+
throw new Error("供应商功能不可用");
|
|
101
|
+
await this.providerService.removeModel(command.providerId, command.modelId);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (command.type === "providers.fetch") {
|
|
105
|
+
if (!this.providerService)
|
|
106
|
+
throw new Error("供应商功能不可用");
|
|
107
|
+
if (sendToSocket)
|
|
108
|
+
sendToSocket({ type: "providers.list", providers: await this.providerService.list(), apis: [...KNOWN_APIS] });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
72
111
|
if (command.type === "workspace.create") {
|
|
73
112
|
return await this.workspaceService.createWorkspace(command.name, command.path);
|
|
74
113
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FileIndexSnapshot } from "./file-index.js";
|
|
2
|
+
export declare const MAX_CONTENT_RESULTS = 500;
|
|
3
|
+
export declare const MAX_CONTENT_FILE_BYTES: number;
|
|
4
|
+
export declare const MAX_CONTENT_DEPTH = 12;
|
|
5
|
+
export declare const MAX_CONTENT_MS = 10000;
|
|
6
|
+
export type ContentSearchCandidate = {
|
|
7
|
+
path: string;
|
|
8
|
+
kind: "file" | "directory";
|
|
9
|
+
text: boolean | null;
|
|
10
|
+
size: number | null;
|
|
11
|
+
};
|
|
12
|
+
export type ContentSearchHit = {
|
|
13
|
+
path: string;
|
|
14
|
+
name: string;
|
|
15
|
+
line: number;
|
|
16
|
+
text: string;
|
|
17
|
+
};
|
|
18
|
+
export type ContentSearchResult = {
|
|
19
|
+
hits: ContentSearchHit[];
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
skipped: string[];
|
|
22
|
+
complete: boolean;
|
|
23
|
+
reasons: string[];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* SDK probe: createGrepTool(root).execute(id, { pattern, path, literal: true }) works without a
|
|
27
|
+
* session or running turn (Pi 0.86). Use it for matching. Directory-wide SDK grep would walk a second
|
|
28
|
+
* tree and cannot accept FileIndex candidates or our per-file containment/size/depth policy.
|
|
29
|
+
* Instead give it one verified indexed file at a time; validate again before publishing any hit.
|
|
30
|
+
* The SDK owns ripgrep and its match/byte/line truncation; we expose those limits, never hide them.
|
|
31
|
+
*/
|
|
32
|
+
export declare function searchByContent(root: string, candidates: readonly ContentSearchCandidate[], query: string, indexStatus?: Pick<FileIndexSnapshot, "complete" | "skipped">): Promise<ContentSearchResult>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { createGrepTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { isTextName, looksBinary, resolveInsideRoot } from "./files.js";
|
|
5
|
+
export const MAX_CONTENT_RESULTS = 500;
|
|
6
|
+
export const MAX_CONTENT_FILE_BYTES = 512 * 1024;
|
|
7
|
+
export const MAX_CONTENT_DEPTH = 12;
|
|
8
|
+
export const MAX_CONTENT_MS = 10000;
|
|
9
|
+
/**
|
|
10
|
+
* SDK probe: createGrepTool(root).execute(id, { pattern, path, literal: true }) works without a
|
|
11
|
+
* session or running turn (Pi 0.86). Use it for matching. Directory-wide SDK grep would walk a second
|
|
12
|
+
* tree and cannot accept FileIndex candidates or our per-file containment/size/depth policy.
|
|
13
|
+
* Instead give it one verified indexed file at a time; validate again before publishing any hit.
|
|
14
|
+
* The SDK owns ripgrep and its match/byte/line truncation; we expose those limits, never hide them.
|
|
15
|
+
*/
|
|
16
|
+
export async function searchByContent(root, candidates, query, indexStatus = { complete: true, skipped: [] }) {
|
|
17
|
+
await resolveInsideRoot(root, "");
|
|
18
|
+
const hits = [];
|
|
19
|
+
const skipped = new Set(indexStatus.skipped);
|
|
20
|
+
const reasons = new Set(indexStatus.complete ? [] : ["index"]);
|
|
21
|
+
const needle = query.trim();
|
|
22
|
+
if (!needle)
|
|
23
|
+
return { hits, truncated: false, skipped: [...skipped], complete: indexStatus.complete, reasons: [...reasons] };
|
|
24
|
+
const tool = createGrepTool(root);
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => controller.abort(), MAX_CONTENT_MS);
|
|
27
|
+
try {
|
|
28
|
+
for (const candidate of candidates) {
|
|
29
|
+
if (candidate.kind !== "file")
|
|
30
|
+
continue;
|
|
31
|
+
if (controller.signal.aborted) {
|
|
32
|
+
reasons.add("time");
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
// Validate even an excluded candidate: metadata and cached paths are not a security boundary.
|
|
36
|
+
let absolute;
|
|
37
|
+
try {
|
|
38
|
+
absolute = await resolveInsideRoot(root, candidate.path);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const code = error.code;
|
|
42
|
+
if (code === "outside_workspace" || code === "bad_path" || code === "workspace_missing")
|
|
43
|
+
throw error;
|
|
44
|
+
skipped.add(candidate.path);
|
|
45
|
+
reasons.add("unreadable");
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (candidate.path.replace(/\\/g, "/").split("/").length - 1 > MAX_CONTENT_DEPTH) {
|
|
49
|
+
skipped.add(candidate.path);
|
|
50
|
+
reasons.add("depth");
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (candidate.text === false || !isTextName(candidate.path)) {
|
|
54
|
+
skipped.add(candidate.path);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let bytes;
|
|
58
|
+
try {
|
|
59
|
+
// Read at most cap+1 bytes even if a file grows after stat; never allocate its untrusted size.
|
|
60
|
+
const handle = await open(absolute, "r");
|
|
61
|
+
try {
|
|
62
|
+
const info = await handle.stat();
|
|
63
|
+
if (!info.isFile()) {
|
|
64
|
+
skipped.add(candidate.path);
|
|
65
|
+
reasons.add("unreadable");
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (info.size > MAX_CONTENT_FILE_BYTES) {
|
|
69
|
+
skipped.add(candidate.path);
|
|
70
|
+
reasons.add("size");
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const buffer = Buffer.alloc(MAX_CONTENT_FILE_BYTES + 1);
|
|
74
|
+
let length = 0;
|
|
75
|
+
while (length < buffer.length) {
|
|
76
|
+
const read = await handle.read(buffer, length, buffer.length - length, null);
|
|
77
|
+
if (!read.bytesRead)
|
|
78
|
+
break;
|
|
79
|
+
length += read.bytesRead;
|
|
80
|
+
}
|
|
81
|
+
if (length > MAX_CONTENT_FILE_BYTES) {
|
|
82
|
+
skipped.add(candidate.path);
|
|
83
|
+
reasons.add("size");
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
bytes = buffer.subarray(0, length);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await handle.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
skipped.add(candidate.path);
|
|
94
|
+
reasons.add("unreadable");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (looksBinary(bytes) || bytes.includes(0)) {
|
|
98
|
+
skipped.add(candidate.path);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
skipped.add(candidate.path);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await tool.execute("content-search", {
|
|
111
|
+
pattern: needle, path: absolute, literal: true, limit: MAX_CONTENT_RESULTS - hits.length,
|
|
112
|
+
}, controller.signal);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (controller.signal.aborted) {
|
|
116
|
+
reasons.add("time");
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
// SDK/ripgrep failures are request failures, never a successful empty search.
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
const prefix = basename(absolute) + ":";
|
|
123
|
+
const rows = [];
|
|
124
|
+
for (const block of result.content) {
|
|
125
|
+
if (block.type !== "text")
|
|
126
|
+
continue;
|
|
127
|
+
for (const row of block.text.split("\n")) {
|
|
128
|
+
if (!row.startsWith(prefix))
|
|
129
|
+
continue;
|
|
130
|
+
const match = /^(\d+): (.*)$/.exec(row.slice(prefix.length));
|
|
131
|
+
if (!match)
|
|
132
|
+
continue;
|
|
133
|
+
rows.push({ path: candidate.path, name: basename(absolute), line: Number(match[1]), text: match[2] });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (rows.length) {
|
|
137
|
+
await resolveInsideRoot(root, candidate.path);
|
|
138
|
+
hits.push(...rows.slice(0, MAX_CONTENT_RESULTS - hits.length));
|
|
139
|
+
}
|
|
140
|
+
const details = result.details;
|
|
141
|
+
if (details?.truncation?.truncated)
|
|
142
|
+
reasons.add("output");
|
|
143
|
+
if (details?.linesTruncated)
|
|
144
|
+
reasons.add("line");
|
|
145
|
+
if (details?.matchLimitReached || hits.length >= MAX_CONTENT_RESULTS) {
|
|
146
|
+
reasons.add("results");
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
return { hits, truncated: reasons.size > 0, skipped: [...skipped], complete: indexStatus.complete, reasons: [...reasons] };
|
|
155
|
+
}
|