maka-agent 0.2.0-dev.44.20260920 → 0.2.0-dev.45.20260921
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/runtime-host-service-manager.js +1 -6
- package/dist/runtime-host-update-command.js +39 -27
- package/native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe +0 -0
- package/node_modules/@maka/core/dist/artifacts.js +1 -0
- package/node_modules/@maka/core/dist/model-metadata.js +1 -2
- package/node_modules/@maka/core/dist/provider-auth.js +1 -7
- package/node_modules/@maka/core/dist/provider-registry.js +13 -7
- package/node_modules/@maka/core/dist/recall.js +5 -1
- package/node_modules/@maka/core/dist/session-start-mode.js +2 -5
- package/node_modules/@maka/core/dist/session.js +0 -1
- package/node_modules/@maka/core/dist/settings.js +17 -0
- package/node_modules/@maka/core/dist/tool-quiet-preview.js +2 -16
- package/node_modules/@maka/core/dist/transcript-search.js +46 -0
- package/node_modules/@maka/core/package.json +1 -4
- package/node_modules/@maka/runtime/dist/model-factory.js +0 -16
- package/node_modules/@maka/runtime/dist/model-fetcher.js +1 -4
- package/node_modules/@maka/runtime/dist/model-runtime.js +0 -8
- package/node_modules/@maka/runtime/dist/recall-tools.js +4 -0
- package/node_modules/@maka/runtime/dist/session-manager.js +4 -13
- package/node_modules/@maka/runtime/dist/subagent-tools.js +69 -11
- package/node_modules/@maka/runtime/dist/test-connection.js +1 -48
- package/node_modules/@maka/runtime/package.json +0 -1
- package/node_modules/@maka/runtime-host/dist/client/host-handoff-copy.js +25 -18
- package/node_modules/@maka/runtime-host/dist/client/host-handoff.js +3 -1
- package/node_modules/@maka/runtime-host/dist/protocol/connection-effects.js +0 -108
- package/node_modules/@maka/runtime-host/dist/protocol/index.js +6 -1
- package/node_modules/@maka/runtime-host/dist/protocol/operations.js +4 -5
- package/node_modules/@maka/runtime-host/dist/protocol/recall.js +270 -0
- package/node_modules/@maka/runtime-host/dist/protocol/session-continuity.js +1 -7
- package/node_modules/@maka/runtime-host/dist/protocol/usage-screen.js +12 -4
- package/node_modules/@maka/runtime-host/dist/server/access-credential-store.js +4 -0
- package/node_modules/@maka/runtime-host/dist/server/connection-effect-coordinator.js +1 -57
- package/node_modules/@maka/runtime-host/dist/server/execution-composition.js +11 -33
- package/node_modules/@maka/runtime-host/dist/server/interactive-run-composer.js +3 -20
- package/node_modules/@maka/runtime-host/dist/server/recall-coordinator.js +117 -0
- package/node_modules/@maka/runtime-host/dist/server/session-revision-coordinator.js +0 -4
- package/node_modules/@maka/storage/dist/artifact-metadata-codec.js +4 -3
- package/node_modules/@maka/storage/dist/artifact-store.js +1 -11
- package/node_modules/@maka/storage/dist/process-lifetime-file-update-lock.js +0 -41
- package/node_modules/@maka/storage/dist/runtime-policy/coordinator.js +0 -28
- package/node_modules/@maka/storage/dist/runtime-policy-stores.js +0 -2
- package/node_modules/@maka/storage/dist/sqlite-usage-schema.js +29 -5
- package/node_modules/@maka/storage/dist/sqlite-workflow-schema.js +2 -0
- package/node_modules/@maka/storage/dist/storage-writer-composition.js +0 -3
- package/node_modules/@maka/storage/dist/telemetry-file-schema.js +5 -3
- package/node_modules/@maka/storage/dist/usage-screen.js +5 -6
- package/node_modules/@maka/storage/dist/usage-stores.js +14 -4
- package/node_modules/@maka/storage/package.json +0 -2
- package/package.json +1 -1
- package/node_modules/@maka/core/dist/connection-usage.js +0 -27
- package/node_modules/@maka/core/dist/deep-research-client-progress.js +0 -132
- package/node_modules/@maka/core/dist/deep-research-run.js +0 -625
- package/node_modules/@maka/core/dist/deep-research.js +0 -208
- package/node_modules/@maka/core/dist/thread-search.js +0 -498
- package/node_modules/@maka/runtime/dist/commandcode-cli-language-model.js +0 -837
- package/node_modules/@maka/runtime/dist/connection-usage.js +0 -232
- package/node_modules/@maka/runtime/dist/deep-research-tools.js +0 -732
- package/node_modules/@maka/runtime-host/dist/protocol/deep-research.js +0 -233
- package/node_modules/@maka/runtime-host/dist/server/deep-research-coordinator.js +0 -116
- package/node_modules/@maka/storage/dist/deep-research-authority.js +0 -129
- package/node_modules/@maka/storage/dist/deep-research-store.js +0 -343
|
@@ -1,498 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Licensed to the Apache Software Foundation (ASF) under one
|
|
3
|
-
* or more contributor license agreements. See the NOTICE file
|
|
4
|
-
* distributed with this work for additional information
|
|
5
|
-
* regarding copyright ownership. The ASF licenses this file
|
|
6
|
-
* to you under the Apache License, Version 2.0 (the
|
|
7
|
-
* "License"); you may not use this file except in compliance
|
|
8
|
-
* with the License. You may obtain a copy of the License at
|
|
9
|
-
*
|
|
10
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
-
*
|
|
12
|
-
* Unless required by applicable law or agreed to in writing,
|
|
13
|
-
* software distributed under the License is distributed on an
|
|
14
|
-
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
15
|
-
* KIND, either express or implied. See the License for the
|
|
16
|
-
* specific language governing permissions and limitations
|
|
17
|
-
* under the License.
|
|
18
|
-
*/
|
|
19
|
-
/**
|
|
20
|
-
* Local thread / session search — bounded scan, no FTS5.
|
|
21
|
-
*
|
|
22
|
-
* Anchors:
|
|
23
|
-
* - Current behavior is pinned by the focused thread-search tests.
|
|
24
|
-
* - Contract: `@maka/core/search` (PR-SEARCH-0 + PR-SEARCH-1.5 `SearchResultTarget`).
|
|
25
|
-
* - Implementation lane greenlight: xuan msg `074714c7`.
|
|
26
|
-
*
|
|
27
|
-
* Scope (this module, PR-SEARCH-2):
|
|
28
|
-
* - Pure helper. Accepts an injected `ThreadSearchDeps` so unit tests can
|
|
29
|
-
* supply fake `listSessions` / `readMessages` without an Electron runtime.
|
|
30
|
-
* - Bounded substring scan over user-visible message types only:
|
|
31
|
-
* UserMessage / AssistantMessage / ToolCallMessage / ToolResultMessage.
|
|
32
|
-
* Excluded: SystemNoteMessage / TokenUsageMessage / TurnStateMessage /
|
|
33
|
-
* PermissionDecisionMessage.
|
|
34
|
-
* - Excludes sessions with `backend === 'fake'` (retired local simulation,
|
|
35
|
-
* plus the e2e fixtures that still seed it). Desktop also excludes archived
|
|
36
|
-
* sessions; Agent global history opts in to them explicitly.
|
|
37
|
-
* - Snippets are redacted via `@maka/core/redaction.redactSecrets()`.
|
|
38
|
-
* - `ToolResultMessage.content` is JSON-serialized for scan and capped to
|
|
39
|
-
* the first `TOOL_RESULT_SCAN_CAP_BYTES` bytes (worst-case bound).
|
|
40
|
-
* - Result limits come from `@maka/core/search.normalizeSearchLimit`
|
|
41
|
-
* (default 5, max `SEARCH_MAX_LIMIT=10`).
|
|
42
|
-
* - Total payload bytes (sum of snippets) capped at `TOTAL_PAYLOAD_CAP_BYTES`.
|
|
43
|
-
* - Per-result snippet capped at `SNIPPET_MAX_CODE_POINTS`.
|
|
44
|
-
* - Returns a success envelope containing `SearchResult[]` plus an explicit
|
|
45
|
-
* scan-truncation bit, with each result following the PR-SEARCH-0 shape and
|
|
46
|
-
* `source: 'thread'` and `target: { kind:'thread', sessionId, turnId? }`
|
|
47
|
-
* per PR-SEARCH-1.5, extended with stable message id, match kind, and
|
|
48
|
-
* timestamp anchors for Agent global search. `url` is left undefined
|
|
49
|
-
* (thread navigation does NOT use `maka://session`).
|
|
50
|
-
*
|
|
51
|
-
* Hard no-go (enforced by source gate at review):
|
|
52
|
-
* - No `fetch` / `XMLHttpRequest` / `new WebSocket` / `BrowserWindow`.
|
|
53
|
-
* - No `electron` imports — runs in main but stays Electron-agnostic via DI.
|
|
54
|
-
* - No FTS5 / SQLite / better-sqlite3.
|
|
55
|
-
* - No telemetry emission of query body.
|
|
56
|
-
* - No `maka://session` URI construction.
|
|
57
|
-
*/
|
|
58
|
-
import { validateWorkspacePrivacyContext } from './incognito.js';
|
|
59
|
-
import { redactSecrets } from './redaction.js';
|
|
60
|
-
import { normalizeSearchLimit, normalizeSearchQuery } from './search.js';
|
|
61
|
-
import { collapseSessionRevisions } from './session-revisions.js';
|
|
62
|
-
/** Max scan bytes per ToolResultMessage.content (JSON-serialized). */
|
|
63
|
-
export const TOOL_RESULT_SCAN_CAP_BYTES = 10_240;
|
|
64
|
-
/** Max code points retained in a result snippet. */
|
|
65
|
-
export const SNIPPET_MAX_CODE_POINTS = 240;
|
|
66
|
-
/** Half-window of snippet context characters on each side of the match. */
|
|
67
|
-
export const SNIPPET_CONTEXT_HALF = 80;
|
|
68
|
-
/** Cap on total snippet bytes (UTF-8) summed across all results. */
|
|
69
|
-
export const TOTAL_PAYLOAD_CAP_BYTES = 64 * 1024;
|
|
70
|
-
/** Max sessions scanned per query (newest first by lastMessageAt). */
|
|
71
|
-
export const MAX_SESSIONS_SCANNED = 200;
|
|
72
|
-
/** Max encoded bytes accepted for an opaque thread-search continuation. */
|
|
73
|
-
export const THREAD_SEARCH_CURSOR_MAX_CHARS = 2_048;
|
|
74
|
-
/** Returned source kind — locked to `'thread'` in v1. */
|
|
75
|
-
export const THREAD_SOURCE = 'thread';
|
|
76
|
-
/**
|
|
77
|
-
* Shared API surface. Desktop IPC and Runtime Host Agent tools wrap this
|
|
78
|
-
* helper with their own authority-owned dependencies.
|
|
79
|
-
*
|
|
80
|
-
* Accepts `unknown` because the IPC payload crosses a process boundary —
|
|
81
|
-
* TypeScript's `SearchRequest` annotation in the handler is compile-time
|
|
82
|
-
* only. A renderer can send anything; malformed input must fail closed
|
|
83
|
-
* with an error envelope. Dependency adapters project ordinary I/O
|
|
84
|
-
* failures before calling this function. Same defense pattern as PR-MEMORY-1
|
|
85
|
-
* `validateMemoryWriteRequest` and PR-UI-IPC-1 baseUrl normalize
|
|
86
|
-
* (@xuan msg `2f1aba55` fixup).
|
|
87
|
-
*/
|
|
88
|
-
export async function runThreadSearch(request, deps, options = {}) {
|
|
89
|
-
if (options.abortSignal?.aborted)
|
|
90
|
-
return abortedSearch();
|
|
91
|
-
// L1: runtime shape guard. Renderer payload is untrusted across the
|
|
92
|
-
// IPC boundary. Null / non-object / missing fields → typed reject.
|
|
93
|
-
if (typeof request !== 'object' || request === null || Array.isArray(request)) {
|
|
94
|
-
return { ok: false, reason: 'invalid_query', message: 'search request must be an object' };
|
|
95
|
-
}
|
|
96
|
-
const record = request;
|
|
97
|
-
// L2: source enum gate — this module only handles `'thread'`. The
|
|
98
|
-
// shape check above already rejected non-objects, so reading
|
|
99
|
-
// `record.source` is safe.
|
|
100
|
-
if (record.source !== THREAD_SOURCE) {
|
|
101
|
-
return { ok: false, reason: 'disabled', message: 'thread search only handles source=thread' };
|
|
102
|
-
}
|
|
103
|
-
// L3: query / limit normalization via @maka/core helpers — single
|
|
104
|
-
// chokepoint, never bypass. Both already guard typeof + finite.
|
|
105
|
-
const queryResult = normalizeSearchQuery(record.query);
|
|
106
|
-
if (!queryResult.ok) {
|
|
107
|
-
return queryResult;
|
|
108
|
-
}
|
|
109
|
-
const limitResult = normalizeSearchLimit(record.limit);
|
|
110
|
-
if (!limitResult.ok) {
|
|
111
|
-
return limitResult;
|
|
112
|
-
}
|
|
113
|
-
// Matching a secret-shaped query against raw history would expose a
|
|
114
|
-
// hit/no-hit membership oracle even if the returned snippet were redacted.
|
|
115
|
-
// Reject such queries before touching the history authority, and match every
|
|
116
|
-
// searchable field only after applying the same redaction projection.
|
|
117
|
-
const redactedQuery = redactSecrets(queryResult.value);
|
|
118
|
-
if (redactedQuery !== queryResult.value) {
|
|
119
|
-
return {
|
|
120
|
-
ok: false,
|
|
121
|
-
reason: 'invalid_query',
|
|
122
|
-
message: 'Search query contains credential material and cannot be searched.',
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
const queryFolded = foldForMatch(redactedQuery);
|
|
126
|
-
const cursorResult = decodeThreadSearchCursor(record.cursor, queryFolded);
|
|
127
|
-
if (!cursorResult.ok)
|
|
128
|
-
return cursorResult;
|
|
129
|
-
// L4: privacy gate (PR-SEARCH-2.5 @xuan `2c55b975`). Host-owned
|
|
130
|
-
// privacy authority. Two early-return paths share the same
|
|
131
|
-
// `reason:'incognito_active'` to avoid an extra UI state:
|
|
132
|
-
// - active incognito (user toggled on): `incognitoActive === true`
|
|
133
|
-
// - malformed authority payload (system fail-closed): validator
|
|
134
|
-
// reject treated as if incognito were active
|
|
135
|
-
// Both paths MUST NOT touch `listSessions` / `readMessages`.
|
|
136
|
-
// Distinguishing message wording is kept for diagnostics; consumers
|
|
137
|
-
// can read `message` if they need to differentiate.
|
|
138
|
-
const privacyPayload = await deps.getPrivacyContext();
|
|
139
|
-
if (options.abortSignal?.aborted)
|
|
140
|
-
return abortedSearch();
|
|
141
|
-
const privacyResult = validateWorkspacePrivacyContext(privacyPayload);
|
|
142
|
-
if (!privacyResult.ok) {
|
|
143
|
-
return {
|
|
144
|
-
ok: false,
|
|
145
|
-
reason: 'incognito_active',
|
|
146
|
-
message: 'Search is disabled because workspace privacy state could not be verified.',
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
if (privacyResult.value.incognitoActive) {
|
|
150
|
-
return {
|
|
151
|
-
ok: false,
|
|
152
|
-
reason: 'incognito_active',
|
|
153
|
-
message: 'Search is disabled while incognito is active.',
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
const maxResults = limitResult.value;
|
|
157
|
-
const eligibleSessions = collapseSessionRevisions(await deps.listSessions(), options.activeSessionId)
|
|
158
|
-
// Exclude fake-backend sessions. The rail still shows them (marked stale)
|
|
159
|
-
// because they are task records, but their transcripts are simulator output;
|
|
160
|
-
// returning fabricated text as a hit on the user's own history is worse than
|
|
161
|
-
// returning nothing. Retiring the backend (#3211) did not make that content
|
|
162
|
-
// real, so the filter stays.
|
|
163
|
-
.filter((session) => session.backend !== 'fake' &&
|
|
164
|
-
(options.includeArchived === true || !session.isArchived) &&
|
|
165
|
-
!options.excludeSessionIds?.has(session.id))
|
|
166
|
-
// Newest first by lastMessageAt; secondary by id for determinism.
|
|
167
|
-
.sort((a, b) => {
|
|
168
|
-
const ts = (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0);
|
|
169
|
-
if (ts !== 0)
|
|
170
|
-
return ts;
|
|
171
|
-
return a.id.localeCompare(b.id);
|
|
172
|
-
});
|
|
173
|
-
if (options.abortSignal?.aborted)
|
|
174
|
-
return abortedSearch();
|
|
175
|
-
const remainingSessions = cursorResult.value
|
|
176
|
-
? eligibleSessions.filter((session) => sessionIsAfterCursor(session, cursorResult.value))
|
|
177
|
-
: eligibleSessions;
|
|
178
|
-
const sessions = remainingSessions.slice(0, MAX_SESSIONS_SCANNED);
|
|
179
|
-
const hasMoreSessions = remainingSessions.length > sessions.length;
|
|
180
|
-
const results = [];
|
|
181
|
-
let totalBytes = 0;
|
|
182
|
-
let truncated = hasMoreSessions;
|
|
183
|
-
let scannedCompletePage = true;
|
|
184
|
-
sessionScan: for (const session of sessions) {
|
|
185
|
-
if (options.abortSignal?.aborted)
|
|
186
|
-
return abortedSearch();
|
|
187
|
-
if (results.length >= maxResults) {
|
|
188
|
-
truncated = true;
|
|
189
|
-
scannedCompletePage = false;
|
|
190
|
-
break;
|
|
191
|
-
}
|
|
192
|
-
const searchableTitle = redactSecrets(session.name);
|
|
193
|
-
const titleHit = findMatch(searchableTitle, queryFolded);
|
|
194
|
-
if (titleHit !== undefined) {
|
|
195
|
-
const snippet = capCodePoints(buildSnippet(searchableTitle, titleHit, SNIPPET_CONTEXT_HALF), SNIPPET_MAX_CODE_POINTS);
|
|
196
|
-
const snippetBytes = Buffer.byteLength(snippet, 'utf8');
|
|
197
|
-
if (totalBytes + snippetBytes > TOTAL_PAYLOAD_CAP_BYTES) {
|
|
198
|
-
truncated = true;
|
|
199
|
-
scannedCompletePage = false;
|
|
200
|
-
break;
|
|
201
|
-
}
|
|
202
|
-
totalBytes += snippetBytes;
|
|
203
|
-
results.push({
|
|
204
|
-
source: THREAD_SOURCE,
|
|
205
|
-
title: searchableTitle,
|
|
206
|
-
summary: '任务标题',
|
|
207
|
-
snippet,
|
|
208
|
-
target: {
|
|
209
|
-
kind: 'thread',
|
|
210
|
-
sessionId: session.id,
|
|
211
|
-
matchKind: 'session_title',
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
if (results.length >= maxResults) {
|
|
215
|
-
truncated = true;
|
|
216
|
-
scannedCompletePage = false;
|
|
217
|
-
break;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
const messages = await deps.readMessages(session.id, options.abortSignal);
|
|
221
|
-
if (options.abortSignal?.aborted)
|
|
222
|
-
return abortedSearch();
|
|
223
|
-
if (!messages)
|
|
224
|
-
continue;
|
|
225
|
-
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
|
|
226
|
-
if (messageIndex > 0 && messageIndex % 256 === 0) {
|
|
227
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
228
|
-
}
|
|
229
|
-
if (options.abortSignal?.aborted)
|
|
230
|
-
return abortedSearch();
|
|
231
|
-
const message = messages[messageIndex];
|
|
232
|
-
if (results.length >= maxResults) {
|
|
233
|
-
truncated = true;
|
|
234
|
-
scannedCompletePage = false;
|
|
235
|
-
break sessionScan;
|
|
236
|
-
}
|
|
237
|
-
const turnId = message.turnId;
|
|
238
|
-
if (session.id === options.activeSessionId && turnId && options.excludeTurnIds?.has(turnId)) {
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
const rawCandidate = collectSearchableText(message);
|
|
242
|
-
if (rawCandidate === undefined)
|
|
243
|
-
continue;
|
|
244
|
-
const candidate = redactSecrets(rawCandidate);
|
|
245
|
-
const hit = findMatch(candidate, queryFolded);
|
|
246
|
-
if (hit === undefined)
|
|
247
|
-
continue;
|
|
248
|
-
// Build the snippet, redact secrets, cap length.
|
|
249
|
-
const snippet = capCodePoints(redactSecrets(buildSnippet(candidate, hit, SNIPPET_CONTEXT_HALF)), SNIPPET_MAX_CODE_POINTS);
|
|
250
|
-
const snippetBytes = Buffer.byteLength(snippet, 'utf8');
|
|
251
|
-
if (totalBytes + snippetBytes > TOTAL_PAYLOAD_CAP_BYTES) {
|
|
252
|
-
truncated = true;
|
|
253
|
-
scannedCompletePage = false;
|
|
254
|
-
break sessionScan;
|
|
255
|
-
}
|
|
256
|
-
totalBytes += snippetBytes;
|
|
257
|
-
results.push({
|
|
258
|
-
source: THREAD_SOURCE,
|
|
259
|
-
title: redactSecrets(session.name),
|
|
260
|
-
summary: formatSearchResultSummary(message),
|
|
261
|
-
snippet,
|
|
262
|
-
// PR-SEARCH-1.5: navigation target via discriminated union; no
|
|
263
|
-
// `url` field for thread results (maka://session is deferred).
|
|
264
|
-
target: {
|
|
265
|
-
kind: 'thread',
|
|
266
|
-
sessionId: session.id,
|
|
267
|
-
...(turnId ? { turnId } : {}),
|
|
268
|
-
sequence: messageIndex,
|
|
269
|
-
messageId: message.id,
|
|
270
|
-
matchKind: threadSearchMatchKind(message),
|
|
271
|
-
messageTimestamp: message.ts,
|
|
272
|
-
},
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
if (truncated && results.length > 0) {
|
|
277
|
-
results[results.length - 1] = { ...results[results.length - 1], truncated: true };
|
|
278
|
-
}
|
|
279
|
-
const lastSession = sessions.at(-1);
|
|
280
|
-
const nextCursor = hasMoreSessions && scannedCompletePage && lastSession
|
|
281
|
-
? encodeThreadSearchCursor({
|
|
282
|
-
version: 1,
|
|
283
|
-
query: queryFolded,
|
|
284
|
-
lastMessageAt: sessionSortTime(lastSession),
|
|
285
|
-
sessionId: lastSession.id,
|
|
286
|
-
})
|
|
287
|
-
: undefined;
|
|
288
|
-
return { ok: true, results, truncated, ...(nextCursor ? { nextCursor } : {}) };
|
|
289
|
-
}
|
|
290
|
-
function abortedSearch() {
|
|
291
|
-
return { ok: false, reason: 'aborted', message: 'History search was aborted.' };
|
|
292
|
-
}
|
|
293
|
-
function decodeThreadSearchCursor(input, query) {
|
|
294
|
-
if (input === undefined)
|
|
295
|
-
return { ok: true, value: undefined };
|
|
296
|
-
if (typeof input !== 'string' ||
|
|
297
|
-
input.length === 0 ||
|
|
298
|
-
input.length > THREAD_SEARCH_CURSOR_MAX_CHARS ||
|
|
299
|
-
input.trim() !== input) {
|
|
300
|
-
return invalidThreadSearchCursor();
|
|
301
|
-
}
|
|
302
|
-
try {
|
|
303
|
-
const decoded = Buffer.from(input, 'base64url').toString('utf8');
|
|
304
|
-
if (Buffer.from(decoded, 'utf8').toString('base64url') !== input) {
|
|
305
|
-
return invalidThreadSearchCursor();
|
|
306
|
-
}
|
|
307
|
-
const value = JSON.parse(decoded);
|
|
308
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
309
|
-
return invalidThreadSearchCursor();
|
|
310
|
-
}
|
|
311
|
-
const cursor = value;
|
|
312
|
-
if (cursor.version !== 1 ||
|
|
313
|
-
cursor.query !== query ||
|
|
314
|
-
typeof cursor.lastMessageAt !== 'number' ||
|
|
315
|
-
!Number.isFinite(cursor.lastMessageAt) ||
|
|
316
|
-
typeof cursor.sessionId !== 'string' ||
|
|
317
|
-
cursor.sessionId.length === 0 ||
|
|
318
|
-
cursor.sessionId.length > 256) {
|
|
319
|
-
return invalidThreadSearchCursor();
|
|
320
|
-
}
|
|
321
|
-
return {
|
|
322
|
-
ok: true,
|
|
323
|
-
value: {
|
|
324
|
-
version: 1,
|
|
325
|
-
query,
|
|
326
|
-
lastMessageAt: cursor.lastMessageAt,
|
|
327
|
-
sessionId: cursor.sessionId,
|
|
328
|
-
},
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
return invalidThreadSearchCursor();
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
function invalidThreadSearchCursor() {
|
|
336
|
-
return {
|
|
337
|
-
ok: false,
|
|
338
|
-
reason: 'invalid_query',
|
|
339
|
-
message: 'Search cursor is invalid or belongs to another query.',
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
function encodeThreadSearchCursor(cursor) {
|
|
343
|
-
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
|
|
344
|
-
}
|
|
345
|
-
function sessionSortTime(session) {
|
|
346
|
-
return session.lastMessageAt ?? 0;
|
|
347
|
-
}
|
|
348
|
-
function sessionIsAfterCursor(session, cursor) {
|
|
349
|
-
const timestamp = sessionSortTime(session);
|
|
350
|
-
return (timestamp < cursor.lastMessageAt ||
|
|
351
|
-
(timestamp === cursor.lastMessageAt && session.id.localeCompare(cursor.sessionId) > 0));
|
|
352
|
-
}
|
|
353
|
-
/** Stable result classification shared by Desktop navigation and Agent tools. */
|
|
354
|
-
export function threadSearchMatchKind(message) {
|
|
355
|
-
switch (message.type) {
|
|
356
|
-
case 'user':
|
|
357
|
-
return 'user_message';
|
|
358
|
-
case 'assistant':
|
|
359
|
-
return 'assistant_message';
|
|
360
|
-
case 'tool_call':
|
|
361
|
-
return 'tool_intent';
|
|
362
|
-
case 'tool_result':
|
|
363
|
-
return 'tool_result';
|
|
364
|
-
case 'permission_decision':
|
|
365
|
-
case 'token_usage':
|
|
366
|
-
case 'turn_state':
|
|
367
|
-
case 'workhub_coordination':
|
|
368
|
-
case 'system_note':
|
|
369
|
-
throw new Error(`Message type ${message.type} is not searchable`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
export function formatSearchResultSummary(message) {
|
|
373
|
-
switch (message.type) {
|
|
374
|
-
case 'user':
|
|
375
|
-
return '用户消息';
|
|
376
|
-
case 'assistant':
|
|
377
|
-
return '助手回复';
|
|
378
|
-
case 'tool_call':
|
|
379
|
-
return message.displayName
|
|
380
|
-
? `工具调用:${message.displayName}`
|
|
381
|
-
: `工具调用:${message.toolName}`;
|
|
382
|
-
case 'tool_result':
|
|
383
|
-
return message.isError ? '工具结果:失败' : '工具结果:成功';
|
|
384
|
-
case 'permission_decision':
|
|
385
|
-
return '权限记录';
|
|
386
|
-
case 'token_usage':
|
|
387
|
-
return '用量记录';
|
|
388
|
-
case 'turn_state':
|
|
389
|
-
return '回合状态';
|
|
390
|
-
case 'workhub_coordination':
|
|
391
|
-
return 'WorkHub 协调记录';
|
|
392
|
-
case 'system_note':
|
|
393
|
-
return '系统记录';
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
/**
|
|
397
|
-
* Extract user-visible answer text from a stored message. Returns `undefined`
|
|
398
|
-
* for excluded message kinds (system notes, token usage, turn state,
|
|
399
|
-
* permission decisions). This is the only "what counts as searchable
|
|
400
|
-
* transcript content" gate; adding new searchable surfaces requires
|
|
401
|
-
* extending this switch + a corresponding test.
|
|
402
|
-
*
|
|
403
|
-
* For ToolResultMessage, the `content` is JSON-serialized and capped
|
|
404
|
-
* at `TOOL_RESULT_SCAN_CAP_BYTES` so a 100 MB tool result doesn't
|
|
405
|
-
* inflate scan time.
|
|
406
|
-
*/
|
|
407
|
-
export function collectSearchableText(message) {
|
|
408
|
-
switch (message.type) {
|
|
409
|
-
case 'user':
|
|
410
|
-
// Prefer the human-facing view so skill-invocation envelopes do not
|
|
411
|
-
// dominate local search hits for what the user actually typed.
|
|
412
|
-
return message.displayText ?? message.text;
|
|
413
|
-
case 'assistant':
|
|
414
|
-
// Search result snippets are a transcript surface. Assistant
|
|
415
|
-
// reasoning/thinking may be rendered separately in the live chat,
|
|
416
|
-
// but it is not answer text and must not leak into local search.
|
|
417
|
-
return message.text;
|
|
418
|
-
case 'tool_call':
|
|
419
|
-
// PR-SEARCH-2 review fixup (@xuan `2f1aba55`): index ONLY
|
|
420
|
-
// `intent` — the user-visible description of what the tool call
|
|
421
|
-
// is doing. `toolName` (e.g. `Bash`) and `displayName` are
|
|
422
|
-
// internal labels and would let searches for `Bash` match every
|
|
423
|
-
// bash invocation regardless of intent. The PR-SEARCH-1 plan
|
|
424
|
-
// already locked `intent` as the only searchable field on
|
|
425
|
-
// `ToolCallMessage`; the previous draft over-indexed by mistake.
|
|
426
|
-
return message.intent && message.intent.length > 0 ? message.intent : undefined;
|
|
427
|
-
case 'tool_result': {
|
|
428
|
-
// Bounded JSON-serialize. The cap protects against pathological
|
|
429
|
-
// multi-MB tool outputs (file dumps, etc.).
|
|
430
|
-
let serialized;
|
|
431
|
-
try {
|
|
432
|
-
serialized = JSON.stringify(message.content);
|
|
433
|
-
}
|
|
434
|
-
catch {
|
|
435
|
-
return undefined;
|
|
436
|
-
}
|
|
437
|
-
if (Buffer.byteLength(serialized, 'utf8') > TOOL_RESULT_SCAN_CAP_BYTES) {
|
|
438
|
-
// Truncate to the cap. Use byte-safe slice via Buffer.
|
|
439
|
-
const buf = Buffer.from(serialized, 'utf8').subarray(0, TOOL_RESULT_SCAN_CAP_BYTES);
|
|
440
|
-
return buf.toString('utf8');
|
|
441
|
-
}
|
|
442
|
-
return serialized;
|
|
443
|
-
}
|
|
444
|
-
case 'permission_decision':
|
|
445
|
-
case 'token_usage':
|
|
446
|
-
case 'turn_state':
|
|
447
|
-
case 'workhub_coordination':
|
|
448
|
-
case 'system_note':
|
|
449
|
-
// Coordination records are rendered by WorkHub, but the reserved
|
|
450
|
-
// Coordination Session is intentionally outside general thread search.
|
|
451
|
-
// The remaining cases are not user-typed / not user-visible content.
|
|
452
|
-
return undefined;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
/**
|
|
456
|
-
* NFC + lowercase canonicalization for substring match. NOT a security
|
|
457
|
-
* boundary — purely for case-insensitive + composed-form matching.
|
|
458
|
-
*
|
|
459
|
-
* Public for tests; production callers use `runThreadSearch` only.
|
|
460
|
-
*/
|
|
461
|
-
export function foldForMatch(value) {
|
|
462
|
-
return value.normalize('NFC').toLowerCase();
|
|
463
|
-
}
|
|
464
|
-
/**
|
|
465
|
-
* Find the index of the first occurrence of `queryFolded` in `text`
|
|
466
|
-
* (after the same fold operation). Returns the index in the original
|
|
467
|
-
* (unfolded) text — JS `String.prototype.toLowerCase()` preserves
|
|
468
|
-
* code-point indexing for ASCII and most CJK, which is what we need
|
|
469
|
-
* for snippet extraction. Returns `undefined` on no match.
|
|
470
|
-
*/
|
|
471
|
-
export function findMatch(text, queryFolded) {
|
|
472
|
-
const folded = foldForMatch(text);
|
|
473
|
-
const idx = folded.indexOf(queryFolded);
|
|
474
|
-
return idx >= 0 ? idx : undefined;
|
|
475
|
-
}
|
|
476
|
-
/**
|
|
477
|
-
* Extract a context window around the match. Pure substring + ellipsis
|
|
478
|
-
* marker; no HTML, no markup. Caller is responsible for redaction +
|
|
479
|
-
* length cap afterward.
|
|
480
|
-
*/
|
|
481
|
-
export function buildSnippet(text, matchIndex, halfWindow) {
|
|
482
|
-
const start = Math.max(0, matchIndex - halfWindow);
|
|
483
|
-
const end = Math.min(text.length, matchIndex + halfWindow);
|
|
484
|
-
const prefix = start > 0 ? '…' : '';
|
|
485
|
-
const suffix = end < text.length ? '…' : '';
|
|
486
|
-
return prefix + text.slice(start, end).replace(/\s+/g, ' ').trim() + suffix;
|
|
487
|
-
}
|
|
488
|
-
/**
|
|
489
|
-
* Cap a string to at most `maxCodePoints` code points. Uses
|
|
490
|
-
* `Array.from` so surrogate pairs (emoji) are not split. Appends
|
|
491
|
-
* an ellipsis when truncated.
|
|
492
|
-
*/
|
|
493
|
-
export function capCodePoints(value, maxCodePoints) {
|
|
494
|
-
const codePoints = Array.from(value);
|
|
495
|
-
if (codePoints.length <= maxCodePoints)
|
|
496
|
-
return value;
|
|
497
|
-
return codePoints.slice(0, maxCodePoints - 1).join('') + '…';
|
|
498
|
-
}
|