tinker-agent 2.0.0 → 2.1.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 (50) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +27 -2
  3. package/package.json +2 -1
  4. package/src/agent/context-meter.ts +2 -4
  5. package/src/agent/runtime-session.ts +9 -2
  6. package/src/agent/session-ledger.ts +12 -5
  7. package/src/agent/tool-result-content.ts +76 -0
  8. package/src/agent/types.ts +14 -2
  9. package/src/cli/config.ts +4 -0
  10. package/src/cli/model-profiles.ts +41 -2
  11. package/src/cli/public-config-contract.ts +30 -7
  12. package/src/cli/runner-dependencies.ts +5 -0
  13. package/src/cli/tui-memory.ts +1 -0
  14. package/src/cli/tui-runner.tsx +4 -0
  15. package/src/context/compiled-context-hash.ts +2 -1
  16. package/src/context/compiled-context-validator.ts +13 -4
  17. package/src/context/context-protocol-validator.ts +33 -2
  18. package/src/context/context-revision-compiler.ts +2 -1
  19. package/src/context/context-revision.ts +8 -2
  20. package/src/context/context-swap-renderer.ts +46 -12
  21. package/src/context/prefix-retirement-planner.ts +13 -9
  22. package/src/context/protocol-frame.ts +74 -7
  23. package/src/context/swap-planner.ts +19 -14
  24. package/src/events/observation-text-log.ts +1 -1
  25. package/src/events/stdout-event-printer.ts +6 -0
  26. package/src/image/image-asset-store.ts +32 -3
  27. package/src/memory/contracts.ts +61 -3
  28. package/src/memory/memory-coordinator.ts +313 -49
  29. package/src/memory/memory-extractor.ts +48 -48
  30. package/src/memory/memory-get-tool.ts +86 -0
  31. package/src/memory/memory-search-tool.ts +122 -33
  32. package/src/memory/memory-store.ts +227 -20
  33. package/src/model/fake-model-client.ts +129 -76
  34. package/src/model/model-client.ts +62 -11
  35. package/src/model/openai-chat-mapping.ts +2 -1
  36. package/src/model/openai-chat-model-client.ts +22 -10
  37. package/src/model/openai-model-utils.ts +61 -30
  38. package/src/model/openai-responses-mapping.ts +25 -1
  39. package/src/model/openai-responses-model-client.ts +27 -11
  40. package/src/model/token-estimator.ts +10 -0
  41. package/src/observation/observation-builder.ts +100 -25
  42. package/src/session/session-history-reader.ts +128 -5
  43. package/src/session/session-schema.ts +59 -9
  44. package/src/session/session-store.ts +343 -196
  45. package/src/tools/registry.ts +18 -0
  46. package/src/tools/types.ts +46 -0
  47. package/src/tools/view-image.ts +89 -0
  48. package/src/tools/wait.ts +85 -0
  49. package/src/tui/components/memory-browser.tsx +3 -0
  50. package/src/tui/event-store.ts +61 -2
@@ -1,11 +1,19 @@
1
1
  import type { SessionId, TurnId } from "../ids/runtime-id";
2
2
 
3
3
  export const MEMORY_SEARCH_TOOL_NAME = "MemorySearch" as const;
4
- export const MEMORY_SCHEMA_VERSION = 1 as const;
5
- export const MAX_MEMORIES_PER_TURN = 4;
4
+ export const MEMORY_GET_TOOL_NAME = "MemoryGet" as const;
5
+ export const MEMORY_SCHEMA_VERSION = 2 as const;
6
6
  export const MAX_MEMORY_TEXT_BYTES = 512;
7
+ export const MAX_MEMORY_SUMMARY_BYTES = 4_096;
8
+ export const MAX_SEARCH_RESULT_SUMMARY_BYTES = 1_536;
7
9
  export const MAX_MEMORY_QUERY_BYTES = 1_024;
10
+ export const MAX_MEMORY_ID_BYTES = 64;
8
11
  export const MEMORY_SEARCH_LIMIT = 5;
12
+ export const MEMORY_RECALL_CANDIDATE_LIMIT = 20;
13
+ export const MAX_MEMORY_KEYWORDS = 8;
14
+ export const MAX_MEMORY_KEYWORD_BYTES = 128;
15
+ export const MEMORY_RRF_K = 60;
16
+ export const MEMORY_EXTRACTION_QUEUE_CAPACITY = 64;
9
17
 
10
18
  export type MemoryEmbeddingKind = "openai-compatible";
11
19
 
@@ -32,6 +40,7 @@ export type MemoryPaths = {
32
40
 
33
41
  export type MemoryWriteCandidate = {
34
42
  readonly text: string;
43
+ readonly summary: string;
35
44
  readonly embedding: Float32Array;
36
45
  };
37
46
 
@@ -57,18 +66,51 @@ export type MemoryInsertedRecord = {
57
66
  export type MemorySearchMatch = {
58
67
  readonly memoryId: string;
59
68
  readonly text: string;
69
+ readonly summary: string;
60
70
  readonly score: number;
61
71
  readonly sourceWorkspace: string;
72
+ readonly sourceSessionId: string;
62
73
  readonly createdAt: string;
63
74
  };
64
75
 
76
+ export type MemoryRecallPath = "vector" | "fts";
77
+
78
+ export type MemoryFtsMatch = {
79
+ readonly memoryId: string;
80
+ readonly text: string;
81
+ readonly summary: string;
82
+ readonly bm25: number;
83
+ readonly sourceWorkspace: string;
84
+ readonly sourceSessionId: string;
85
+ readonly createdAt: string;
86
+ };
87
+
88
+ export type MemoryHybridMatch = {
89
+ readonly memoryId: string;
90
+ readonly text: string;
91
+ readonly summary: string;
92
+ readonly score: number;
93
+ readonly via: readonly MemoryRecallPath[];
94
+ readonly sourceWorkspace: string;
95
+ readonly sourceSessionId: string;
96
+ readonly createdAt: string;
97
+ };
98
+
99
+ export type MemoryRecallDegraded = "vector" | "fts";
100
+
65
101
  export type StoredMemorySummary = {
66
102
  readonly memoryId: string;
67
103
  readonly text: string;
104
+ readonly summary: string;
68
105
  readonly sourceWorkspace: string;
106
+ readonly sourceSessionId: string;
69
107
  readonly createdAt: string;
70
108
  };
71
109
 
110
+ export type StoredMemoryRecord = StoredMemorySummary & {
111
+ readonly sourceTurnId: string;
112
+ };
113
+
72
114
  export type MemoryExtractionRejectedCounts = {
73
115
  readonly duplicate: number;
74
116
  readonly secret: number;
@@ -98,7 +140,11 @@ export type MemorySearchDiagnostic = {
98
140
  readonly workspace: string;
99
141
  readonly sessionId: string;
100
142
  readonly queryBytes: number;
143
+ readonly keywordCount: number;
101
144
  readonly returned: number;
145
+ readonly vectorReturned: number;
146
+ readonly ftsReturned: number;
147
+ readonly degraded: MemoryRecallDegraded | null;
102
148
  readonly scores: readonly number[];
103
149
  readonly ms: number;
104
150
  };
@@ -110,9 +156,21 @@ export type MemoryInitDiagnostic = {
110
156
  readonly reason: string;
111
157
  };
112
158
 
159
+ export type MemoryGetDiagnostic = {
160
+ readonly at: string;
161
+ readonly kind: "get";
162
+ readonly outcome: "ok" | "failed";
163
+ readonly reason: string | null;
164
+ readonly workspace: string;
165
+ readonly sessionId: string;
166
+ readonly found: boolean;
167
+ readonly ms: number;
168
+ };
169
+
113
170
  export type MemoryDiagnostic =
114
171
  | MemoryExtractionDiagnostic
115
172
  | MemorySearchDiagnostic
173
+ | MemoryGetDiagnostic
116
174
  | MemoryInitDiagnostic;
117
175
 
118
176
  export class MemoryError extends Error {
@@ -136,7 +194,7 @@ export function boundedMemoryError(error: unknown): string {
136
194
  return truncateUtf8(singleLine, 400);
137
195
  }
138
196
 
139
- function truncateUtf8(value: string, maxBytes: number): string {
197
+ export function truncateUtf8(value: string, maxBytes: number): string {
140
198
  if (Buffer.byteLength(value, "utf8") <= maxBytes) {
141
199
  return value;
142
200
  }
@@ -8,17 +8,34 @@ import type { SessionId } from "../ids/runtime-id";
8
8
  import type { ModelContextBudget } from "../model/model-context-profile";
9
9
  import type { ModelClient } from "../model/model-client";
10
10
  import type { CompletedTurnSnapshot } from "../session/session-store";
11
- import type { MemorySearchRawResult, ToolExecutor } from "../tools/types";
11
+ import type {
12
+ MemoryGetRawResult,
13
+ MemorySearchRawResult,
14
+ ToolExecutor,
15
+ } from "../tools/types";
12
16
  import {
13
17
  boundedMemoryError,
18
+ MAX_SEARCH_RESULT_SUMMARY_BYTES,
19
+ MEMORY_EXTRACTION_QUEUE_CAPACITY,
14
20
  memoryErrorCode,
21
+ MEMORY_GET_TOOL_NAME,
22
+ MEMORY_RECALL_CANDIDATE_LIMIT,
23
+ MEMORY_RRF_K,
24
+ MEMORY_SEARCH_LIMIT,
15
25
  MEMORY_SEARCH_TOOL_NAME,
16
26
  MemoryError,
27
+ truncateUtf8,
17
28
  type MemoryEmbeddingConfig,
18
29
  type MemoryExtractionDiagnostic,
19
30
  type MemoryExtractionRejectedCounts,
31
+ type MemoryFtsMatch,
32
+ type MemoryGetDiagnostic,
33
+ type MemoryHybridMatch,
20
34
  type MemoryPaths,
35
+ type MemoryRecallDegraded,
36
+ type MemoryRecallPath,
21
37
  type MemorySearchDiagnostic,
38
+ type MemorySearchMatch,
22
39
  type StoredMemorySummary,
23
40
  } from "./contracts";
24
41
  import {
@@ -33,6 +50,7 @@ import {
33
50
  type MemoryExtractionResult,
34
51
  } from "./memory-extractor";
35
52
  import { ExtractedMemoryLog, MemoryLog } from "./memory-log";
53
+ import { createMemoryGetToolExecutor } from "./memory-get-tool";
36
54
  import { createMemorySearchToolExecutor } from "./memory-search-tool";
37
55
  import { MemoryStore, resolveMemoryPaths } from "./memory-store";
38
56
  import { normalizeEmbedding } from "./vector";
@@ -62,7 +80,7 @@ export type CreateMemoryCoordinatorInput = {
62
80
  export class MemoryCoordinator implements CompletedTurnHook {
63
81
  private accepting = true;
64
82
  private active?: ActiveMemoryTask;
65
- private pending?: MemoryWorkerTask;
83
+ private pending: MemoryWorkerTask[] = [];
66
84
 
67
85
  private constructor(
68
86
  private readonly store: MemoryStore,
@@ -124,7 +142,10 @@ export class MemoryCoordinator implements CompletedTurnHook {
124
142
  this.start(task);
125
143
  return;
126
144
  }
127
- this.pending = task;
145
+ if (this.pending.length >= MEMORY_EXTRACTION_QUEUE_CAPACITY) {
146
+ this.pending.shift();
147
+ }
148
+ this.pending.push(task);
128
149
  }
129
150
 
130
151
  recordFailure(input: CompletedTurnHookFailure): void {
@@ -145,8 +166,18 @@ export class MemoryCoordinator implements CompletedTurnHook {
145
166
  readonly sessionId: SessionId;
146
167
  }): ToolExecutor {
147
168
  return createMemorySearchToolExecutor({
148
- search: (query, signal) => this.search(query, signal, input),
149
- recordInvalidCall: (queryBytes) => this.recordInvalidSearch(queryBytes, input),
169
+ search: (query, keywords, signal) => this.search(query, keywords, signal, input),
170
+ recordInvalidCall: (invalid) => this.recordInvalidSearch(invalid, input),
171
+ });
172
+ }
173
+
174
+ createGetToolExecutor(input: {
175
+ readonly workspaceRoot: string;
176
+ readonly sessionId: SessionId;
177
+ }): ToolExecutor {
178
+ return createMemoryGetToolExecutor({
179
+ get: (memoryId, signal) => this.get(memoryId, signal, input),
180
+ recordInvalidCall: () => this.recordInvalidGet(input),
150
181
  });
151
182
  }
152
183
 
@@ -159,7 +190,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
159
190
  return;
160
191
  }
161
192
  this.accepting = false;
162
- this.pending = undefined;
193
+ this.pending = [];
163
194
  this.active?.controller.abort(
164
195
  new MemoryError(
165
196
  "memory_coordinator_disposed",
@@ -183,8 +214,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
183
214
  return;
184
215
  }
185
216
  this.active = undefined;
186
- const next = this.accepting ? this.pending : undefined;
187
- this.pending = undefined;
217
+ const next = this.accepting ? this.pending.shift() : undefined;
188
218
  if (next !== undefined) {
189
219
  this.start(next);
190
220
  }
@@ -204,7 +234,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
204
234
  try {
205
235
  extraction = await this.extractor.extract(task.extractionEvidenceText, signal);
206
236
  inputTokens = extraction.inputTokens;
207
- returned = extraction.memories.length;
237
+ returned = extraction.memory === null ? 0 : 1;
208
238
  } catch (error) {
209
239
  if (error instanceof MemoryExtractionSkippedError) {
210
240
  inputTokens = error.inputTokens;
@@ -246,17 +276,28 @@ export class MemoryCoordinator implements CompletedTurnHook {
246
276
  return;
247
277
  }
248
278
 
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) {
279
+ const candidate = extraction.memory;
280
+ if (candidate === null) {
281
+ await this.log.append(
282
+ extractionDiagnostic({
283
+ clock: this.clock,
284
+ outcome: "ok",
285
+ reason: null,
286
+ workspace: task.workspaceRoot,
287
+ turnId: task.turnId,
288
+ inputTokens,
289
+ returned,
290
+ ms: elapsedMs(startedAt),
291
+ }),
292
+ );
293
+ return;
294
+ }
295
+
296
+ if (
297
+ containsSensitiveMemory(candidate.text) ||
298
+ containsSensitiveMemory(candidate.summary)
299
+ ) {
300
+ rejected = Object.freeze({ ...rejected, secret: 1 });
260
301
  await this.log.append(
261
302
  extractionDiagnostic({
262
303
  clock: this.clock,
@@ -273,24 +314,20 @@ export class MemoryCoordinator implements CompletedTurnHook {
273
314
  return;
274
315
  }
275
316
 
276
- let embeddings: readonly Float32Array[];
317
+ let embedding: Float32Array;
277
318
  try {
278
- const rawEmbeddings = await this.embeddingClient.embed(safeMemories, signal);
279
- if (rawEmbeddings.length !== safeMemories.length) {
319
+ const rawEmbeddings = await this.embeddingClient.embed([candidate.text], signal);
320
+ if (rawEmbeddings.length !== 1 || rawEmbeddings[0] === undefined) {
280
321
  throw new MemoryError(
281
322
  "memory_embedding_response_invalid",
282
323
  "Embedding response count does not match the memory candidate count.",
283
324
  );
284
325
  }
285
- embeddings = Object.freeze(
286
- rawEmbeddings.map((vector) =>
287
- normalizeEmbedding(vector, this.embeddingDimensions),
288
- ),
289
- );
326
+ embedding = normalizeEmbedding(rawEmbeddings[0], this.embeddingDimensions);
290
327
  } catch (error) {
291
328
  rejected = Object.freeze({
292
329
  ...rejected,
293
- embedding: safeMemories.length,
330
+ embedding: 1,
294
331
  });
295
332
  await this.log.append(
296
333
  extractionDiagnostic({
@@ -315,10 +352,13 @@ export class MemoryCoordinator implements CompletedTurnHook {
315
352
  workspaceRoot: task.workspaceRoot,
316
353
  sessionId: task.sessionId,
317
354
  turnId: task.turnId,
318
- candidates: safeMemories.map((text, index) => ({
319
- text,
320
- embedding: embeddings[index],
321
- })),
355
+ candidates: [
356
+ {
357
+ text: candidate.text,
358
+ summary: candidate.summary,
359
+ embedding,
360
+ },
361
+ ],
322
362
  });
323
363
  rejected = Object.freeze({
324
364
  ...rejected,
@@ -366,22 +406,56 @@ export class MemoryCoordinator implements CompletedTurnHook {
366
406
  }
367
407
 
368
408
  private async search(
369
- query: string,
409
+ query: string | null,
410
+ keywords: readonly string[],
370
411
  signal: AbortSignal,
371
412
  source: { readonly workspaceRoot: string; readonly sessionId: SessionId },
372
413
  ): Promise<MemorySearchRawResult> {
373
414
  const startedAt = performance.now();
374
- const queryBytes = Buffer.byteLength(query, "utf8");
415
+ const queryBytes = query === null ? 0 : Buffer.byteLength(query, "utf8");
416
+ let degraded: MemoryRecallDegraded | null = null;
417
+ let vectorMatches: readonly MemorySearchMatch[] = [];
418
+ let ftsMatches: readonly MemoryFtsMatch[] = [];
375
419
  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
- );
420
+ if (query !== null) {
421
+ try {
422
+ const raw = await this.embeddingClient.embed([query], signal);
423
+ if (raw.length !== 1 || raw[0] === undefined) {
424
+ throw new MemoryError(
425
+ "memory_embedding_response_invalid",
426
+ "Embedding response did not contain the query vector.",
427
+ );
428
+ }
429
+ const queryEmbedding = normalizeEmbedding(raw[0], this.embeddingDimensions);
430
+ vectorMatches = this.store.search(
431
+ queryEmbedding,
432
+ MEMORY_RECALL_CANDIDATE_LIMIT,
433
+ );
434
+ } catch (error) {
435
+ if (signal.aborted || keywords.length === 0) {
436
+ throw error;
437
+ }
438
+ degraded = "vector";
439
+ }
440
+ }
441
+ if (keywords.length > 0) {
442
+ try {
443
+ ftsMatches = this.store.searchFts(keywords, MEMORY_RECALL_CANDIDATE_LIMIT);
444
+ } catch (error) {
445
+ if (signal.aborted) {
446
+ throw error;
447
+ }
448
+ const vectorUsable = query !== null && degraded === null;
449
+ if (!vectorUsable) {
450
+ throw error;
451
+ }
452
+ degraded = "fts";
453
+ }
382
454
  }
383
- const queryEmbedding = normalizeEmbedding(raw[0], this.embeddingDimensions);
384
- const matches = this.store.search(queryEmbedding);
455
+ const fused = fuseMemoryRecall({ vector: vectorMatches, fts: ftsMatches }).slice(
456
+ 0,
457
+ MEMORY_SEARCH_LIMIT,
458
+ );
385
459
  await this.log.append(
386
460
  searchDiagnostic({
387
461
  clock: this.clock,
@@ -390,19 +464,28 @@ export class MemoryCoordinator implements CompletedTurnHook {
390
464
  workspace: source.workspaceRoot,
391
465
  sessionId: source.sessionId,
392
466
  queryBytes,
393
- returned: matches.length,
394
- scores: matches.map((match) => roundScore(match.score)),
467
+ keywordCount: keywords.length,
468
+ returned: fused.length,
469
+ vectorReturned: vectorMatches.length,
470
+ ftsReturned: ftsMatches.length,
471
+ degraded,
472
+ scores: fused.map((match) => roundScore(match.score)),
395
473
  ms: elapsedMs(startedAt),
396
474
  }),
397
475
  );
398
476
  return {
399
477
  ok: true,
478
+ degraded,
400
479
  matches: Object.freeze(
401
- matches.map((match) =>
480
+ fused.map((match) =>
402
481
  Object.freeze({
482
+ memoryId: match.memoryId,
403
483
  text: match.text,
484
+ summary: truncateUtf8(match.summary, MAX_SEARCH_RESULT_SUMMARY_BYTES),
404
485
  score: match.score,
486
+ via: match.via,
405
487
  sourceWorkspace: match.sourceWorkspace,
488
+ sourceSessionId: match.sourceSessionId,
406
489
  createdAt: match.createdAt,
407
490
  }),
408
491
  ),
@@ -420,6 +503,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
420
503
  workspace: source.workspaceRoot,
421
504
  sessionId: source.sessionId,
422
505
  queryBytes,
506
+ keywordCount: keywords.length,
423
507
  ms: elapsedMs(startedAt),
424
508
  }),
425
509
  );
@@ -434,7 +518,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
434
518
  }
435
519
 
436
520
  private recordInvalidSearch(
437
- queryBytes: number,
521
+ invalid: { readonly queryBytes: number; readonly keywordCount: number },
438
522
  source: { readonly workspaceRoot: string; readonly sessionId: SessionId },
439
523
  ): Promise<void> {
440
524
  return this.log.append(
@@ -444,7 +528,82 @@ export class MemoryCoordinator implements CompletedTurnHook {
444
528
  reason: "memory_search_args_invalid",
445
529
  workspace: source.workspaceRoot,
446
530
  sessionId: source.sessionId,
447
- queryBytes,
531
+ queryBytes: invalid.queryBytes,
532
+ keywordCount: invalid.keywordCount,
533
+ ms: 0,
534
+ }),
535
+ );
536
+ }
537
+
538
+ private async get(
539
+ memoryId: string,
540
+ signal: AbortSignal,
541
+ source: { readonly workspaceRoot: string; readonly sessionId: SessionId },
542
+ ): Promise<MemoryGetRawResult> {
543
+ const startedAt = performance.now();
544
+ try {
545
+ signal.throwIfAborted();
546
+ const record = this.store.getById(memoryId);
547
+ await this.log.append(
548
+ getDiagnostic({
549
+ clock: this.clock,
550
+ outcome: "ok",
551
+ reason: null,
552
+ workspace: source.workspaceRoot,
553
+ sessionId: source.sessionId,
554
+ found: record !== undefined,
555
+ ms: elapsedMs(startedAt),
556
+ }),
557
+ );
558
+ return {
559
+ ok: true,
560
+ memory:
561
+ record === undefined
562
+ ? null
563
+ : Object.freeze({
564
+ memoryId: record.memoryId,
565
+ text: record.text,
566
+ summary: record.summary,
567
+ sourceWorkspace: record.sourceWorkspace,
568
+ sourceSessionId: record.sourceSessionId,
569
+ sourceTurnId: record.sourceTurnId,
570
+ createdAt: record.createdAt,
571
+ }),
572
+ };
573
+ } catch (error) {
574
+ if (signal.aborted) {
575
+ throw error;
576
+ }
577
+ await this.log.append(
578
+ getDiagnostic({
579
+ clock: this.clock,
580
+ outcome: "failed",
581
+ reason: memoryErrorCode(error, "memory_get_failed"),
582
+ workspace: source.workspaceRoot,
583
+ sessionId: source.sessionId,
584
+ found: false,
585
+ ms: elapsedMs(startedAt),
586
+ }),
587
+ );
588
+ return {
589
+ ok: false,
590
+ error: boundedMemoryError(error),
591
+ };
592
+ }
593
+ }
594
+
595
+ private recordInvalidGet(source: {
596
+ readonly workspaceRoot: string;
597
+ readonly sessionId: SessionId;
598
+ }): Promise<void> {
599
+ return this.log.append(
600
+ getDiagnostic({
601
+ clock: this.clock,
602
+ outcome: "failed",
603
+ reason: "memory_get_args_invalid",
604
+ workspace: source.workspaceRoot,
605
+ sessionId: source.sessionId,
606
+ found: false,
448
607
  ms: 0,
449
608
  }),
450
609
  );
@@ -463,7 +622,10 @@ export function buildExtractionEvidenceText(
463
622
  }
464
623
  const messages = snapshot.messages
465
624
  .filter(
466
- (message) => message.role !== "tool" || message.name !== MEMORY_SEARCH_TOOL_NAME,
625
+ (message) =>
626
+ message.role !== "tool" ||
627
+ (message.name !== MEMORY_SEARCH_TOOL_NAME &&
628
+ message.name !== MEMORY_GET_TOOL_NAME),
467
629
  )
468
630
  .map((message) => ({ ...message }));
469
631
  return JSON.stringify(
@@ -520,7 +682,11 @@ function searchDiagnostic(input: {
520
682
  readonly workspace: string;
521
683
  readonly sessionId: string;
522
684
  readonly queryBytes: number;
685
+ readonly keywordCount: number;
523
686
  readonly returned?: number;
687
+ readonly vectorReturned?: number;
688
+ readonly ftsReturned?: number;
689
+ readonly degraded?: MemoryRecallDegraded | null;
524
690
  readonly scores?: readonly number[];
525
691
  readonly ms: number;
526
692
  }): MemorySearchDiagnostic {
@@ -532,12 +698,37 @@ function searchDiagnostic(input: {
532
698
  workspace: input.workspace,
533
699
  sessionId: input.sessionId,
534
700
  queryBytes: input.queryBytes,
701
+ keywordCount: input.keywordCount,
535
702
  returned: input.returned ?? 0,
703
+ vectorReturned: input.vectorReturned ?? 0,
704
+ ftsReturned: input.ftsReturned ?? 0,
705
+ degraded: input.degraded ?? null,
536
706
  scores: Object.freeze([...(input.scores ?? [])]),
537
707
  ms: input.ms,
538
708
  });
539
709
  }
540
710
 
711
+ function getDiagnostic(input: {
712
+ readonly clock: () => string;
713
+ readonly outcome: MemoryGetDiagnostic["outcome"];
714
+ readonly reason: string | null;
715
+ readonly workspace: string;
716
+ readonly sessionId: string;
717
+ readonly found: boolean;
718
+ readonly ms: number;
719
+ }): MemoryGetDiagnostic {
720
+ return Object.freeze({
721
+ at: input.clock(),
722
+ kind: "get",
723
+ outcome: input.outcome,
724
+ reason: input.reason,
725
+ workspace: input.workspace,
726
+ sessionId: input.sessionId,
727
+ found: input.found,
728
+ ms: input.ms,
729
+ });
730
+ }
731
+
541
732
  function emptyRejectedCounts(): MemoryExtractionRejectedCounts {
542
733
  return Object.freeze({
543
734
  duplicate: 0,
@@ -554,3 +745,76 @@ function elapsedMs(startedAt: number): number {
554
745
  function roundScore(score: number): number {
555
746
  return Math.round(score * 1_000) / 1_000;
556
747
  }
748
+
749
+ export function fuseMemoryRecall(input: {
750
+ readonly vector: readonly MemorySearchMatch[];
751
+ readonly fts: readonly MemoryFtsMatch[];
752
+ }): readonly MemoryHybridMatch[] {
753
+ type Entry = {
754
+ readonly memoryId: string;
755
+ readonly text: string;
756
+ readonly summary: string;
757
+ readonly sourceWorkspace: string;
758
+ readonly sourceSessionId: string;
759
+ readonly createdAt: string;
760
+ score: number;
761
+ readonly via: Set<MemoryRecallPath>;
762
+ };
763
+ const entries = new Map<string, Entry>();
764
+ const accumulate = (
765
+ match: {
766
+ readonly memoryId: string;
767
+ readonly text: string;
768
+ readonly summary: string;
769
+ readonly sourceWorkspace: string;
770
+ readonly sourceSessionId: string;
771
+ readonly createdAt: string;
772
+ },
773
+ rank: number,
774
+ path: MemoryRecallPath,
775
+ ): void => {
776
+ const contribution = 1 / (MEMORY_RRF_K + rank);
777
+ const existing = entries.get(match.memoryId);
778
+ if (existing !== undefined) {
779
+ existing.score += contribution;
780
+ existing.via.add(path);
781
+ return;
782
+ }
783
+ entries.set(match.memoryId, {
784
+ memoryId: match.memoryId,
785
+ text: match.text,
786
+ summary: match.summary,
787
+ sourceWorkspace: match.sourceWorkspace,
788
+ sourceSessionId: match.sourceSessionId,
789
+ createdAt: match.createdAt,
790
+ score: contribution,
791
+ via: new Set([path]),
792
+ });
793
+ };
794
+ input.vector.forEach((match, index) => accumulate(match, index + 1, "vector"));
795
+ input.fts.forEach((match, index) => accumulate(match, index + 1, "fts"));
796
+
797
+ return Object.freeze(
798
+ [...entries.values()]
799
+ .sort(
800
+ (left, right) =>
801
+ right.score - left.score ||
802
+ right.createdAt.localeCompare(left.createdAt) ||
803
+ left.memoryId.localeCompare(right.memoryId),
804
+ )
805
+ .map((entry) =>
806
+ Object.freeze({
807
+ memoryId: entry.memoryId,
808
+ text: entry.text,
809
+ summary: entry.summary,
810
+ score: entry.score,
811
+ via: Object.freeze(
812
+ (["vector", "fts"] as const).filter((path) => entry.via.has(path)),
813
+ ),
814
+ sourceWorkspace: entry.sourceWorkspace,
815
+ sourceSessionId: entry.sourceSessionId,
816
+ createdAt: entry.createdAt,
817
+ }),
818
+ ),
819
+ );
820
+ }