tinker-agent 1.3.0 → 1.5.0

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 (49) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +271 -72
  3. package/bin/tinker.js +75 -25
  4. package/package.json +12 -3
  5. package/src/agent/runtime-session.ts +113 -15
  6. package/src/cli/command-line.ts +291 -0
  7. package/src/cli/config.ts +158 -262
  8. package/src/cli/index.ts +33 -21
  9. package/src/cli/main.ts +213 -0
  10. package/src/cli/model-profiles.ts +226 -72
  11. package/src/cli/output.ts +113 -0
  12. package/src/cli/package-metadata.ts +36 -0
  13. package/src/cli/prompt-source.ts +229 -0
  14. package/src/cli/public-cli-contract.ts +69 -0
  15. package/src/cli/public-config-contract.ts +732 -0
  16. package/src/cli/run-runner.ts +17 -12
  17. package/src/cli/runner-dependencies.ts +108 -0
  18. package/src/cli/tui-memory.ts +67 -0
  19. package/src/cli/tui-runner.tsx +79 -49
  20. package/src/context/context-policy.ts +2 -2
  21. package/src/events/stdout-event-printer.ts +1 -0
  22. package/src/mcp/mcp-manager.ts +2 -19
  23. package/src/mcp/mcp-tool-executor.ts +3 -4
  24. package/src/memory/contracts.ts +148 -0
  25. package/src/memory/embedding-client.ts +105 -0
  26. package/src/memory/memory-coordinator.ts +556 -0
  27. package/src/memory/memory-extractor.ts +231 -0
  28. package/src/memory/memory-log.ts +88 -0
  29. package/src/memory/memory-search-tool.ts +100 -0
  30. package/src/memory/memory-store.ts +687 -0
  31. package/src/memory/vector.ts +153 -0
  32. package/src/model/fake-model-client.ts +971 -3
  33. package/src/model/model-context-profile.ts +0 -30
  34. package/src/observation/observation-builder.ts +20 -0
  35. package/src/session/session-store.ts +123 -0
  36. package/src/tools/bash.ts +8 -25
  37. package/src/tools/grep.ts +9 -1
  38. package/src/tools/registry.ts +19 -1
  39. package/src/tools/ripgrep.ts +24 -27
  40. package/src/tools/types.ts +16 -0
  41. package/src/tools/web-fetch/index.ts +2 -15
  42. package/src/tui/app.tsx +72 -2
  43. package/src/tui/clipboard.ts +22 -0
  44. package/src/tui/components/footer.tsx +9 -4
  45. package/src/tui/components/memory-browser.tsx +151 -0
  46. package/src/tui/components/prompt-input.tsx +6 -3
  47. package/src/tui/event-store.ts +9 -2
  48. package/src/tui/slash-commands.ts +88 -24
  49. package/src/tui/workspace-file-search.ts +78 -71
@@ -0,0 +1,105 @@
1
+ import OpenAI from "openai";
2
+ import type { MemoryEmbeddingConfig } from "./contracts";
3
+ import { MemoryError } from "./contracts";
4
+
5
+ const EMBEDDING_TIMEOUT_MS = 60_000;
6
+ const EMBEDDING_MAX_RETRIES = 2;
7
+
8
+ export interface MemoryEmbeddingClient {
9
+ embed(
10
+ inputs: readonly string[],
11
+ signal: AbortSignal,
12
+ ): Promise<readonly (readonly number[])[]>;
13
+ }
14
+
15
+ export class OpenAICompatibleEmbeddingClient implements MemoryEmbeddingClient {
16
+ private readonly client: OpenAI;
17
+
18
+ constructor(
19
+ private readonly config: MemoryEmbeddingConfig,
20
+ options: { readonly fetch?: typeof fetch } = {},
21
+ ) {
22
+ this.client = new OpenAI({
23
+ apiKey: config.apiKey,
24
+ baseURL: config.apiBase,
25
+ timeout: EMBEDDING_TIMEOUT_MS,
26
+ maxRetries: EMBEDDING_MAX_RETRIES,
27
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
28
+ });
29
+ }
30
+
31
+ async embed(
32
+ inputs: readonly string[],
33
+ signal: AbortSignal,
34
+ ): Promise<readonly (readonly number[])[]> {
35
+ if (inputs.length === 0) {
36
+ throw new MemoryError(
37
+ "memory_embedding_input_invalid",
38
+ "Embedding input must not be empty.",
39
+ );
40
+ }
41
+ signal.throwIfAborted();
42
+
43
+ let response;
44
+ try {
45
+ response = await this.client.embeddings.create(
46
+ {
47
+ model: this.config.model,
48
+ input: [...inputs],
49
+ encoding_format: "float",
50
+ },
51
+ { signal },
52
+ );
53
+ } catch (error) {
54
+ if (signal.aborted) {
55
+ throw error;
56
+ }
57
+ throw new MemoryError(
58
+ "memory_embedding_request_failed",
59
+ "Embedding provider request failed.",
60
+ { cause: error },
61
+ );
62
+ }
63
+ signal.throwIfAborted();
64
+
65
+ if (!Array.isArray(response.data)) {
66
+ throw new MemoryError(
67
+ "memory_embedding_response_invalid",
68
+ "Embedding response did not contain a data array.",
69
+ );
70
+ }
71
+ if (response.data.length !== inputs.length) {
72
+ throw new MemoryError(
73
+ "memory_embedding_response_invalid",
74
+ `Embedding response returned ${response.data.length} vectors for ${inputs.length} inputs.`,
75
+ );
76
+ }
77
+
78
+ const vectors: Array<readonly number[] | undefined> = Array.from({
79
+ length: inputs.length,
80
+ });
81
+ for (const item of response.data) {
82
+ if (
83
+ !Number.isSafeInteger(item.index) ||
84
+ item.index < 0 ||
85
+ item.index >= inputs.length ||
86
+ vectors[item.index] !== undefined ||
87
+ !Array.isArray(item.embedding) ||
88
+ item.embedding.some((value) => typeof value !== "number")
89
+ ) {
90
+ throw new MemoryError(
91
+ "memory_embedding_response_invalid",
92
+ "Embedding response indices or vectors are invalid.",
93
+ );
94
+ }
95
+ vectors[item.index] = Object.freeze([...item.embedding]);
96
+ }
97
+ if (vectors.some((vector) => vector === undefined)) {
98
+ throw new MemoryError(
99
+ "memory_embedding_response_invalid",
100
+ "Embedding response did not map every input index.",
101
+ );
102
+ }
103
+ return Object.freeze(vectors as readonly (readonly number[])[]);
104
+ }
105
+ }
@@ -0,0 +1,556 @@
1
+ import path from "node:path";
2
+ import type {
3
+ CompletedTurnHook,
4
+ CompletedTurnHookFailure,
5
+ CompletedTurnHookInput,
6
+ } from "../agent/runtime-session";
7
+ import type { SessionId } from "../ids/runtime-id";
8
+ import type { ModelContextBudget } from "../model/model-context-profile";
9
+ import type { ModelClient } from "../model/model-client";
10
+ import type { CompletedTurnSnapshot } from "../session/session-store";
11
+ import type { MemorySearchRawResult, ToolExecutor } from "../tools/types";
12
+ import {
13
+ boundedMemoryError,
14
+ memoryErrorCode,
15
+ MEMORY_SEARCH_TOOL_NAME,
16
+ MemoryError,
17
+ type MemoryEmbeddingConfig,
18
+ type MemoryExtractionDiagnostic,
19
+ type MemoryExtractionRejectedCounts,
20
+ type MemoryPaths,
21
+ type MemorySearchDiagnostic,
22
+ type StoredMemorySummary,
23
+ } from "./contracts";
24
+ import {
25
+ OpenAICompatibleEmbeddingClient,
26
+ type MemoryEmbeddingClient,
27
+ } from "./embedding-client";
28
+ import {
29
+ MemoryExtractionOutputError,
30
+ MemoryExtractionRequestError,
31
+ MemoryExtractionSkippedError,
32
+ MemoryExtractor,
33
+ type MemoryExtractionResult,
34
+ } from "./memory-extractor";
35
+ import { ExtractedMemoryLog, MemoryLog } from "./memory-log";
36
+ import { createMemorySearchToolExecutor } from "./memory-search-tool";
37
+ import { MemoryStore, resolveMemoryPaths } from "./memory-store";
38
+ import { normalizeEmbedding } from "./vector";
39
+
40
+ type MemoryWorkerTask = {
41
+ readonly workspaceRoot: string;
42
+ readonly sessionId: SessionId;
43
+ readonly turnId: CompletedTurnHookInput["turnId"];
44
+ readonly extractionEvidenceText: string;
45
+ };
46
+
47
+ type ActiveMemoryTask = {
48
+ readonly task: MemoryWorkerTask;
49
+ readonly controller: AbortController;
50
+ completion?: Promise<void>;
51
+ };
52
+
53
+ export type CreateMemoryCoordinatorInput = {
54
+ readonly paths?: MemoryPaths;
55
+ readonly embedding: MemoryEmbeddingConfig;
56
+ readonly extractionContextBudget: ModelContextBudget;
57
+ readonly createExtractionClient: () => ModelClient;
58
+ readonly createEmbeddingClient?: () => MemoryEmbeddingClient;
59
+ readonly clock?: () => string;
60
+ };
61
+
62
+ export class MemoryCoordinator implements CompletedTurnHook {
63
+ private accepting = true;
64
+ private active?: ActiveMemoryTask;
65
+ private pending?: MemoryWorkerTask;
66
+
67
+ private constructor(
68
+ private readonly store: MemoryStore,
69
+ private readonly extractor: MemoryExtractor,
70
+ private readonly embeddingClient: MemoryEmbeddingClient,
71
+ private readonly log: MemoryLog,
72
+ private readonly extractedLog: ExtractedMemoryLog,
73
+ private readonly embeddingDimensions: number,
74
+ private readonly clock: () => string,
75
+ ) {}
76
+
77
+ static async create(input: CreateMemoryCoordinatorInput): Promise<MemoryCoordinator> {
78
+ const paths = input.paths ?? resolveMemoryPaths();
79
+ const log = new MemoryLog(paths.log);
80
+ const extractedLog = new ExtractedMemoryLog(paths.extractedLog);
81
+ const store = await MemoryStore.open({
82
+ paths,
83
+ embedding: input.embedding,
84
+ clock: input.clock,
85
+ });
86
+ try {
87
+ const extractionClient = input.createExtractionClient();
88
+ const embeddingClient =
89
+ input.createEmbeddingClient?.() ??
90
+ new OpenAICompatibleEmbeddingClient(input.embedding);
91
+ return new MemoryCoordinator(
92
+ store,
93
+ new MemoryExtractor(extractionClient, input.extractionContextBudget),
94
+ embeddingClient,
95
+ log,
96
+ extractedLog,
97
+ input.embedding.dimensions,
98
+ input.clock ?? (() => new Date().toISOString()),
99
+ );
100
+ } catch (error) {
101
+ store.close();
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ enqueue(input: CompletedTurnHookInput): void {
107
+ if (!this.accepting) {
108
+ throw new MemoryError(
109
+ "completed_turn_enqueue_failed",
110
+ "Memory coordinator is shutting down.",
111
+ );
112
+ }
113
+ const extractionEvidenceText = buildExtractionEvidenceText(
114
+ input.workspaceRoot,
115
+ input.snapshot,
116
+ );
117
+ const task = Object.freeze({
118
+ workspaceRoot: input.workspaceRoot,
119
+ sessionId: input.sessionId,
120
+ turnId: input.turnId,
121
+ extractionEvidenceText,
122
+ });
123
+ if (this.active === undefined) {
124
+ this.start(task);
125
+ return;
126
+ }
127
+ this.pending = task;
128
+ }
129
+
130
+ recordFailure(input: CompletedTurnHookFailure): void {
131
+ void this.log.append(
132
+ extractionDiagnostic({
133
+ clock: this.clock,
134
+ outcome: "failed",
135
+ reason: input.reason,
136
+ workspace: input.workspaceRoot,
137
+ turnId: input.turnId,
138
+ ms: 0,
139
+ }),
140
+ );
141
+ }
142
+
143
+ createSearchToolExecutor(input: {
144
+ readonly workspaceRoot: string;
145
+ readonly sessionId: SessionId;
146
+ }): ToolExecutor {
147
+ return createMemorySearchToolExecutor({
148
+ search: (query, signal) => this.search(query, signal, input),
149
+ recordInvalidCall: (queryBytes) => this.recordInvalidSearch(queryBytes, input),
150
+ });
151
+ }
152
+
153
+ listStoredMemories(): readonly StoredMemorySummary[] {
154
+ return this.store.listStoredMemories();
155
+ }
156
+
157
+ dispose(): void {
158
+ if (!this.accepting) {
159
+ return;
160
+ }
161
+ this.accepting = false;
162
+ this.pending = undefined;
163
+ this.active?.controller.abort(
164
+ new MemoryError(
165
+ "memory_coordinator_disposed",
166
+ "Memory coordinator is shutting down.",
167
+ ),
168
+ );
169
+ this.store.close();
170
+ }
171
+
172
+ private start(task: MemoryWorkerTask): void {
173
+ const active: ActiveMemoryTask = {
174
+ task,
175
+ controller: new AbortController(),
176
+ };
177
+ this.active = active;
178
+ active.completion = Promise.resolve()
179
+ .then(() => this.processTask(task, active.controller.signal))
180
+ .catch(() => undefined)
181
+ .finally(() => {
182
+ if (this.active !== active) {
183
+ return;
184
+ }
185
+ this.active = undefined;
186
+ const next = this.accepting ? this.pending : undefined;
187
+ this.pending = undefined;
188
+ if (next !== undefined) {
189
+ this.start(next);
190
+ }
191
+ });
192
+ }
193
+
194
+ private async processTask(
195
+ task: MemoryWorkerTask,
196
+ signal: AbortSignal,
197
+ ): Promise<void> {
198
+ const startedAt = performance.now();
199
+ let inputTokens = 0;
200
+ let returned = 0;
201
+ let rejected = emptyRejectedCounts();
202
+
203
+ let extraction: MemoryExtractionResult;
204
+ try {
205
+ extraction = await this.extractor.extract(task.extractionEvidenceText, signal);
206
+ inputTokens = extraction.inputTokens;
207
+ returned = extraction.memories.length;
208
+ } catch (error) {
209
+ if (error instanceof MemoryExtractionSkippedError) {
210
+ inputTokens = error.inputTokens;
211
+ await this.log.append(
212
+ extractionDiagnostic({
213
+ clock: this.clock,
214
+ outcome: "skipped",
215
+ reason: error.code,
216
+ workspace: task.workspaceRoot,
217
+ turnId: task.turnId,
218
+ inputTokens,
219
+ ms: elapsedMs(startedAt),
220
+ }),
221
+ );
222
+ return;
223
+ }
224
+ if (error instanceof MemoryExtractionOutputError) {
225
+ inputTokens = error.inputTokens;
226
+ returned = error.returned;
227
+ rejected = Object.freeze({ ...rejected, invalid: Math.max(1, returned) });
228
+ } else if (error instanceof MemoryExtractionRequestError) {
229
+ inputTokens = error.inputTokens;
230
+ }
231
+ await this.log.append(
232
+ extractionDiagnostic({
233
+ clock: this.clock,
234
+ outcome: signal.aborted ? "skipped" : "failed",
235
+ reason: signal.aborted
236
+ ? "extraction_cancelled"
237
+ : memoryErrorCode(error, "extraction_model_failed"),
238
+ workspace: task.workspaceRoot,
239
+ turnId: task.turnId,
240
+ inputTokens,
241
+ returned,
242
+ rejected,
243
+ ms: elapsedMs(startedAt),
244
+ }),
245
+ );
246
+ return;
247
+ }
248
+
249
+ const safeMemories = extraction.memories.filter((text) => {
250
+ if (!containsSensitiveMemory(text)) {
251
+ return true;
252
+ }
253
+ rejected = Object.freeze({
254
+ ...rejected,
255
+ secret: rejected.secret + 1,
256
+ });
257
+ return false;
258
+ });
259
+ if (safeMemories.length === 0) {
260
+ await this.log.append(
261
+ extractionDiagnostic({
262
+ clock: this.clock,
263
+ outcome: "ok",
264
+ reason: null,
265
+ workspace: task.workspaceRoot,
266
+ turnId: task.turnId,
267
+ inputTokens,
268
+ returned,
269
+ rejected,
270
+ ms: elapsedMs(startedAt),
271
+ }),
272
+ );
273
+ return;
274
+ }
275
+
276
+ let embeddings: readonly Float32Array[];
277
+ try {
278
+ const rawEmbeddings = await this.embeddingClient.embed(safeMemories, signal);
279
+ if (rawEmbeddings.length !== safeMemories.length) {
280
+ throw new MemoryError(
281
+ "memory_embedding_response_invalid",
282
+ "Embedding response count does not match the memory candidate count.",
283
+ );
284
+ }
285
+ embeddings = Object.freeze(
286
+ rawEmbeddings.map((vector) =>
287
+ normalizeEmbedding(vector, this.embeddingDimensions),
288
+ ),
289
+ );
290
+ } catch (error) {
291
+ rejected = Object.freeze({
292
+ ...rejected,
293
+ embedding: safeMemories.length,
294
+ });
295
+ await this.log.append(
296
+ extractionDiagnostic({
297
+ clock: this.clock,
298
+ outcome: signal.aborted ? "skipped" : "failed",
299
+ reason: signal.aborted
300
+ ? "extraction_cancelled"
301
+ : memoryErrorCode(error, "memory_embedding_failed"),
302
+ workspace: task.workspaceRoot,
303
+ turnId: task.turnId,
304
+ inputTokens,
305
+ returned,
306
+ rejected,
307
+ ms: elapsedMs(startedAt),
308
+ }),
309
+ );
310
+ return;
311
+ }
312
+
313
+ try {
314
+ const result = this.store.insertBatch({
315
+ workspaceRoot: task.workspaceRoot,
316
+ sessionId: task.sessionId,
317
+ turnId: task.turnId,
318
+ candidates: safeMemories.map((text, index) => ({
319
+ text,
320
+ embedding: embeddings[index],
321
+ })),
322
+ });
323
+ rejected = Object.freeze({
324
+ ...rejected,
325
+ duplicate: result.duplicate,
326
+ });
327
+ if (result.inserted.length > 0) {
328
+ await this.extractedLog.append({
329
+ at: result.inserted[0].createdAt,
330
+ workspace: task.workspaceRoot,
331
+ turnId: task.turnId,
332
+ memories: result.inserted,
333
+ });
334
+ }
335
+ await this.log.append(
336
+ extractionDiagnostic({
337
+ clock: this.clock,
338
+ outcome: "ok",
339
+ reason: null,
340
+ workspace: task.workspaceRoot,
341
+ turnId: task.turnId,
342
+ inputTokens,
343
+ returned,
344
+ written: result.written,
345
+ rejected,
346
+ ms: elapsedMs(startedAt),
347
+ }),
348
+ );
349
+ } catch (error) {
350
+ await this.log.append(
351
+ extractionDiagnostic({
352
+ clock: this.clock,
353
+ outcome: signal.aborted ? "skipped" : "failed",
354
+ reason: signal.aborted
355
+ ? "extraction_cancelled"
356
+ : memoryErrorCode(error, "memory_write_failed"),
357
+ workspace: task.workspaceRoot,
358
+ turnId: task.turnId,
359
+ inputTokens,
360
+ returned,
361
+ rejected,
362
+ ms: elapsedMs(startedAt),
363
+ }),
364
+ );
365
+ }
366
+ }
367
+
368
+ private async search(
369
+ query: string,
370
+ signal: AbortSignal,
371
+ source: { readonly workspaceRoot: string; readonly sessionId: SessionId },
372
+ ): Promise<MemorySearchRawResult> {
373
+ const startedAt = performance.now();
374
+ const queryBytes = Buffer.byteLength(query, "utf8");
375
+ try {
376
+ const raw = await this.embeddingClient.embed([query], signal);
377
+ if (raw.length !== 1 || raw[0] === undefined) {
378
+ throw new MemoryError(
379
+ "memory_embedding_response_invalid",
380
+ "Embedding response did not contain the query vector.",
381
+ );
382
+ }
383
+ const queryEmbedding = normalizeEmbedding(raw[0], this.embeddingDimensions);
384
+ const matches = this.store.search(queryEmbedding);
385
+ await this.log.append(
386
+ searchDiagnostic({
387
+ clock: this.clock,
388
+ outcome: "ok",
389
+ reason: null,
390
+ workspace: source.workspaceRoot,
391
+ sessionId: source.sessionId,
392
+ queryBytes,
393
+ returned: matches.length,
394
+ scores: matches.map((match) => roundScore(match.score)),
395
+ ms: elapsedMs(startedAt),
396
+ }),
397
+ );
398
+ return {
399
+ ok: true,
400
+ matches: Object.freeze(
401
+ matches.map((match) =>
402
+ Object.freeze({
403
+ text: match.text,
404
+ score: match.score,
405
+ sourceWorkspace: match.sourceWorkspace,
406
+ createdAt: match.createdAt,
407
+ }),
408
+ ),
409
+ ),
410
+ };
411
+ } catch (error) {
412
+ const reason = signal.aborted
413
+ ? "memory_search_cancelled"
414
+ : memoryErrorCode(error, "memory_search_failed");
415
+ await this.log.append(
416
+ searchDiagnostic({
417
+ clock: this.clock,
418
+ outcome: signal.aborted ? "skipped" : "failed",
419
+ reason,
420
+ workspace: source.workspaceRoot,
421
+ sessionId: source.sessionId,
422
+ queryBytes,
423
+ ms: elapsedMs(startedAt),
424
+ }),
425
+ );
426
+ if (signal.aborted) {
427
+ throw error;
428
+ }
429
+ return {
430
+ ok: false,
431
+ error: boundedMemoryError(error),
432
+ };
433
+ }
434
+ }
435
+
436
+ private recordInvalidSearch(
437
+ queryBytes: number,
438
+ source: { readonly workspaceRoot: string; readonly sessionId: SessionId },
439
+ ): Promise<void> {
440
+ return this.log.append(
441
+ searchDiagnostic({
442
+ clock: this.clock,
443
+ outcome: "failed",
444
+ reason: "memory_search_args_invalid",
445
+ workspace: source.workspaceRoot,
446
+ sessionId: source.sessionId,
447
+ queryBytes,
448
+ ms: 0,
449
+ }),
450
+ );
451
+ }
452
+ }
453
+
454
+ export function buildExtractionEvidenceText(
455
+ workspaceRoot: string,
456
+ snapshot: CompletedTurnSnapshot,
457
+ ): string {
458
+ if (!path.isAbsolute(workspaceRoot)) {
459
+ throw new MemoryError(
460
+ "completed_turn_enqueue_failed",
461
+ "Completed turn workspace must be absolute.",
462
+ );
463
+ }
464
+ const messages = snapshot.messages
465
+ .filter(
466
+ (message) => message.role !== "tool" || message.name !== MEMORY_SEARCH_TOOL_NAME,
467
+ )
468
+ .map((message) => ({ ...message }));
469
+ return JSON.stringify(
470
+ {
471
+ workspaceRoot,
472
+ messages,
473
+ },
474
+ null,
475
+ 2,
476
+ );
477
+ }
478
+
479
+ export function containsSensitiveMemory(text: string): boolean {
480
+ return [
481
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/i,
482
+ /\b(?:authorization\s*:\s*)?bearer\s+[a-z0-9._~+/=-]{12,}\b/i,
483
+ /\b(?:sk-[a-z0-9_-]{12,}|gh[opusr]_[a-z0-9_]{20,}|github_pat_[a-z0-9_]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})\b/,
484
+ /\b(?:cookie|session(?:_?token|id)?)\s*[:=]\s*["']?[^\s"',;]{8,}/i,
485
+ /\b(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token)\s*[:=]\s*["']?[^\s"',;]{6,}/i,
486
+ ].some((pattern) => pattern.test(text));
487
+ }
488
+
489
+ function extractionDiagnostic(input: {
490
+ readonly clock: () => string;
491
+ readonly outcome: MemoryExtractionDiagnostic["outcome"];
492
+ readonly reason: string | null;
493
+ readonly workspace: string;
494
+ readonly turnId: string;
495
+ readonly ms: number;
496
+ readonly inputTokens?: number;
497
+ readonly returned?: number;
498
+ readonly written?: number;
499
+ readonly rejected?: MemoryExtractionRejectedCounts;
500
+ }): MemoryExtractionDiagnostic {
501
+ return Object.freeze({
502
+ at: input.clock(),
503
+ kind: "extraction",
504
+ outcome: input.outcome,
505
+ reason: input.reason,
506
+ workspace: input.workspace,
507
+ turnId: input.turnId,
508
+ inputTokens: input.inputTokens ?? 0,
509
+ returned: input.returned ?? 0,
510
+ written: input.written ?? 0,
511
+ rejected: input.rejected ?? emptyRejectedCounts(),
512
+ ms: input.ms,
513
+ });
514
+ }
515
+
516
+ function searchDiagnostic(input: {
517
+ readonly clock: () => string;
518
+ readonly outcome: MemorySearchDiagnostic["outcome"];
519
+ readonly reason: string | null;
520
+ readonly workspace: string;
521
+ readonly sessionId: string;
522
+ readonly queryBytes: number;
523
+ readonly returned?: number;
524
+ readonly scores?: readonly number[];
525
+ readonly ms: number;
526
+ }): MemorySearchDiagnostic {
527
+ return Object.freeze({
528
+ at: input.clock(),
529
+ kind: "search",
530
+ outcome: input.outcome,
531
+ reason: input.reason,
532
+ workspace: input.workspace,
533
+ sessionId: input.sessionId,
534
+ queryBytes: input.queryBytes,
535
+ returned: input.returned ?? 0,
536
+ scores: Object.freeze([...(input.scores ?? [])]),
537
+ ms: input.ms,
538
+ });
539
+ }
540
+
541
+ function emptyRejectedCounts(): MemoryExtractionRejectedCounts {
542
+ return Object.freeze({
543
+ duplicate: 0,
544
+ secret: 0,
545
+ invalid: 0,
546
+ embedding: 0,
547
+ });
548
+ }
549
+
550
+ function elapsedMs(startedAt: number): number {
551
+ return Math.round((performance.now() - startedAt) * 100) / 100;
552
+ }
553
+
554
+ function roundScore(score: number): number {
555
+ return Math.round(score * 1_000) / 1_000;
556
+ }