@nklisch/pi-enhanced 0.2.7 → 0.3.1

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 (70) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-x64-gnu.node +0 -0
  3. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  4. package/node_modules/@nklisch/pi-plugins/README.md +4 -2
  5. package/node_modules/@nklisch/pi-plugins/dist/hooks.js +81 -8
  6. package/node_modules/@nklisch/pi-plugins/dist/hooks.js.map +1 -1
  7. package/node_modules/@nklisch/pi-plugins/dist/mcp.js +32 -1
  8. package/node_modules/@nklisch/pi-plugins/dist/mcp.js.map +1 -1
  9. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +10 -4
  10. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +144 -10
  11. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
  12. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +40 -14
  13. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  14. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +27 -0
  15. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +76 -47
  16. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/public.d.ts +171 -140
  17. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/settings.d.ts +2 -2
  18. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/FORK-MAINTENANCE.md +8 -7
  19. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/VISION.md +12 -10
  20. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +118 -104
  21. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/comparison-with-upstream.md +3 -3
  22. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0004-reconsider-ui-direction.md +5 -0
  23. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  24. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +31 -4
  25. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/default-agents.ts +1 -1
  26. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/invocation-config.ts +7 -4
  27. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/index.ts +18 -9
  28. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/layered-settings.ts +2 -2
  29. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +10 -63
  30. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +122 -76
  31. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +3 -5
  32. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +4 -3
  33. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +2 -23
  34. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +177 -252
  35. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +90 -280
  36. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-state.ts +153 -239
  37. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +569 -700
  38. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace.ts +1 -1
  39. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/notification.ts +78 -122
  40. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/renderer.ts +15 -42
  41. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +44 -95
  42. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service-adapter.ts +157 -90
  43. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service.ts +60 -68
  44. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/conversation.ts +3 -3
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query-source.ts +28 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query.ts +488 -0
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/settings.ts +1 -1
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/agent-tool.ts +171 -249
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-tool.ts +51 -94
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/helpers.ts +9 -6
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/list-tool.ts +46 -0
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/parent-tool-registry.ts +17 -0
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/query-session-tool.ts +412 -0
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/result-renderer.ts +27 -111
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/resume-tool.ts +105 -0
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/spawn-config.ts +26 -8
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/steer-tool.ts +27 -111
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/stop-tool.ts +53 -0
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/types.ts +11 -5
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +33 -27
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/display.ts +6 -3
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigation.ts +121 -10
  63. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +430 -112
  64. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/subagents-settings.ts +1 -1
  65. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/widget-renderer.ts +8 -9
  66. package/node_modules/@nklisch/pi-plugins/package.json +2 -2
  67. package/package.json +1 -1
  68. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/background-spawner.ts +0 -81
  69. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +0 -149
  70. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-report.ts +0 -77
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Pure, bounded projection and query model for child-session transcripts.
3
+ *
4
+ * The projection deliberately has only two entry families. Tool results enrich
5
+ * their matching call instead of becoming a second row, which keeps the tool
6
+ * and the transcript UI consistent when a call is still pending or finishes
7
+ * out of order.
8
+ */
9
+ import type { SessionMessage } from "#src/types";
10
+
11
+ export type SessionQueryKind = "all" | "messages" | "tool_calls" | "tool_results";
12
+ export type SessionQueryOrder = "newest" | "oldest";
13
+ /** Overlay family filter; tool query parameters continue to use `kind`. */
14
+ export type SessionQueryEntryFamily = "all" | "tools";
15
+ export type ToolCallState = "pending" | "completed" | "failed";
16
+ export type SessionQueryField = "text" | "toolName" | "toolCallId" | "arguments" | "result";
17
+ export type SessionQueryOutcome = "matches" | "no_matches" | "page_out_of_range";
18
+
19
+ export const DEFAULT_QUERY_LIMIT = 20;
20
+ export const MIN_QUERY_LIMIT = 1;
21
+ export const MAX_QUERY_LIMIT = 50;
22
+
23
+ /** Returned message text is bounded; matching always uses its complete source field. */
24
+ export const MESSAGE_EXCERPT_CAP = 2_000;
25
+ export const TOOL_ARGUMENTS_EXCERPT_CAP = 2_000;
26
+ export const TOOL_RESULT_EXCERPT_CAP = 4_000;
27
+
28
+ export interface QueryTruncation {
29
+ /** Returned excerpts capped for the caller, by field name. */
30
+ readonly excerpts: readonly string[];
31
+ }
32
+
33
+ export interface QueryMatch {
34
+ /** The complete source field containing the first match. */
35
+ readonly field: SessionQueryField;
36
+ /** UTF-16 offsets in the complete, unmarked source field. */
37
+ readonly sourceRange: {
38
+ readonly start: number;
39
+ readonly end: number;
40
+ };
41
+ }
42
+
43
+ interface QueryEntryBase {
44
+ /** Stable only within this projection; it is never written to a session. */
45
+ readonly id: string;
46
+ /** Position of the originating SessionMessage in the source snapshot. */
47
+ readonly sourceIndex: number;
48
+ readonly timestamp?: number;
49
+ readonly truncation: QueryTruncation;
50
+ /** Present only for a non-empty query; absent for prefix browsing. */
51
+ readonly match?: QueryMatch;
52
+ }
53
+
54
+ export interface MessageQueryEntry extends QueryEntryBase {
55
+ readonly kind: "message";
56
+ readonly role: "user" | "assistant";
57
+ /** A prefix preview for browsing, or a match-centered excerpt for a query. */
58
+ readonly text: string;
59
+ }
60
+
61
+ export interface ToolCallQueryEntry extends QueryEntryBase {
62
+ readonly kind: "tool_call";
63
+ /** Bounded display preview; undefined for native bash-execution messages. */
64
+ readonly toolCallId?: string;
65
+ /** Bounded display preview; the complete name remains query-local. */
66
+ readonly toolName: string;
67
+ /** A bounded JSON arguments preview, or a match-centered excerpt for a query. */
68
+ readonly arguments: string;
69
+ readonly state: ToolCallState;
70
+ /** Bounded result text; undefined means pending/no correlated result, while `""` is a completed empty result. */
71
+ readonly result?: string;
72
+ }
73
+
74
+ export type SessionQueryEntry = MessageQueryEntry | ToolCallQueryEntry;
75
+
76
+ export interface SessionQueryOptions {
77
+ readonly query?: string;
78
+ readonly kind?: SessionQueryKind;
79
+ readonly order?: SessionQueryOrder;
80
+ readonly limit?: number;
81
+ /** Restrict the result to the overlay's tool family without changing search fields. */
82
+ readonly entryFamily?: SessionQueryEntryFamily;
83
+ /** Ordered matching-entry offset; no cursor or query state is retained. */
84
+ readonly offset?: number;
85
+ }
86
+
87
+ export interface SessionQueryResult {
88
+ readonly entries: readonly SessionQueryEntry[];
89
+ /** Number of matching entries before paging. */
90
+ readonly totalMatches: number;
91
+ /** Offset applied to the ordered matching-entry set. */
92
+ readonly offset: number;
93
+ /** Number of entries returned by this model page. */
94
+ readonly returnedCount: number;
95
+ /** Offset for the next page, when entries remain after this page. */
96
+ readonly nextOffset?: number;
97
+ /** Offset for a preceding page, when this result has a preceding page. */
98
+ readonly previousOffset?: number;
99
+ /** Matching entries before the returned page. */
100
+ readonly omittedBefore: number;
101
+ /** Matching entries after the returned page. */
102
+ readonly omittedAfter: number;
103
+ readonly hasMore: boolean;
104
+ /** True whenever the transcript source was read successfully. */
105
+ readonly searchComplete: true;
106
+ readonly outcome: SessionQueryOutcome;
107
+ }
108
+
109
+ interface SearchableEntry {
110
+ entry: SessionQueryEntry;
111
+ /** Complete raw searchable fields; this object never escapes the query call. */
112
+ searchable: Readonly<Partial<Record<SessionQueryField, string>>>;
113
+ }
114
+
115
+ interface BoundedText {
116
+ readonly value: string;
117
+ readonly truncated: boolean;
118
+ }
119
+
120
+ /** Project raw Pi messages into the stable two-family query algebra. */
121
+ export function projectSessionMessages(messages: readonly SessionMessage[]): readonly SessionQueryEntry[] {
122
+ return projectSearchableMessages(messages).map(({ entry }) => entry);
123
+ }
124
+
125
+ function projectSearchableMessages(messages: readonly SessionMessage[]): SearchableEntry[] {
126
+ const projected: SearchableEntry[] = [];
127
+ const calls = new Map<string, SearchableEntry>();
128
+
129
+ for (let sourceIndex = 0; sourceIndex < messages.length; sourceIndex++) {
130
+ const message = messages[sourceIndex]!;
131
+ if (message.role === "user" || message.role === "assistant") {
132
+ const text = visibleText(message.content);
133
+ if (text.trim()) {
134
+ const bounded = bound(text, MESSAGE_EXCERPT_CAP);
135
+ const searchable = { text };
136
+ projected.push({
137
+ entry: {
138
+ kind: "message",
139
+ id: messageIdentity(message.role, sourceIndex, message.timestamp),
140
+ sourceIndex,
141
+ ...(timestampOf(message) === undefined ? {} : { timestamp: timestampOf(message) }),
142
+ role: message.role,
143
+ text: bounded.value,
144
+ truncation: truncationFor({ text: bounded }),
145
+ },
146
+ searchable,
147
+ });
148
+ }
149
+ if (message.role === "assistant") {
150
+ for (const content of message.content) {
151
+ if (content.type !== "toolCall") continue;
152
+ const callId = content.id;
153
+ const args = stringifyArguments(content.arguments);
154
+ const boundedToolName = boundPreview(content.name, TOOL_ARGUMENTS_EXCERPT_CAP);
155
+ const boundedToolCallId = boundPreview(callId, TOOL_ARGUMENTS_EXCERPT_CAP);
156
+ const boundedArgs = bound(args, TOOL_ARGUMENTS_EXCERPT_CAP);
157
+ // Keep the complete name and id only in this projection-local search
158
+ // record. The public entry carries bounded display values, while its
159
+ // stable `id` remains the full correlation key used by the overlay.
160
+ const searchable = { toolName: content.name, toolCallId: callId, arguments: args };
161
+ const call: SearchableEntry = {
162
+ entry: {
163
+ kind: "tool_call",
164
+ id: callId,
165
+ sourceIndex,
166
+ ...(timestampOf(message) === undefined ? {} : { timestamp: timestampOf(message) }),
167
+ toolCallId: boundedToolCallId.value,
168
+ toolName: boundedToolName.value,
169
+ arguments: boundedArgs.value,
170
+ state: "pending",
171
+ truncation: truncationFor({
172
+ toolName: boundedToolName,
173
+ toolCallId: boundedToolCallId,
174
+ arguments: boundedArgs,
175
+ }),
176
+ },
177
+ searchable,
178
+ };
179
+ projected.push(call);
180
+ calls.set(callId, call);
181
+ }
182
+ }
183
+ continue;
184
+ }
185
+
186
+ if (message.role === "bashExecution") {
187
+ const args = message.command;
188
+ const state: ToolCallState = message.exitCode === undefined && !message.cancelled
189
+ ? "pending"
190
+ : message.cancelled || message.exitCode !== 0
191
+ ? "failed"
192
+ : "completed";
193
+ // An empty output after completion is still a result. Keeping `""` rather
194
+ // than collapsing it to undefined lets tool_results distinguish a call
195
+ // that completed silently from one that is still pending.
196
+ const result = state === "pending" ? undefined : message.output ?? "";
197
+ const boundedArgs = bound(args, TOOL_ARGUMENTS_EXCERPT_CAP);
198
+ const boundedResult = result === undefined ? undefined : bound(result, TOOL_RESULT_EXCERPT_CAP);
199
+ const searchable = { toolName: "bash", arguments: args, ...(result === undefined ? {} : { result }) };
200
+ projected.push({
201
+ entry: {
202
+ kind: "tool_call",
203
+ id: `bash:${sourceIndex}`,
204
+ sourceIndex,
205
+ ...(timestampOf(message) === undefined ? {} : { timestamp: timestampOf(message) }),
206
+ toolName: "bash",
207
+ arguments: boundedArgs.value,
208
+ state,
209
+ ...(boundedResult === undefined ? {} : { result: boundedResult.value }),
210
+ truncation: truncationFor({
211
+ arguments: boundedArgs,
212
+ ...(boundedResult === undefined ? {} : { result: boundedResult }),
213
+ }),
214
+ },
215
+ searchable,
216
+ });
217
+ }
218
+ }
219
+
220
+ // Correlate after projection so out-of-order results work and an orphan
221
+ // result never creates a duplicate row.
222
+ for (const message of messages) {
223
+ if (message.role !== "toolResult") continue;
224
+ const call = calls.get(message.toolCallId);
225
+ if (!call) continue;
226
+ const current = call.entry as ToolCallQueryEntry;
227
+ const resultText = visibleText(message.content);
228
+ const boundedResult = bound(resultText, TOOL_RESULT_EXCERPT_CAP);
229
+ const searchable = { ...call.searchable, result: resultText };
230
+ const updated: ToolCallQueryEntry = {
231
+ ...current,
232
+ state: message.isError ? "failed" : "completed",
233
+ // Preserve an empty but completed result as `""`; undefined means that
234
+ // no correlated result has arrived yet.
235
+ result: boundedResult.value,
236
+ truncation: truncationFor({
237
+ arguments: bound(call.searchable.arguments ?? current.arguments, TOOL_ARGUMENTS_EXCERPT_CAP),
238
+ result: boundedResult,
239
+ }),
240
+ };
241
+ call.entry = updated;
242
+ call.searchable = searchable;
243
+ }
244
+
245
+ return projected;
246
+ }
247
+
248
+ /** Query a fresh projection; no derived state is retained between calls. */
249
+ export function querySession(
250
+ messages: readonly SessionMessage[],
251
+ options: SessionQueryOptions = {},
252
+ ): SessionQueryResult {
253
+ const limit = normalizeLimit(options.limit);
254
+ const offset = normalizeOffset(options.offset);
255
+ const kind = options.kind ?? "all";
256
+ const order = options.order ?? "newest";
257
+ const query = options.query ?? "";
258
+ const needle = query.toLowerCase();
259
+ const projected = projectSearchableMessages(messages);
260
+ const matching = projected.flatMap((candidate): SessionQueryEntry[] => {
261
+ if (options.entryFamily === "tools" && candidate.entry.kind !== "tool_call") return [];
262
+ if (!matchesKind(candidate.entry, kind)) return [];
263
+ if (needle.length === 0) return [candidate.entry];
264
+ const match = findFirstMatch(candidate.entry, candidate.searchable, needle, kind);
265
+ return match === undefined ? [] : [entryWithMatch(candidate, match)];
266
+ });
267
+ const ordered = order === "newest" ? [...matching].reverse() : matching;
268
+ const totalMatches = ordered.length;
269
+ const outcome: SessionQueryOutcome = totalMatches === 0
270
+ ? "no_matches"
271
+ : offset >= totalMatches
272
+ ? "page_out_of_range"
273
+ : "matches";
274
+ const entries = outcome === "matches"
275
+ ? ordered.slice(offset, Math.min(totalMatches, offset + limit))
276
+ : [];
277
+ const returnedCount = entries.length;
278
+ const nextOffset = offset < totalMatches && offset + returnedCount < totalMatches
279
+ ? offset + returnedCount
280
+ : undefined;
281
+ const previousOffset = totalMatches > 0 && offset > 0
282
+ ? Math.max(0, Math.min(Math.max(0, totalMatches - limit), offset - limit))
283
+ : undefined;
284
+ const omittedBefore = Math.min(offset, totalMatches);
285
+ const omittedAfter = Math.max(0, totalMatches - offset - returnedCount);
286
+ return {
287
+ entries,
288
+ totalMatches,
289
+ offset,
290
+ returnedCount,
291
+ ...(nextOffset === undefined ? {} : { nextOffset }),
292
+ ...(previousOffset === undefined ? {} : { previousOffset }),
293
+ omittedBefore,
294
+ omittedAfter,
295
+ hasMore: nextOffset !== undefined,
296
+ searchComplete: true,
297
+ outcome,
298
+ };
299
+ }
300
+
301
+ export function normalizeLimit(limit: number | undefined): number {
302
+ const value = limit ?? DEFAULT_QUERY_LIMIT;
303
+ if (!Number.isInteger(value) || value < MIN_QUERY_LIMIT || value > MAX_QUERY_LIMIT) {
304
+ throw new RangeError(`limit must be an integer from ${MIN_QUERY_LIMIT} to ${MAX_QUERY_LIMIT}`);
305
+ }
306
+ return value;
307
+ }
308
+
309
+ export function normalizeOffset(offset: number | undefined): number {
310
+ const value = offset ?? 0;
311
+ if (!Number.isSafeInteger(value) || value < 0) {
312
+ throw new RangeError("offset must be a non-negative safe integer");
313
+ }
314
+ return value;
315
+ }
316
+
317
+ function matchesKind(entry: SessionQueryEntry, kind: SessionQueryKind): boolean {
318
+ if (kind === "all") return true;
319
+ if (kind === "messages") return entry.kind === "message";
320
+ if (kind === "tool_calls") return entry.kind === "tool_call";
321
+ return entry.kind === "tool_call" && entry.result !== undefined;
322
+ }
323
+
324
+ function findFirstMatch(
325
+ entry: SessionQueryEntry,
326
+ searchable: Readonly<Partial<Record<SessionQueryField, string>>>,
327
+ needle: string,
328
+ kind: SessionQueryKind,
329
+ ): QueryMatch | undefined {
330
+ const fields = entry.kind === "message"
331
+ ? ["text"] as const
332
+ : kind === "tool_results"
333
+ ? ["result"] as const
334
+ : kind === "tool_calls"
335
+ ? ["toolName", "toolCallId", "arguments"] as const
336
+ : ["toolName", "toolCallId", "arguments", "result"] as const;
337
+ for (const field of fields) {
338
+ const value = searchable[field];
339
+ if (value === undefined) continue;
340
+ const lowered = value.toLowerCase();
341
+ const matchStart = lowered.indexOf(needle);
342
+ if (matchStart < 0) continue;
343
+ const matchEnd = matchStart + needle.length;
344
+ const sourceStart = sourceOffsetForLower(value, matchStart, false);
345
+ const sourceEnd = Math.max(sourceStart + 1, sourceOffsetForLower(value, matchEnd, true));
346
+ return { field, sourceRange: { start: sourceStart, end: Math.min(value.length, sourceEnd) } };
347
+ }
348
+ return undefined;
349
+ }
350
+
351
+ function entryWithMatch(candidate: SearchableEntry, match: QueryMatch): SessionQueryEntry {
352
+ const { entry, searchable } = candidate;
353
+ if (entry.kind === "message") {
354
+ const text = searchable.text ?? entry.text;
355
+ const bounded = boundAroundMatch(text, match.sourceRange, MESSAGE_EXCERPT_CAP);
356
+ return { ...entry, text: bounded.value, truncation: truncationFor({ text: bounded }), match };
357
+ }
358
+
359
+ const toolName = searchable.toolName ?? entry.toolName;
360
+ const boundedToolName = match.field === "toolName"
361
+ ? boundAroundMatch(toolName, match.sourceRange, TOOL_ARGUMENTS_EXCERPT_CAP)
362
+ : boundPreview(toolName, TOOL_ARGUMENTS_EXCERPT_CAP);
363
+ const toolCallId = searchable.toolCallId;
364
+ const boundedToolCallId = toolCallId === undefined
365
+ ? undefined
366
+ : match.field === "toolCallId"
367
+ ? boundAroundMatch(toolCallId, match.sourceRange, TOOL_ARGUMENTS_EXCERPT_CAP)
368
+ : boundPreview(toolCallId, TOOL_ARGUMENTS_EXCERPT_CAP);
369
+ const argumentsText = searchable.arguments ?? entry.arguments;
370
+ const boundedArguments = match.field === "arguments"
371
+ ? boundAroundMatch(argumentsText, match.sourceRange, TOOL_ARGUMENTS_EXCERPT_CAP)
372
+ : bound(argumentsText, TOOL_ARGUMENTS_EXCERPT_CAP);
373
+ const boundedResult = searchable.result === undefined
374
+ ? undefined
375
+ : match.field === "result"
376
+ ? boundAroundMatch(searchable.result, match.sourceRange, TOOL_RESULT_EXCERPT_CAP)
377
+ : bound(searchable.result, TOOL_RESULT_EXCERPT_CAP);
378
+ return {
379
+ ...entry,
380
+ toolName: boundedToolName.value,
381
+ ...(boundedToolCallId === undefined ? {} : { toolCallId: boundedToolCallId.value }),
382
+ arguments: boundedArguments.value,
383
+ ...(boundedResult === undefined ? {} : { result: boundedResult.value }),
384
+ truncation: truncationFor({
385
+ toolName: boundedToolName,
386
+ ...(boundedToolCallId === undefined ? {} : { toolCallId: boundedToolCallId }),
387
+ arguments: boundedArguments,
388
+ ...(boundedResult === undefined ? {} : { result: boundedResult }),
389
+ }),
390
+ match,
391
+ };
392
+ }
393
+
394
+ function visibleText(content: unknown): string {
395
+ if (typeof content === "string") return content;
396
+ if (!Array.isArray(content)) return "";
397
+ return content
398
+ .filter((part): part is { type: "text"; text?: string } =>
399
+ typeof part === "object" && part !== null && (part as { type?: unknown }).type === "text",
400
+ )
401
+ .map((part) => part.text ?? "")
402
+ .join("\n");
403
+ }
404
+
405
+ function stringifyArguments(value: unknown): string {
406
+ if (typeof value === "string") return value;
407
+ try {
408
+ return JSON.stringify(value) ?? "";
409
+ } catch {
410
+ return String(value);
411
+ }
412
+ }
413
+
414
+ function timestampOf(message: SessionMessage): number | undefined {
415
+ return typeof message.timestamp === "number" ? message.timestamp : undefined;
416
+ }
417
+
418
+ function messageIdentity(role: "user" | "assistant", sourceIndex: number, timestamp: unknown): string {
419
+ return `message:${role}:${sourceIndex}${typeof timestamp === "number" ? `:${timestamp}` : ""}`;
420
+ }
421
+
422
+ function bound(value: string, cap: number): BoundedText {
423
+ return value.length > cap ? { value: value.slice(0, cap), truncated: true } : { value, truncated: false };
424
+ }
425
+
426
+ /** Prefix previews identify omitted metadata without exposing the full field. */
427
+ function boundPreview(value: string, cap: number): BoundedText {
428
+ if (value.length <= cap) return { value, truncated: false };
429
+ const marker = "…";
430
+ const contentCap = Math.max(0, cap - marker.length);
431
+ return { value: `${value.slice(0, contentCap)}${marker}`, truncated: true };
432
+ }
433
+
434
+ /**
435
+ * Keep the first match visible while spending the excerpt budget on context.
436
+ * The source range remains in raw-field coordinates; markers are presentation
437
+ * text and are not included in that range.
438
+ */
439
+ function boundAroundMatch(value: string, sourceRange: { start: number; end: number }, cap: number): BoundedText {
440
+ if (value.length <= cap) return { value, truncated: false };
441
+ const contentCap = Math.max(1, cap - 2);
442
+ const matchStart = Math.max(0, Math.min(value.length, sourceRange.start));
443
+ const matchEnd = Math.max(matchStart + 1, Math.min(value.length, sourceRange.end));
444
+ if (matchEnd - matchStart >= contentCap) {
445
+ const end = Math.min(value.length, matchStart + contentCap);
446
+ return { value: `${value.slice(0, matchStart) ? "…" : ""}${value.slice(matchStart, end)}${end < value.length ? "…" : ""}`, truncated: true };
447
+ }
448
+
449
+ const windowStart = Math.max(0, matchEnd - contentCap);
450
+ const windowEnd = Math.min(matchStart, value.length - contentCap);
451
+ const centeredStart = matchStart - Math.floor((contentCap - (matchEnd - matchStart)) / 2);
452
+ const start = Math.max(windowStart, Math.min(windowEnd, centeredStart));
453
+ const end = Math.min(value.length, start + contentCap);
454
+ return {
455
+ value: `${start > 0 ? "…" : ""}${value.slice(start, end)}${end < value.length ? "…" : ""}`,
456
+ truncated: true,
457
+ };
458
+ }
459
+
460
+ /**
461
+ * Map an offset in the lower-cased field back to the original UTF-16 field.
462
+ * Matching itself uses `value.toLowerCase()` as a whole; this linear walk only
463
+ * accounts for each code point's lower-case UTF-16 width, including expanding
464
+ * mappings such as `İ` → `i` plus a combining dot.
465
+ */
466
+ function sourceOffsetForLower(value: string, lowerOffset: number, end: boolean): number {
467
+ let sourceOffset = 0;
468
+ let lowerPosition = 0;
469
+ for (const character of value) {
470
+ if (lowerOffset <= lowerPosition) return sourceOffset;
471
+ const lowerLength = character.toLowerCase().length;
472
+ const nextLowerPosition = lowerPosition + lowerLength;
473
+ if (lowerOffset < nextLowerPosition || (end && lowerOffset === nextLowerPosition)) {
474
+ return end ? sourceOffset + character.length : sourceOffset;
475
+ }
476
+ sourceOffset += character.length;
477
+ lowerPosition = nextLowerPosition;
478
+ }
479
+ return sourceOffset;
480
+ }
481
+
482
+ function truncationFor(excerpts: Readonly<Record<string, BoundedText>>): QueryTruncation {
483
+ return {
484
+ excerpts: Object.entries(excerpts)
485
+ .filter(([, value]) => value.truncated)
486
+ .map(([name]) => name),
487
+ };
488
+ }
@@ -10,7 +10,7 @@ export interface SubagentsSettings {
10
10
  /**
11
11
  * 0 = unlimited — the extension's single source of truth for that convention:
12
12
  * `normalizeMaxTurns()` in turn-limits.ts treats 0 → `undefined`, and the
13
- * `/agents` → Settings input prompt explicitly says "0 = unlimited".
13
+ * `/subagents:settings` input prompt explicitly says "0 = unlimited".
14
14
  */
15
15
  defaultMaxTurns?: number;
16
16
  graceTurns?: number;