@nklisch/pi-enhanced 0.3.0 → 0.4.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 (46) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -0
  3. package/node_modules/@nklisch/pi-astral-pocket/LICENSE +3 -0
  4. package/node_modules/@nklisch/pi-astral-pocket/README.md +82 -0
  5. package/node_modules/@nklisch/pi-astral-pocket/package.json +52 -0
  6. package/node_modules/@nklisch/pi-astral-pocket/src/activation.ts +44 -0
  7. package/node_modules/@nklisch/pi-astral-pocket/src/config.ts +78 -0
  8. package/node_modules/@nklisch/pi-astral-pocket/src/distiller.ts +162 -0
  9. package/node_modules/@nklisch/pi-astral-pocket/src/guidance.ts +59 -0
  10. package/node_modules/@nklisch/pi-astral-pocket/src/index.ts +127 -0
  11. package/node_modules/@nklisch/pi-astral-pocket/src/sessions.ts +225 -0
  12. package/node_modules/@nklisch/pi-astral-pocket/src/store.ts +227 -0
  13. package/node_modules/@nklisch/pi-astral-pocket/src/tools.ts +112 -0
  14. package/node_modules/@nklisch/pi-clearance/native/clearance-core.darwin-arm64.node +0 -0
  15. package/node_modules/@nklisch/pi-clearance/native/clearance-core.darwin-x64.node +0 -0
  16. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-arm64-gnu.node +0 -0
  17. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  18. package/node_modules/@nklisch/pi-conveniences/extensions/agents-context.ts +4 -6
  19. package/node_modules/@nklisch/pi-conveniences/extensions/context-window-footer.ts +38 -22
  20. package/node_modules/@nklisch/pi-conveniences/package.json +1 -1
  21. package/node_modules/@nklisch/pi-plugins/README.md +12 -6
  22. package/node_modules/@nklisch/pi-plugins/dist/catalog.js +11 -0
  23. package/node_modules/@nklisch/pi-plugins/dist/catalog.js.map +1 -1
  24. package/node_modules/@nklisch/pi-plugins/dist/host.js +85 -29
  25. package/node_modules/@nklisch/pi-plugins/dist/host.js.map +1 -1
  26. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.d.ts +4 -0
  27. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.js +24 -0
  28. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager-layout.js.map +1 -0
  29. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +6 -0
  30. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +130 -47
  31. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
  32. package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.d.ts +9 -0
  33. package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.js +37 -0
  34. package/node_modules/@nklisch/pi-plugins/dist/plugin-metadata.js.map +1 -0
  35. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +2 -0
  36. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  37. package/node_modules/@nklisch/pi-plugins/dist/types.d.ts +4 -1
  38. package/node_modules/@nklisch/pi-plugins/dist/types.js.map +1 -1
  39. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +8 -0
  40. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +2 -1
  41. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +12 -5
  42. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  43. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query.ts +229 -68
  44. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/query-session-tool.ts +115 -33
  45. package/node_modules/@nklisch/pi-plugins/package.json +2 -2
  46. package/package.json +4 -1
@@ -13,24 +13,33 @@ export type SessionQueryOrder = "newest" | "oldest";
13
13
  /** Overlay family filter; tool query parameters continue to use `kind`. */
14
14
  export type SessionQueryEntryFamily = "all" | "tools";
15
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";
16
18
 
17
19
  export const DEFAULT_QUERY_LIMIT = 20;
18
20
  export const MIN_QUERY_LIMIT = 1;
19
21
  export const MAX_QUERY_LIMIT = 50;
20
22
 
21
- /** Caps are character caps: transcript content is text, not a byte protocol. */
22
- export const QUERY_SEARCH_FIELD_CAP = 8_192;
23
+ /** Returned message text is bounded; matching always uses its complete source field. */
23
24
  export const MESSAGE_EXCERPT_CAP = 2_000;
24
25
  export const TOOL_ARGUMENTS_EXCERPT_CAP = 2_000;
25
26
  export const TOOL_RESULT_EXCERPT_CAP = 4_000;
26
27
 
27
28
  export interface QueryTruncation {
28
- /** Search fields capped before matching, by field name. */
29
- readonly searchFields: readonly string[];
30
29
  /** Returned excerpts capped for the caller, by field name. */
31
30
  readonly excerpts: readonly string[];
32
31
  }
33
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
+
34
43
  interface QueryEntryBase {
35
44
  /** Stable only within this projection; it is never written to a session. */
36
45
  readonly id: string;
@@ -38,21 +47,24 @@ interface QueryEntryBase {
38
47
  readonly sourceIndex: number;
39
48
  readonly timestamp?: number;
40
49
  readonly truncation: QueryTruncation;
50
+ /** Present only for a non-empty query; absent for prefix browsing. */
51
+ readonly match?: QueryMatch;
41
52
  }
42
53
 
43
54
  export interface MessageQueryEntry extends QueryEntryBase {
44
55
  readonly kind: "message";
45
56
  readonly role: "user" | "assistant";
46
- /** Bounded visible message text. */
57
+ /** A prefix preview for browsing, or a match-centered excerpt for a query. */
47
58
  readonly text: string;
48
59
  }
49
60
 
50
61
  export interface ToolCallQueryEntry extends QueryEntryBase {
51
62
  readonly kind: "tool_call";
52
- /** Undefined for a native bash-execution message, which has no call id. */
63
+ /** Bounded display preview; undefined for native bash-execution messages. */
53
64
  readonly toolCallId?: string;
65
+ /** Bounded display preview; the complete name remains query-local. */
54
66
  readonly toolName: string;
55
- /** Bounded JSON arguments (or the command for a bash execution). */
67
+ /** A bounded JSON arguments preview, or a match-centered excerpt for a query. */
56
68
  readonly arguments: string;
57
69
  readonly state: ToolCallState;
58
70
  /** Bounded result text; undefined means pending/no correlated result, while `""` is a completed empty result. */
@@ -68,20 +80,36 @@ export interface SessionQueryOptions {
68
80
  readonly limit?: number;
69
81
  /** Restrict the result to the overlay's tool family without changing search fields. */
70
82
  readonly entryFamily?: SessionQueryEntryFamily;
83
+ /** Ordered matching-entry offset; no cursor or query state is retained. */
84
+ readonly offset?: number;
71
85
  }
72
86
 
73
87
  export interface SessionQueryResult {
74
88
  readonly entries: readonly SessionQueryEntry[];
75
- /** Number of entries matching before the result limit is applied. */
89
+ /** Number of matching entries before paging. */
76
90
  readonly totalMatches: number;
77
- readonly omittedCount: 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;
78
103
  readonly hasMore: boolean;
104
+ /** True whenever the transcript source was read successfully. */
105
+ readonly searchComplete: true;
106
+ readonly outcome: SessionQueryOutcome;
79
107
  }
80
108
 
81
109
  interface SearchableEntry {
82
110
  entry: SessionQueryEntry;
83
- searchable: Readonly<Record<string, string>>;
84
- rawSearchable: Readonly<Record<string, string>>;
111
+ /** Complete raw searchable fields; this object never escapes the query call. */
112
+ searchable: Readonly<Partial<Record<SessionQueryField, string>>>;
85
113
  }
86
114
 
87
115
  interface BoundedText {
@@ -104,8 +132,7 @@ function projectSearchableMessages(messages: readonly SessionMessage[]): Searcha
104
132
  const text = visibleText(message.content);
105
133
  if (text.trim()) {
106
134
  const bounded = bound(text, MESSAGE_EXCERPT_CAP);
107
- const rawSearchable = { text };
108
- const searchable = searchFields(rawSearchable);
135
+ const searchable = { text };
109
136
  projected.push({
110
137
  entry: {
111
138
  kind: "message",
@@ -114,10 +141,9 @@ function projectSearchableMessages(messages: readonly SessionMessage[]): Searcha
114
141
  ...(timestampOf(message) === undefined ? {} : { timestamp: timestampOf(message) }),
115
142
  role: message.role,
116
143
  text: bounded.value,
117
- truncation: truncationFor(rawSearchable, { text: bounded }),
144
+ truncation: truncationFor({ text: bounded }),
118
145
  },
119
146
  searchable,
120
- rawSearchable,
121
147
  });
122
148
  }
123
149
  if (message.role === "assistant") {
@@ -125,23 +151,30 @@ function projectSearchableMessages(messages: readonly SessionMessage[]): Searcha
125
151
  if (content.type !== "toolCall") continue;
126
152
  const callId = content.id;
127
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);
128
156
  const boundedArgs = bound(args, TOOL_ARGUMENTS_EXCERPT_CAP);
129
- const rawSearchable = { toolName: content.name, toolCallId: callId, arguments: args };
130
- const searchable = searchFields(rawSearchable);
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 };
131
161
  const call: SearchableEntry = {
132
162
  entry: {
133
163
  kind: "tool_call",
134
164
  id: callId,
135
165
  sourceIndex,
136
166
  ...(timestampOf(message) === undefined ? {} : { timestamp: timestampOf(message) }),
137
- toolCallId: callId,
138
- toolName: content.name,
167
+ toolCallId: boundedToolCallId.value,
168
+ toolName: boundedToolName.value,
139
169
  arguments: boundedArgs.value,
140
170
  state: "pending",
141
- truncation: truncationFor(rawSearchable, { arguments: boundedArgs }),
171
+ truncation: truncationFor({
172
+ toolName: boundedToolName,
173
+ toolCallId: boundedToolCallId,
174
+ arguments: boundedArgs,
175
+ }),
142
176
  },
143
177
  searchable,
144
- rawSearchable,
145
178
  };
146
179
  projected.push(call);
147
180
  calls.set(callId, call);
@@ -163,8 +196,7 @@ function projectSearchableMessages(messages: readonly SessionMessage[]): Searcha
163
196
  const result = state === "pending" ? undefined : message.output ?? "";
164
197
  const boundedArgs = bound(args, TOOL_ARGUMENTS_EXCERPT_CAP);
165
198
  const boundedResult = result === undefined ? undefined : bound(result, TOOL_RESULT_EXCERPT_CAP);
166
- const rawSearchable = { toolName: "bash", arguments: args, ...(result === undefined ? {} : { result }) };
167
- const searchable = searchFields(rawSearchable);
199
+ const searchable = { toolName: "bash", arguments: args, ...(result === undefined ? {} : { result }) };
168
200
  projected.push({
169
201
  entry: {
170
202
  kind: "tool_call",
@@ -175,13 +207,12 @@ function projectSearchableMessages(messages: readonly SessionMessage[]): Searcha
175
207
  arguments: boundedArgs.value,
176
208
  state,
177
209
  ...(boundedResult === undefined ? {} : { result: boundedResult.value }),
178
- truncation: truncationFor(rawSearchable, {
210
+ truncation: truncationFor({
179
211
  arguments: boundedArgs,
180
212
  ...(boundedResult === undefined ? {} : { result: boundedResult }),
181
213
  }),
182
214
  },
183
215
  searchable,
184
- rawSearchable,
185
216
  });
186
217
  }
187
218
  }
@@ -195,22 +226,20 @@ function projectSearchableMessages(messages: readonly SessionMessage[]): Searcha
195
226
  const current = call.entry as ToolCallQueryEntry;
196
227
  const resultText = visibleText(message.content);
197
228
  const boundedResult = bound(resultText, TOOL_RESULT_EXCERPT_CAP);
198
- const rawSearchable = { ...call.rawSearchable, result: resultText };
199
- const searchable = searchFields(rawSearchable);
229
+ const searchable = { ...call.searchable, result: resultText };
200
230
  const updated: ToolCallQueryEntry = {
201
231
  ...current,
202
232
  state: message.isError ? "failed" : "completed",
203
233
  // Preserve an empty but completed result as `""`; undefined means that
204
234
  // no correlated result has arrived yet.
205
235
  result: boundedResult.value,
206
- truncation: truncationFor(rawSearchable, {
207
- arguments: bound(call.rawSearchable.arguments ?? current.arguments, TOOL_ARGUMENTS_EXCERPT_CAP),
236
+ truncation: truncationFor({
237
+ arguments: bound(call.searchable.arguments ?? current.arguments, TOOL_ARGUMENTS_EXCERPT_CAP),
208
238
  result: boundedResult,
209
239
  }),
210
240
  };
211
241
  call.entry = updated;
212
242
  call.searchable = searchable;
213
- call.rawSearchable = rawSearchable;
214
243
  }
215
244
 
216
245
  return projected;
@@ -222,23 +251,50 @@ export function querySession(
222
251
  options: SessionQueryOptions = {},
223
252
  ): SessionQueryResult {
224
253
  const limit = normalizeLimit(options.limit);
254
+ const offset = normalizeOffset(options.offset);
225
255
  const kind = options.kind ?? "all";
226
256
  const order = options.order ?? "newest";
227
257
  const query = options.query ?? "";
228
258
  const needle = query.toLowerCase();
229
259
  const projected = projectSearchableMessages(messages);
230
- const matches = projected
231
- .filter(({ entry }) => options.entryFamily !== "tools" || entry.kind === "tool_call")
232
- .filter(({ entry }) => matchesKind(entry, kind))
233
- .filter(({ searchable }) => matchesQuery(searchable, needle, kind))
234
- .map(({ entry }) => entry);
235
- const ordered = order === "newest" ? [...matches].reverse() : matches;
236
- const entries = ordered.slice(0, limit);
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);
237
286
  return {
238
287
  entries,
239
- totalMatches: matches.length,
240
- omittedCount: Math.max(0, matches.length - entries.length),
241
- hasMore: matches.length > entries.length,
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,
242
298
  };
243
299
  }
244
300
 
@@ -250,6 +306,14 @@ export function normalizeLimit(limit: number | undefined): number {
250
306
  return value;
251
307
  }
252
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
+
253
317
  function matchesKind(entry: SessionQueryEntry, kind: SessionQueryKind): boolean {
254
318
  if (kind === "all") return true;
255
319
  if (kind === "messages") return entry.kind === "message";
@@ -257,20 +321,74 @@ function matchesKind(entry: SessionQueryEntry, kind: SessionQueryKind): boolean
257
321
  return entry.kind === "tool_call" && entry.result !== undefined;
258
322
  }
259
323
 
260
- function matchesQuery(
261
- searchable: Readonly<Record<string, string>>,
324
+ function findFirstMatch(
325
+ entry: SessionQueryEntry,
326
+ searchable: Readonly<Partial<Record<SessionQueryField, string>>>,
262
327
  needle: string,
263
328
  kind: SessionQueryKind,
264
- ): boolean {
265
- if (!needle) return true;
266
- if (kind === "messages") return searchable.text?.toLowerCase().includes(needle) ?? false;
267
- if (kind === "tool_calls") {
268
- return [searchable.toolName, searchable.toolCallId, searchable.arguments]
269
- .filter((value): value is string => value !== undefined)
270
- .some((value) => value.toLowerCase().includes(needle));
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 };
271
357
  }
272
- if (kind === "tool_results") return searchable.result?.toLowerCase().includes(needle) ?? false;
273
- return Object.values(searchable).some((value) => value.toLowerCase().includes(needle));
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
+ };
274
392
  }
275
393
 
276
394
  function visibleText(content: unknown): string {
@@ -301,27 +419,70 @@ function messageIdentity(role: "user" | "assistant", sourceIndex: number, timest
301
419
  return `message:${role}:${sourceIndex}${typeof timestamp === "number" ? `:${timestamp}` : ""}`;
302
420
  }
303
421
 
304
- function capForSearch(value: string): string {
305
- return value.length > QUERY_SEARCH_FIELD_CAP ? value.slice(0, QUERY_SEARCH_FIELD_CAP) : value;
422
+ function bound(value: string, cap: number): BoundedText {
423
+ return value.length > cap ? { value: value.slice(0, cap), truncated: true } : { value, truncated: false };
306
424
  }
307
425
 
308
- function searchFields(fields: Readonly<Record<string, string>>): Readonly<Record<string, string>> {
309
- return Object.fromEntries(Object.entries(fields).map(([name, value]) => [name, capForSearch(value)]));
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 };
310
432
  }
311
433
 
312
- function bound(value: string, cap: number): BoundedText {
313
- return value.length > cap ? { value: value.slice(0, cap), truncated: true } : { value, truncated: false };
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
+ };
314
458
  }
315
459
 
316
- function truncationFor(
317
- rawSearchable: Readonly<Record<string, string>>,
318
- excerpts: Readonly<Record<string, BoundedText>>,
319
- ): QueryTruncation {
320
- const searchFields = Object.entries(rawSearchable)
321
- .filter(([, value]) => value.length > QUERY_SEARCH_FIELD_CAP)
322
- .map(([name]) => name);
323
- const excerptFields = Object.entries(excerpts)
324
- .filter(([, value]) => value.truncated)
325
- .map(([name]) => name);
326
- return { searchFields, excerpts: excerptFields };
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
+ };
327
488
  }