@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.
Files changed (42) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +8 -7
  3. package/dist/client/assets/index-DBR_t9lF.js +79 -0
  4. package/dist/client/assets/index-mo5Dvh_v.css +1 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/server/application/catalog-service.d.ts +9 -0
  7. package/dist/server/application/catalog-service.js +11 -0
  8. package/dist/server/application/provider-service.d.ts +77 -0
  9. package/dist/server/application/provider-service.js +165 -0
  10. package/dist/server/application/run-service.d.ts +19 -0
  11. package/dist/server/application/run-service.js +78 -0
  12. package/dist/server/command-handler.d.ts +16 -1
  13. package/dist/server/command-handler.js +40 -1
  14. package/dist/server/content-search.d.ts +32 -0
  15. package/dist/server/content-search.js +155 -0
  16. package/dist/server/file-changes.d.ts +119 -0
  17. package/dist/server/file-changes.js +278 -0
  18. package/dist/server/file-diff.d.ts +38 -0
  19. package/dist/server/file-diff.js +97 -0
  20. package/dist/server/file-index.d.ts +56 -0
  21. package/dist/server/file-index.js +150 -0
  22. package/dist/server/files.d.ts +8 -0
  23. package/dist/server/files.js +3 -1
  24. package/dist/server/git.d.ts +17 -0
  25. package/dist/server/git.js +39 -0
  26. package/dist/server/http-server.js +121 -1
  27. package/dist/server/index.js +28 -1
  28. package/dist/server/pi-session-events.d.ts +9 -0
  29. package/dist/server/pi-session-events.js +12 -0
  30. package/dist/server/pi-session-pool.d.ts +19 -1
  31. package/dist/server/pi-session-pool.js +13 -1
  32. package/dist/server/pi-session.d.ts +10 -0
  33. package/dist/server/pi-session.js +11 -0
  34. package/dist/server/providers.d.ts +160 -0
  35. package/dist/server/providers.js +396 -0
  36. package/dist/server/runtime.d.ts +67 -0
  37. package/dist/server/runtime.js +54 -2
  38. package/dist/server/server-context.d.ts +15 -0
  39. package/dist/server/types.d.ts +143 -0
  40. package/package.json +14 -1
  41. package/dist/client/assets/index-DDkbPYLz.css +0 -1
  42. 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
+ }