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