@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.
- package/.turbo/turbo-build.log +12 -11
- package/.turbo/turbo-lint.log +6 -0
- package/.turbo/turbo-test.log +27100 -0
- package/CHANGELOG.md +37 -0
- package/dist/chunk-MCKGQKYU.js +15 -0
- package/dist/dist-3KMQR4IO.js +27092 -0
- package/dist/index.d.ts +553 -29
- package/dist/index.js +3132 -1902
- package/dist/isolate-5MISBSUK.js +733 -0
- package/dist/isolate-5R6762YA.js +605 -0
- package/dist/isolate-KUZ5NOPG.js +727 -0
- package/dist/isolate-LOL3T7RA.js +729 -0
- package/dist/isolate-N22X4TCE.js +740 -0
- package/dist/isolate-T7WXM7IL.js +1490 -0
- package/dist/isolate-TCWTUVG4.js +1532 -0
- package/dist/isolate-WFOLANOB.js +768 -0
- package/package.json +24 -4
- package/scripts/migrate-to-engine.mjs +556 -0
- package/src/config.ts +112 -1
- package/src/harness.ts +282 -91
- package/src/index.ts +7 -0
- package/src/isolate/bindings.ts +206 -0
- package/src/isolate/bundler.ts +179 -0
- package/src/isolate/index.ts +10 -0
- package/src/isolate/polyfills.ts +796 -0
- package/src/isolate/run-code-tool.ts +220 -0
- package/src/isolate/runtime.ts +286 -0
- package/src/isolate/type-stubs.ts +196 -0
- package/src/mcp.ts +140 -9
- package/src/memory.ts +142 -191
- package/src/reminder-store.ts +7 -235
- package/src/reminder-tools.ts +15 -2
- package/src/secrets-store.ts +163 -0
- package/src/state.ts +22 -1291
- package/src/storage/engine.ts +106 -0
- package/src/storage/index.ts +59 -0
- package/src/storage/memory-engine.ts +588 -0
- package/src/storage/postgres-engine.ts +139 -0
- package/src/storage/schema.ts +145 -0
- package/src/storage/sql-dialect.ts +963 -0
- package/src/storage/sqlite-engine.ts +99 -0
- package/src/storage/store-adapters.ts +100 -0
- package/src/subagent-manager.ts +1 -0
- package/src/subagent-tools.ts +1 -0
- package/src/telemetry.ts +5 -1
- package/src/tenant-token.ts +42 -0
- package/src/todo-tools.ts +1 -136
- package/src/upload-store.ts +1 -0
- package/src/vfs/bash-manager.ts +120 -0
- package/src/vfs/bash-tool.ts +59 -0
- package/src/vfs/create-bash-fs.ts +32 -0
- package/src/vfs/edit-file-tool.ts +72 -0
- package/src/vfs/index.ts +5 -0
- package/src/vfs/poncho-fs-adapter.ts +267 -0
- package/src/vfs/protected-fs.ts +177 -0
- package/src/vfs/read-file-tool.ts +103 -0
- package/src/vfs/write-file-tool.ts +49 -0
- package/test/harness.test.ts +30 -36
- package/test/isolate-vfs.test.ts +453 -0
- package/test/isolate.test.ts +252 -0
- package/test/state.test.ts +4 -27
- package/test/storage-engine.test.ts +250 -0
- package/test/vfs.test.ts +242 -0
- 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.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
251
|
+
description: "Keyword search query for past conversations",
|
|
387
252
|
},
|
|
388
|
-
|
|
389
|
-
type: "
|
|
390
|
-
description:
|
|
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
|
-
|
|
258
|
+
before: {
|
|
393
259
|
type: "string",
|
|
394
|
-
description:
|
|
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
|
-
|
|
403
|
-
|
|
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(
|
|
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
|
];
|