@poncho-ai/harness 0.34.1 → 0.36.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 (64) hide show
  1. package/.turbo/turbo-build.log +12 -11
  2. package/.turbo/turbo-lint.log +6 -0
  3. package/.turbo/turbo-test.log +27100 -0
  4. package/CHANGELOG.md +37 -0
  5. package/dist/chunk-MCKGQKYU.js +15 -0
  6. package/dist/dist-3KMQR4IO.js +27092 -0
  7. package/dist/index.d.ts +553 -29
  8. package/dist/index.js +3132 -1902
  9. package/dist/isolate-5MISBSUK.js +733 -0
  10. package/dist/isolate-5R6762YA.js +605 -0
  11. package/dist/isolate-KUZ5NOPG.js +727 -0
  12. package/dist/isolate-LOL3T7RA.js +729 -0
  13. package/dist/isolate-N22X4TCE.js +740 -0
  14. package/dist/isolate-T7WXM7IL.js +1490 -0
  15. package/dist/isolate-TCWTUVG4.js +1532 -0
  16. package/dist/isolate-WFOLANOB.js +768 -0
  17. package/package.json +24 -4
  18. package/scripts/migrate-to-engine.mjs +556 -0
  19. package/src/config.ts +112 -1
  20. package/src/harness.ts +282 -91
  21. package/src/index.ts +7 -0
  22. package/src/isolate/bindings.ts +206 -0
  23. package/src/isolate/bundler.ts +179 -0
  24. package/src/isolate/index.ts +10 -0
  25. package/src/isolate/polyfills.ts +796 -0
  26. package/src/isolate/run-code-tool.ts +220 -0
  27. package/src/isolate/runtime.ts +286 -0
  28. package/src/isolate/type-stubs.ts +196 -0
  29. package/src/mcp.ts +140 -9
  30. package/src/memory.ts +142 -191
  31. package/src/reminder-store.ts +7 -235
  32. package/src/reminder-tools.ts +15 -2
  33. package/src/secrets-store.ts +163 -0
  34. package/src/state.ts +22 -1291
  35. package/src/storage/engine.ts +106 -0
  36. package/src/storage/index.ts +59 -0
  37. package/src/storage/memory-engine.ts +588 -0
  38. package/src/storage/postgres-engine.ts +139 -0
  39. package/src/storage/schema.ts +145 -0
  40. package/src/storage/sql-dialect.ts +963 -0
  41. package/src/storage/sqlite-engine.ts +99 -0
  42. package/src/storage/store-adapters.ts +100 -0
  43. package/src/subagent-manager.ts +1 -0
  44. package/src/subagent-tools.ts +1 -0
  45. package/src/telemetry.ts +5 -1
  46. package/src/tenant-token.ts +42 -0
  47. package/src/todo-tools.ts +1 -136
  48. package/src/upload-store.ts +1 -0
  49. package/src/vfs/bash-manager.ts +120 -0
  50. package/src/vfs/bash-tool.ts +59 -0
  51. package/src/vfs/create-bash-fs.ts +32 -0
  52. package/src/vfs/edit-file-tool.ts +72 -0
  53. package/src/vfs/index.ts +5 -0
  54. package/src/vfs/poncho-fs-adapter.ts +267 -0
  55. package/src/vfs/protected-fs.ts +177 -0
  56. package/src/vfs/read-file-tool.ts +103 -0
  57. package/src/vfs/write-file-tool.ts +49 -0
  58. package/test/harness.test.ts +30 -36
  59. package/test/isolate-vfs.test.ts +453 -0
  60. package/test/isolate.test.ts +252 -0
  61. package/test/state.test.ts +4 -27
  62. package/test/storage-engine.test.ts +250 -0
  63. package/test/vfs.test.ts +242 -0
  64. package/src/kv-store.ts +0 -216
package/src/mcp.ts CHANGED
@@ -273,6 +273,15 @@ export class LocalMcpBridge {
273
273
  private readonly toolCatalog = new Map<string, McpToolDescriptor[]>();
274
274
  private readonly unavailableServers = new Map<string, string>();
275
275
  private readonly authFailedServers = new Set<string>();
276
+ private envResolver?: (tenantId: string | undefined, envName: string) => Promise<string | undefined>;
277
+
278
+ /**
279
+ * Set a resolver for per-tenant env vars (e.g. MCP auth tokens).
280
+ * Called by the harness after creating the secrets store.
281
+ */
282
+ setEnvResolver(resolver: (tenantId: string | undefined, envName: string) => Promise<string | undefined>): void {
283
+ this.envResolver = resolver;
284
+ }
276
285
 
277
286
  constructor(config: McpConfig | undefined) {
278
287
  this.remoteServers = (config?.mcp ?? []).filter((entry): entry is RemoteMcpServerConfig =>
@@ -316,8 +325,12 @@ export class LocalMcpBridge {
316
325
  console.info(`[poncho][mcp] ${line}`);
317
326
  }
318
327
 
328
+ /** Set of servers where discovery was deferred (no default token, has env resolver). */
329
+ private readonly deferredDiscoveryServers = new Set<string>();
330
+
319
331
  async discoverTools(): Promise<void> {
320
332
  this.toolCatalog.clear();
333
+ this.deferredDiscoveryServers.clear();
321
334
  for (const remoteServer of this.remoteServers) {
322
335
  const name = this.getServerName(remoteServer);
323
336
  if (this.unavailableServers.has(name)) {
@@ -338,6 +351,13 @@ export class LocalMcpBridge {
338
351
  } catch (error) {
339
352
  const message = error instanceof Error ? error.message : String(error);
340
353
  if (error instanceof McpHttpError && error.status === 401) {
354
+ if (this.envResolver && remoteServer.auth?.tokenEnv) {
355
+ // Discovery failed without token but tenant secrets may provide one —
356
+ // defer discovery to first loadTools() call with a tenant context
357
+ this.deferredDiscoveryServers.add(name);
358
+ this.log("info", "catalog.deferred", { server: name, reason: "auth_deferred_to_tenant" });
359
+ continue;
360
+ }
341
361
  this.authFailedServers.add(name);
342
362
  this.log("warn", "auth.failed", {
343
363
  server: name,
@@ -351,6 +371,64 @@ export class LocalMcpBridge {
351
371
  }
352
372
  }
353
373
 
374
+ /**
375
+ * Run deferred discovery and return ToolDefinitions for all newly discovered tools.
376
+ * Call this during run() so the tools are available to the model immediately.
377
+ */
378
+ async discoverAndLoadDeferred(tenantId: string): Promise<ToolDefinition[]> {
379
+ if (this.deferredDiscoveryServers.size === 0) return [];
380
+ for (const server of this.remoteServers) {
381
+ await this.tryDeferredDiscovery(server, tenantId);
382
+ }
383
+ // Build tool definitions only for servers that were deferred and now have tools
384
+ const tools: ToolDefinition[] = [];
385
+ for (const server of this.remoteServers) {
386
+ const name = this.getServerName(server);
387
+ // Only include servers that WERE deferred (whether discovery succeeded or not)
388
+ if (!server.auth?.tokenEnv) continue;
389
+ const discovered = this.toolCatalog.get(name);
390
+ if (!discovered || discovered.length === 0) continue;
391
+ const client = this.rpcClients.get(name);
392
+ if (!client) continue;
393
+ tools.push(...this.toToolDefinitions(name, discovered, client, server));
394
+ }
395
+ return tools;
396
+ }
397
+
398
+ private async tryDeferredDiscovery(server: RemoteMcpServerConfig, tenantId: string): Promise<void> {
399
+ const name = this.getServerName(server);
400
+ if (!this.deferredDiscoveryServers.has(name)) return;
401
+ if (this.toolCatalog.has(name)) return; // already discovered
402
+
403
+ const tokenEnv = server.auth?.tokenEnv;
404
+ if (!tokenEnv || !this.envResolver) return;
405
+
406
+ const token = await this.envResolver(tenantId, tokenEnv);
407
+ if (!token) return;
408
+
409
+ try {
410
+ const probeClient = new StreamableHttpMcpRpcClient(
411
+ server.url,
412
+ server.timeoutMs ?? 10_000,
413
+ token,
414
+ server.headers,
415
+ );
416
+ const discovered = await probeClient.listTools();
417
+ this.toolCatalog.set(name, discovered);
418
+ this.deferredDiscoveryServers.delete(name);
419
+ this.log("info", "catalog.loaded", {
420
+ server: name,
421
+ discoveredCount: discovered.length,
422
+ via: "deferred_tenant_discovery",
423
+ });
424
+ } catch (error) {
425
+ this.log("warn", "catalog.deferred_failed", {
426
+ server: name,
427
+ error: error instanceof Error ? error.message : String(error),
428
+ });
429
+ }
430
+ }
431
+
354
432
  async startLocalServers(): Promise<void> {
355
433
  this.unavailableServers.clear();
356
434
  for (const server of this.remoteServers) {
@@ -359,15 +437,24 @@ export class LocalMcpBridge {
359
437
  if (tokenEnv) {
360
438
  const token = process.env[tokenEnv];
361
439
  if (!token || token.trim().length === 0) {
362
- this.unavailableServers.set(
363
- name,
364
- `Missing bearer token value from env var ${tokenEnv}`,
365
- );
366
- this.log("warn", "auth.token_missing", {
440
+ if (!this.envResolver) {
441
+ // No tenant secrets resolver — server is genuinely unavailable
442
+ this.unavailableServers.set(
443
+ name,
444
+ `Missing bearer token value from env var ${tokenEnv}`,
445
+ );
446
+ this.log("warn", "auth.token_missing", {
447
+ server: name,
448
+ tokenEnv,
449
+ });
450
+ continue;
451
+ }
452
+ // Tenant secrets resolver available — create client without a default token;
453
+ // per-tenant tokens will be resolved at call time in toToolDefinitions
454
+ this.log("info", "auth.token_deferred", {
367
455
  server: name,
368
456
  tokenEnv,
369
457
  });
370
- continue;
371
458
  }
372
459
  }
373
460
  this.rpcClients.set(
@@ -441,6 +528,31 @@ export class LocalMcpBridge {
441
528
  return output.sort();
442
529
  }
443
530
 
531
+ hasDeferredServers(): boolean {
532
+ return this.deferredDiscoveryServers.size > 0;
533
+ }
534
+
535
+ /**
536
+ * Return ToolDefinitions for catalog tools not already registered in the dispatcher.
537
+ */
538
+ getUnregisteredTools(registeredNames: Set<string>): ToolDefinition[] {
539
+ const tools: ToolDefinition[] = [];
540
+ for (const server of this.remoteServers) {
541
+ const name = this.getServerName(server);
542
+ const discovered = this.toolCatalog.get(name);
543
+ if (!discovered || discovered.length === 0) continue;
544
+ const client = this.rpcClients.get(name);
545
+ if (!client) continue;
546
+ const unregistered = discovered.filter(
547
+ (d) => !registeredNames.has(`${name}/${d.name}`),
548
+ );
549
+ if (unregistered.length > 0) {
550
+ tools.push(...this.toToolDefinitions(name, unregistered, client, server));
551
+ }
552
+ }
553
+ return tools;
554
+ }
555
+
444
556
  async loadTools(
445
557
  requestedPatterns: string[],
446
558
  ): Promise<ToolDefinition[]> {
@@ -474,7 +586,7 @@ export class LocalMcpBridge {
474
586
  const selectedDescriptors = discovered.filter((descriptor) =>
475
587
  selectedRawNames.has(descriptor.name),
476
588
  );
477
- tools.push(...this.toToolDefinitions(serverName, selectedDescriptors, client));
589
+ tools.push(...this.toToolDefinitions(serverName, selectedDescriptors, client, server));
478
590
  }
479
591
  this.log("info", "tools.selected", {
480
592
  requestedPatternCount: requestedPatterns.length,
@@ -489,6 +601,7 @@ export class LocalMcpBridge {
489
601
  serverName: string,
490
602
  tools: McpToolDescriptor[],
491
603
  client: McpRpcClient,
604
+ server?: RemoteMcpServerConfig,
492
605
  ): ToolDefinition[] {
493
606
  return tools.map((tool) => ({
494
607
  name: `${serverName}/${tool.name}`,
@@ -498,9 +611,27 @@ export class LocalMcpBridge {
498
611
  type: "object",
499
612
  properties: {},
500
613
  },
501
- handler: async (input) => {
614
+ handler: async (input, context) => {
502
615
  try {
503
- return await client.callTool(tool.name, input);
616
+ // Per-tenant token resolution: if we have a resolver and the server uses tokenEnv,
617
+ // create a per-request client with the tenant-specific token
618
+ const tokenEnv = server?.auth?.tokenEnv;
619
+ let callClient = client;
620
+ if (tokenEnv && this.envResolver && context?.tenantId) {
621
+ const tenantToken = await this.envResolver(context.tenantId, tokenEnv);
622
+ const defaultToken = process.env[tokenEnv];
623
+ // Only create a per-request client when the tenant has a different token.
624
+ // Using the original client preserves the established MCP session.
625
+ if (tenantToken && tenantToken !== defaultToken) {
626
+ callClient = new StreamableHttpMcpRpcClient(
627
+ server.url,
628
+ server.timeoutMs ?? 10_000,
629
+ tenantToken,
630
+ server.headers,
631
+ );
632
+ }
633
+ }
634
+ return await callClient.callTool(tool.name, input);
504
635
  } catch (error) {
505
636
  if (error instanceof McpHttpError && error.status === 401) {
506
637
  this.authFailedServers.add(serverName);
package/src/memory.ts CHANGED
@@ -1,14 +1,5 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
3
- import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
1
+ import { defineTool, type ToolContext, type ToolDefinition } from "@poncho-ai/sdk";
4
2
  import type { StateProviderName } from "./state.js";
5
- import {
6
- ensureAgentIdentity,
7
- getAgentStoreDirectory,
8
- slugifyStorageComponent,
9
- STORAGE_SCHEMA_VERSION,
10
- } from "./agent-identity.js";
11
- import { createRawKVStore, type RawKVStore } from "./kv-store.js";
12
3
 
13
4
  export interface MainMemory {
14
5
  content: string;
@@ -31,10 +22,6 @@ export interface MemoryStore {
31
22
  updateMainMemory(input: { content: string }): Promise<MainMemory>;
32
23
  }
33
24
 
34
- type MainMemoryPayload = {
35
- main: MainMemory;
36
- };
37
-
38
25
  type RecallItem = {
39
26
  conversationId: string;
40
27
  title: string;
@@ -42,18 +29,27 @@ type RecallItem = {
42
29
  content: string;
43
30
  };
44
31
 
32
+ type ConversationListItem = {
33
+ conversationId: string;
34
+ title: string;
35
+ createdAt?: number;
36
+ updatedAt: number;
37
+ messageCount?: number;
38
+ };
39
+
40
+ type ConversationDetail = {
41
+ conversationId: string;
42
+ title: string;
43
+ createdAt?: number;
44
+ updatedAt: number;
45
+ messages: Array<{ role: string; content: string }>;
46
+ };
47
+
48
+
45
49
  const DEFAULT_MAIN_MEMORY: MainMemory = {
46
50
  content: "",
47
51
  updatedAt: 0,
48
52
  };
49
- const LOCAL_MEMORY_FILE = "memory.json";
50
-
51
- const writeJsonAtomic = async (filePath: string, payload: unknown): Promise<void> => {
52
- await mkdir(dirname(filePath), { recursive: true });
53
- const tmpPath = `${filePath}.tmp`;
54
- await writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
55
- await rename(tmpPath, filePath);
56
- };
57
53
 
58
54
  const scoreText = (text: string, query: string): number => {
59
55
  const normalized = query.trim().toLowerCase();
@@ -99,150 +95,12 @@ class InMemoryMemoryStore implements MemoryStore {
99
95
  }
100
96
  }
101
97
 
102
- class FileMainMemoryStore implements MemoryStore {
103
- private readonly workingDir: string;
104
- private filePath = "";
105
- private readonly ttlMs?: number;
106
- private loaded = false;
107
- private writing = Promise.resolve();
108
- private mainMemory: MainMemory = { ...DEFAULT_MAIN_MEMORY };
109
-
110
- constructor(workingDir: string, ttlSeconds?: number) {
111
- this.workingDir = workingDir;
112
- this.ttlMs = typeof ttlSeconds === "number" ? ttlSeconds * 1000 : undefined;
113
- }
114
-
115
- private async ensureFilePath(): Promise<void> {
116
- if (this.filePath) {
117
- return;
118
- }
119
- const identity = await ensureAgentIdentity(this.workingDir);
120
- this.filePath = resolve(getAgentStoreDirectory(identity), LOCAL_MEMORY_FILE);
121
- }
122
-
123
- private isExpired(updatedAt: number): boolean {
124
- return typeof this.ttlMs === "number" && Date.now() - updatedAt > this.ttlMs;
125
- }
126
-
127
- private async ensureLoaded(): Promise<void> {
128
- await this.ensureFilePath();
129
- if (this.loaded) {
130
- return;
131
- }
132
- this.loaded = true;
133
- try {
134
- const raw = await readFile(this.filePath, "utf8");
135
- const parsed = JSON.parse(raw) as MainMemoryPayload;
136
- const content = typeof parsed.main?.content === "string" ? parsed.main.content : "";
137
- const updatedAt = typeof parsed.main?.updatedAt === "number" ? parsed.main.updatedAt : 0;
138
- this.mainMemory = { content, updatedAt };
139
- } catch {
140
- // Missing or invalid file should not crash local mode.
141
- }
142
- }
143
-
144
- private async persist(): Promise<void> {
145
- const payload: MainMemoryPayload = { main: this.mainMemory };
146
- this.writing = this.writing.then(async () => {
147
- await writeJsonAtomic(this.filePath, payload);
148
- });
149
- await this.writing;
150
- }
151
-
152
- async getMainMemory(): Promise<MainMemory> {
153
- await this.ensureLoaded();
154
- if (this.mainMemory.updatedAt > 0 && this.isExpired(this.mainMemory.updatedAt)) {
155
- this.mainMemory = { ...DEFAULT_MAIN_MEMORY };
156
- await this.persist();
157
- }
158
- return this.mainMemory;
159
- }
160
-
161
- async updateMainMemory(input: { content: string }): Promise<MainMemory> {
162
- await this.ensureLoaded();
163
- this.mainMemory = {
164
- content: input.content.trim(),
165
- updatedAt: Date.now(),
166
- };
167
- await this.persist();
168
- return this.mainMemory;
169
- }
170
- }
171
-
172
- class KVBackedMemoryStore implements MemoryStore {
173
- private readonly kv: RawKVStore;
174
- private readonly storageKey: string;
175
- private readonly ttl?: number;
176
- private readonly memoryFallback: InMemoryMemoryStore;
177
-
178
- constructor(kv: RawKVStore, storageKey: string, ttl?: number) {
179
- this.kv = kv;
180
- this.storageKey = storageKey;
181
- this.ttl = ttl;
182
- this.memoryFallback = new InMemoryMemoryStore(ttl);
183
- }
184
-
185
- private async readPayload(): Promise<MainMemoryPayload> {
186
- try {
187
- const raw = await this.kv.get(this.storageKey);
188
- if (!raw) return { main: { ...DEFAULT_MAIN_MEMORY } };
189
- const parsed = JSON.parse(raw) as MainMemoryPayload;
190
- const content = typeof parsed.main?.content === "string" ? parsed.main.content : "";
191
- const updatedAt = typeof parsed.main?.updatedAt === "number" ? parsed.main.updatedAt : 0;
192
- return { main: { content, updatedAt } };
193
- } catch {
194
- const main = await this.memoryFallback.getMainMemory();
195
- return { main };
196
- }
197
- }
198
-
199
- private async writePayload(payload: MainMemoryPayload): Promise<void> {
200
- try {
201
- const serialized = JSON.stringify(payload);
202
- if (typeof this.ttl === "number") {
203
- await this.kv.setWithTtl(this.storageKey, serialized, Math.max(1, this.ttl));
204
- } else {
205
- await this.kv.set(this.storageKey, serialized);
206
- }
207
- } catch {
208
- await this.memoryFallback.updateMainMemory({ content: payload.main.content });
209
- }
210
- }
211
-
212
- async getMainMemory(): Promise<MainMemory> {
213
- const payload = await this.readPayload();
214
- return payload.main;
215
- }
216
-
217
- async updateMainMemory(input: { content: string }): Promise<MainMemory> {
218
- const payload = await this.readPayload();
219
- payload.main = { content: input.content.trim(), updatedAt: Date.now() };
220
- await this.writePayload(payload);
221
- return payload.main;
222
- }
223
- }
224
-
225
98
  export const createMemoryStore = (
226
99
  agentId: string,
227
100
  config?: MemoryConfig,
228
- options?: { workingDir?: string },
101
+ options?: { workingDir?: string; tenantId?: string },
229
102
  ): MemoryStore => {
230
- const provider = config?.provider ?? "local";
231
103
  const ttl = config?.ttl;
232
- const workingDir = options?.workingDir ?? process.cwd();
233
-
234
- if (provider === "local") {
235
- return new FileMainMemoryStore(workingDir, ttl);
236
- }
237
- if (provider === "memory") {
238
- return new InMemoryMemoryStore(ttl);
239
- }
240
-
241
- const kv = createRawKVStore(config);
242
- if (kv) {
243
- const storageKey = `poncho:${STORAGE_SCHEMA_VERSION}:${slugifyStorageComponent(agentId)}:memory:main`;
244
- return new KVBackedMemoryStore(kv, storageKey, ttl);
245
- }
246
104
  return new InMemoryMemoryStore(ttl);
247
105
  };
248
106
 
@@ -281,9 +139,12 @@ const buildRecallSnippet = (content: string, query: string, maxChars = 360): str
281
139
  };
282
140
 
283
141
  export const createMemoryTools = (
284
- store: MemoryStore,
142
+ store: MemoryStore | ((context: ToolContext) => MemoryStore),
285
143
  options?: { maxRecallConversations?: number },
286
144
  ): ToolDefinition[] => {
145
+ const resolveStore = typeof store === "function"
146
+ ? store
147
+ : () => store;
287
148
  const maxRecallConversations = Math.max(1, options?.maxRecallConversations ?? 20);
288
149
  return [
289
150
  defineTool({
@@ -294,8 +155,8 @@ export const createMemoryTools = (
294
155
  properties: {},
295
156
  additionalProperties: false,
296
157
  },
297
- handler: async () => {
298
- const memory = await store.getMainMemory();
158
+ handler: async (_input, context) => {
159
+ const memory = await resolveStore(context).getMainMemory();
299
160
  return { memory };
300
161
  },
301
162
  }),
@@ -316,12 +177,12 @@ export const createMemoryTools = (
316
177
  required: ["content"],
317
178
  additionalProperties: false,
318
179
  },
319
- handler: async (input) => {
180
+ handler: async (input, context) => {
320
181
  const content = typeof input.content === "string" ? input.content.trim() : "";
321
182
  if (!content) {
322
183
  throw new Error("content is required");
323
184
  }
324
- const memory = await store.updateMainMemory({ content });
185
+ const memory = await resolveStore(context).updateMainMemory({ content });
325
186
  return { ok: true, memory };
326
187
  },
327
188
  }),
@@ -349,13 +210,13 @@ export const createMemoryTools = (
349
210
  required: ["old_str", "new_str"],
350
211
  additionalProperties: false,
351
212
  },
352
- handler: async (input) => {
213
+ handler: async (input, context) => {
353
214
  const oldStr = typeof input.old_str === "string" ? input.old_str : "";
354
215
  const newStr = typeof input.new_str === "string" ? input.new_str : "";
355
216
  if (!oldStr) {
356
217
  throw new Error("old_str must not be empty.");
357
218
  }
358
- const current = await store.getMainMemory();
219
+ const current = await resolveStore(context).getMainMemory();
359
220
  const content = current.content;
360
221
  const first = content.indexOf(oldStr);
361
222
  if (first === -1) {
@@ -370,63 +231,153 @@ export const createMemoryTools = (
370
231
  );
371
232
  }
372
233
  const newContent = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
373
- const memory = await store.updateMainMemory({ content: newContent });
234
+ const memory = await resolveStore(context).updateMainMemory({ content: newContent });
374
235
  return { ok: true, memory };
375
236
  },
376
237
  }),
377
238
  defineTool({
378
239
  name: "conversation_recall",
379
240
  description:
380
- "Recall relevant snippets from previous conversations when prior context is likely important (for example: 'as we discussed', 'last time', or ambiguous references).",
241
+ "Recall past conversations. Three modes:\n" +
242
+ "- Search: provide 'query' to keyword-search past conversations for relevant snippets.\n" +
243
+ "- List: provide 'after' and/or 'before' dates to browse conversations by date range.\n" +
244
+ "- Fetch: provide 'conversationId' to load the full message history of a specific conversation.\n" +
245
+ "Modes can be combined (e.g. query + date range to search within a time window).",
381
246
  inputSchema: {
382
247
  type: "object",
383
248
  properties: {
384
249
  query: {
385
250
  type: "string",
386
- description: "Search query for past conversation recall",
251
+ description: "Keyword search query for past conversations",
387
252
  },
388
- limit: {
389
- type: "number",
390
- description: "Maximum snippets to return",
253
+ after: {
254
+ type: "string",
255
+ description:
256
+ "ISO 8601 date string. Only return conversations updated after this date (e.g. '2025-03-01').",
391
257
  },
392
- excludeConversationId: {
258
+ before: {
393
259
  type: "string",
394
- description: "Optional conversation id to exclude from recall",
260
+ description:
261
+ "ISO 8601 date string. Only return conversations updated before this date.",
262
+ },
263
+ conversationId: {
264
+ type: "string",
265
+ description: "Fetch the full message history of a specific conversation by ID",
266
+ },
267
+ lastN: {
268
+ type: "number",
269
+ description: "When fetching a conversation, only return the last N messages (max 50)",
270
+ },
271
+ limit: {
272
+ type: "number",
273
+ description: "Maximum results to return (default 20 for list, 3 for search, max 50)",
395
274
  },
396
275
  },
397
- required: ["query"],
398
276
  additionalProperties: false,
399
277
  },
400
278
  handler: async (input, context) => {
401
279
  const query = typeof input.query === "string" ? input.query.trim() : "";
402
- if (!query) {
403
- throw new Error("query is required");
280
+ const after = typeof input.after === "string" ? input.after : "";
281
+ const before = typeof input.before === "string" ? input.before : "";
282
+ const fetchId = typeof input.conversationId === "string" ? input.conversationId.trim() : "";
283
+ const hasDateFilter = after !== "" || before !== "";
284
+
285
+ // Determine mode
286
+ const mode = fetchId ? "fetch" : (query && !hasDateFilter) ? "search" : "list";
287
+
288
+ // --- Fetch mode: load full conversation by ID ---
289
+ if (mode === "fetch") {
290
+ const rawFetchFn = context.parameters.__conversationFetchFn;
291
+ if (typeof rawFetchFn !== "function") {
292
+ throw new Error("Conversation fetching is not available in this environment.");
293
+ }
294
+ const conversation = (await (rawFetchFn as (id: string) => Promise<unknown>)(fetchId)) as
295
+ | ConversationDetail
296
+ | undefined;
297
+ if (!conversation) {
298
+ throw new Error(`Conversation '${fetchId}' not found.`);
299
+ }
300
+ const lastN = typeof input.lastN === "number" ? Math.max(1, Math.min(50, input.lastN)) : undefined;
301
+ const messages = lastN
302
+ ? conversation.messages.slice(-lastN)
303
+ : conversation.messages.slice(-50);
304
+ return {
305
+ mode: "fetch",
306
+ conversationId: conversation.conversationId,
307
+ title: conversation.title,
308
+ createdAt: conversation.createdAt
309
+ ? new Date(conversation.createdAt).toISOString()
310
+ : undefined,
311
+ updatedAt: new Date(conversation.updatedAt).toISOString(),
312
+ messageCount: conversation.messages.length,
313
+ messages: messages.map((m) => ({
314
+ role: m.role,
315
+ content: m.content.slice(0, 4000),
316
+ })),
317
+ };
404
318
  }
319
+
320
+ // --- List mode: browse by date, optionally with keyword filtering ---
321
+ if (mode === "list") {
322
+ const rawListFn = context.parameters.__conversationListFn;
323
+ if (typeof rawListFn !== "function") {
324
+ throw new Error("Conversation listing is not available in this environment.");
325
+ }
326
+ const allConversations = (await (rawListFn as () => Promise<unknown>)()) as ConversationListItem[];
327
+ const limit = Math.max(1, Math.min(50, typeof input.limit === "number" ? input.limit : 20));
328
+ const afterMs = after ? new Date(after).getTime() : 0;
329
+ const beforeMs = before ? new Date(before).getTime() : Infinity;
330
+
331
+ let filtered = allConversations.filter((item) => {
332
+ const ts = item.updatedAt;
333
+ return ts >= afterMs && ts <= beforeMs;
334
+ });
335
+
336
+ // If query is also provided, score and rank by relevance
337
+ if (query) {
338
+ filtered = filtered
339
+ .map((item) => ({
340
+ ...item,
341
+ _score: scoreText(item.title, query),
342
+ }))
343
+ .filter((item) => item._score > 0)
344
+ .sort((a, b) => {
345
+ if (b._score === a._score) return b.updatedAt - a.updatedAt;
346
+ return b._score - a._score;
347
+ });
348
+ }
349
+
350
+ return {
351
+ mode: "list",
352
+ conversations: filtered.slice(0, limit).map((item) => ({
353
+ conversationId: item.conversationId,
354
+ title: item.title,
355
+ createdAt: item.createdAt
356
+ ? new Date(item.createdAt).toISOString()
357
+ : undefined,
358
+ updatedAt: new Date(item.updatedAt).toISOString(),
359
+ messageCount: item.messageCount,
360
+ })),
361
+ };
362
+ }
363
+
364
+ // --- Search mode: keyword search across conversation content ---
405
365
  const limit = Math.max(
406
366
  1,
407
- Math.min(5, typeof input.limit === "number" ? input.limit : 3),
367
+ Math.min(10, typeof input.limit === "number" ? input.limit : 3),
408
368
  );
409
- const excludeConversationId =
410
- typeof input.excludeConversationId === "string"
411
- ? input.excludeConversationId
412
- : "";
413
369
  const rawCorpus = context.parameters.__conversationRecallCorpus;
414
370
  const resolvedCorpus =
415
371
  typeof rawCorpus === "function" ? await (rawCorpus as () => Promise<unknown>)() : rawCorpus;
416
372
  const corpus = asRecallCorpus(resolvedCorpus).slice(0, maxRecallConversations);
417
373
  const results = corpus
418
- .filter((item) =>
419
- excludeConversationId ? item.conversationId !== excludeConversationId : true,
420
- )
421
374
  .map((item) => ({
422
375
  ...item,
423
376
  score: scoreText(`${item.title}\n${item.content}`, query),
424
377
  }))
425
378
  .filter((item) => item.score > 0)
426
379
  .sort((a, b) => {
427
- if (b.score === a.score) {
428
- return b.updatedAt - a.updatedAt;
429
- }
380
+ if (b.score === a.score) return b.updatedAt - a.updatedAt;
430
381
  return b.score - a.score;
431
382
  })
432
383
  .slice(0, limit)
@@ -436,7 +387,7 @@ export const createMemoryTools = (
436
387
  updatedAt: item.updatedAt,
437
388
  snippet: buildRecallSnippet(item.content, query),
438
389
  }));
439
- return { results };
390
+ return { mode: "search", results };
440
391
  },
441
392
  }),
442
393
  ];