@poncho-ai/harness 0.34.1 → 0.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.34.1",
3
+ "version": "0.35.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,11 +29,12 @@
29
29
  "ai": "^6.0.86",
30
30
  "cheerio": "^1.2.0",
31
31
  "jiti": "^2.6.1",
32
+ "jose": "^6.2.2",
32
33
  "mustache": "^4.2.0",
33
34
  "redis": "^5.10.0",
34
35
  "yaml": "^2.4.0",
35
36
  "zod": "^3.22.0",
36
- "@poncho-ai/sdk": "1.7.1"
37
+ "@poncho-ai/sdk": "1.8.0"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/mustache": "^4.2.6",
package/src/config.ts CHANGED
@@ -134,6 +134,12 @@ export interface PonchoConfig extends McpConfig {
134
134
  /** Cron expression controlling how often the reminder poll runs (local and serverless). Default: every 10 minutes. */
135
135
  pollSchedule?: string;
136
136
  };
137
+ /**
138
+ * Declare env var names that tenants can self-manage via the web UI or API.
139
+ * Key = env var name, value = human-readable label shown in the settings panel.
140
+ * Example: { LINEAR_API_KEY: "Linear API Key", STRIPE_KEY: "Stripe Secret Key" }
141
+ */
142
+ tenantSecrets?: Record<string, string>;
137
143
  /** Set to `false` to disable the built-in web UI (headless / API-only mode). */
138
144
  webUi?: false;
139
145
  /** Enable browser automation tools. Set `true` for defaults, or provide config. */
package/src/harness.ts CHANGED
@@ -21,10 +21,12 @@ import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, create
21
21
  import {
22
22
  createMemoryStore,
23
23
  createMemoryTools,
24
+ type MemoryConfig,
24
25
  type MemoryStore,
25
26
  } from "./memory.js";
26
27
  import { createTodoStore, createTodoTools, type TodoItem, type TodoStore } from "./todo-tools.js";
27
28
  import { createReminderStore, type ReminderStore } from "./reminder-store.js";
29
+ import { createSecretsStore, resolveEnv, type SecretsStore } from "./secrets-store.js";
28
30
  import { createReminderTools } from "./reminder-tools.js";
29
31
  import { LocalMcpBridge } from "./mcp.js";
30
32
  import { createModelProvider, getModelContextWindow, type ModelProviderFactory, type ProviderConfig } from "./model-factory.js";
@@ -639,8 +641,11 @@ export class AgentHarness {
639
641
  readonly uploadStore?: UploadStore;
640
642
  private skillContextWindow = "";
641
643
  private memoryStore?: MemoryStore;
644
+ private readonly tenantMemoryStores = new Map<string, MemoryStore>();
645
+ private memoryConfig?: MemoryConfig;
642
646
  private todoStore?: TodoStore;
643
647
  reminderStore?: ReminderStore;
648
+ secretsStore?: SecretsStore;
644
649
  private loadedConfig?: PonchoConfig;
645
650
  private loadedSkills: SkillMetadata[] = [];
646
651
  private skillFingerprint = "";
@@ -975,6 +980,31 @@ export class AgentHarness {
975
980
  return this.todoStore.get(conversationId);
976
981
  }
977
982
 
983
+ /**
984
+ * Get a memory store, optionally scoped to a tenant.
985
+ * Returns the default (agent-wide) store when tenantId is null/undefined.
986
+ */
987
+ private getMemoryStore(tenantId?: string): MemoryStore | undefined {
988
+ if (!this.memoryConfig?.enabled) return undefined;
989
+ if (!tenantId) return this.memoryStore;
990
+
991
+ let store = this.tenantMemoryStores.get(tenantId);
992
+ if (!store) {
993
+ const agentId = this.parsedAgent?.frontmatter.id ?? this.parsedAgent?.frontmatter.name ?? "unknown";
994
+ store = createMemoryStore(agentId, this.memoryConfig, {
995
+ workingDir: this.workingDir,
996
+ tenantId,
997
+ });
998
+ this.tenantMemoryStores.set(tenantId, store);
999
+ // Evict oldest entries if cache grows too large
1000
+ if (this.tenantMemoryStores.size > 100) {
1001
+ const oldest = this.tenantMemoryStores.keys().next().value;
1002
+ if (oldest) this.tenantMemoryStores.delete(oldest);
1003
+ }
1004
+ }
1005
+ return store;
1006
+ }
1007
+
978
1008
  private listActiveSkills(): string[] {
979
1009
  return [...this.activeSkillNames].sort();
980
1010
  }
@@ -1164,6 +1194,7 @@ export class AgentHarness {
1164
1194
  if (!this.mcpBridge) {
1165
1195
  return;
1166
1196
  }
1197
+
1167
1198
  const requestedPatterns = this.getRequestedMcpPatterns();
1168
1199
  this.dispatcher.unregisterMany(this.registeredMcpToolNames);
1169
1200
  this.registeredMcpToolNames.clear();
@@ -1354,6 +1385,7 @@ export class AgentHarness {
1354
1385
  this.registerSkillTools(skillMetadata);
1355
1386
  const agentId = this.parsedAgent.frontmatter.id ?? this.parsedAgent.frontmatter.name;
1356
1387
 
1388
+ this.memoryConfig = memoryConfig ?? undefined;
1357
1389
  if (memoryConfig?.enabled) {
1358
1390
  this.memoryStore = createMemoryStore(
1359
1391
  agentId,
@@ -1361,9 +1393,10 @@ export class AgentHarness {
1361
1393
  { workingDir: this.workingDir },
1362
1394
  );
1363
1395
  this.dispatcher.registerMany(
1364
- createMemoryTools(this.memoryStore, {
1365
- maxRecallConversations: memoryConfig.maxRecallConversations,
1366
- }),
1396
+ createMemoryTools(
1397
+ (ctx) => this.getMemoryStore(ctx.tenantId) ?? this.memoryStore!,
1398
+ { maxRecallConversations: memoryConfig.maxRecallConversations },
1399
+ ),
1367
1400
  );
1368
1401
  }
1369
1402
 
@@ -1393,6 +1426,16 @@ export class AgentHarness {
1393
1426
  });
1394
1427
  }
1395
1428
 
1429
+ // Secrets store for per-tenant env var overrides
1430
+ const authTokenEnv = config?.auth?.tokenEnv ?? "PONCHO_AUTH_TOKEN";
1431
+ const authToken = process.env[authTokenEnv];
1432
+ if (authToken) {
1433
+ this.secretsStore = createSecretsStore(agentId, authToken, stateConfig, { workingDir: this.workingDir });
1434
+ bridge.setEnvResolver(async (tenantId, envName) => {
1435
+ return resolveEnv(this.secretsStore, tenantId, envName);
1436
+ });
1437
+ }
1438
+
1396
1439
  await bridge.startLocalServers();
1397
1440
  await bridge.discoverTools();
1398
1441
  await this.refreshMcpTools("initialize");
@@ -1609,6 +1652,7 @@ export class AgentHarness {
1609
1652
  attributes: {
1610
1653
  "gen_ai.operation.name": "invoke_agent",
1611
1654
  ...(input.conversationId ? { "gen_ai.conversation.id": input.conversationId } : {}),
1655
+ ...(input.tenantId ? { "tenant.id": input.tenantId } : {}),
1612
1656
  },
1613
1657
  });
1614
1658
 
@@ -1662,8 +1706,9 @@ export class AgentHarness {
1662
1706
  await this.initialize();
1663
1707
  }
1664
1708
  // Start memory + todo fetches early so they overlap with refresh I/O
1665
- const memoryPromise = this.memoryStore
1666
- ? this.memoryStore.getMainMemory()
1709
+ const activeMemoryStore = this.getMemoryStore(input.tenantId);
1710
+ const memoryPromise = activeMemoryStore
1711
+ ? activeMemoryStore.getMainMemory()
1667
1712
  : undefined;
1668
1713
  const todosPromise = this.todoStore
1669
1714
  ? this.todoStore.get(input.conversationId ?? "__default__")
@@ -1671,6 +1716,16 @@ export class AgentHarness {
1671
1716
  await this.refreshAgentIfChanged();
1672
1717
  await this.refreshSkillsIfChanged();
1673
1718
 
1719
+ // Deferred MCP discovery: servers that couldn't discover at startup because the
1720
+ // env var was missing (tenant secrets provide the token instead).
1721
+ if (input.tenantId && this.mcpBridge?.hasDeferredServers()) {
1722
+ const newTools = await this.mcpBridge.discoverAndLoadDeferred(input.tenantId);
1723
+ for (const tool of newTools) {
1724
+ this.dispatcher.register(tool);
1725
+ this.registeredMcpToolNames.add(tool.name);
1726
+ }
1727
+ }
1728
+
1674
1729
  let agent = this.parsedAgent as ParsedAgent;
1675
1730
  const runId = `run_${randomUUID()}`;
1676
1731
  const start = now();
@@ -2593,6 +2648,7 @@ ${boundedMainMemory.trim()}`
2593
2648
  parameters: input.parameters ?? {},
2594
2649
  abortSignal: input.abortSignal,
2595
2650
  conversationId: input.conversationId,
2651
+ tenantId: input.tenantId,
2596
2652
  };
2597
2653
 
2598
2654
  const toolResultsForModel: Array<{
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ export * from "./reminder-tools.js";
17
17
  export * from "./state.js";
18
18
  export * from "./upload-store.js";
19
19
  export * from "./telemetry.js";
20
+ export * from "./secrets-store.js";
21
+ export * from "./tenant-token.js";
20
22
  export * from "./tool-dispatcher.js";
21
23
  export * from "./subagent-manager.js";
22
24
  export * from "./subagent-tools.js";
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,6 +1,6 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { dirname, resolve } from "node:path";
3
- import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
3
+ import { defineTool, type ToolContext, type ToolDefinition } from "@poncho-ai/sdk";
4
4
  import type { StateProviderName } from "./state.js";
5
5
  import {
6
6
  ensureAgentIdentity,
@@ -102,14 +102,16 @@ class InMemoryMemoryStore implements MemoryStore {
102
102
  class FileMainMemoryStore implements MemoryStore {
103
103
  private readonly workingDir: string;
104
104
  private filePath = "";
105
+ private readonly customRelPath?: string;
105
106
  private readonly ttlMs?: number;
106
107
  private loaded = false;
107
108
  private writing = Promise.resolve();
108
109
  private mainMemory: MainMemory = { ...DEFAULT_MAIN_MEMORY };
109
110
 
110
- constructor(workingDir: string, ttlSeconds?: number) {
111
+ constructor(workingDir: string, ttlSeconds?: number, customRelPath?: string) {
111
112
  this.workingDir = workingDir;
112
113
  this.ttlMs = typeof ttlSeconds === "number" ? ttlSeconds * 1000 : undefined;
114
+ this.customRelPath = customRelPath;
113
115
  }
114
116
 
115
117
  private async ensureFilePath(): Promise<void> {
@@ -117,7 +119,10 @@ class FileMainMemoryStore implements MemoryStore {
117
119
  return;
118
120
  }
119
121
  const identity = await ensureAgentIdentity(this.workingDir);
120
- this.filePath = resolve(getAgentStoreDirectory(identity), LOCAL_MEMORY_FILE);
122
+ this.filePath = resolve(
123
+ getAgentStoreDirectory(identity),
124
+ this.customRelPath ?? LOCAL_MEMORY_FILE,
125
+ );
121
126
  }
122
127
 
123
128
  private isExpired(updatedAt: number): boolean {
@@ -225,13 +230,22 @@ class KVBackedMemoryStore implements MemoryStore {
225
230
  export const createMemoryStore = (
226
231
  agentId: string,
227
232
  config?: MemoryConfig,
228
- options?: { workingDir?: string },
233
+ options?: { workingDir?: string; tenantId?: string },
229
234
  ): MemoryStore => {
230
235
  const provider = config?.provider ?? "local";
231
236
  const ttl = config?.ttl;
232
237
  const workingDir = options?.workingDir ?? process.cwd();
238
+ const tenantId = options?.tenantId;
233
239
 
234
240
  if (provider === "local") {
241
+ if (tenantId) {
242
+ // Tenant-scoped memory: store under tenants/{tenantId}/memory.json
243
+ return new FileMainMemoryStore(
244
+ workingDir,
245
+ ttl,
246
+ `tenants/${slugifyStorageComponent(tenantId)}/${LOCAL_MEMORY_FILE}`,
247
+ );
248
+ }
235
249
  return new FileMainMemoryStore(workingDir, ttl);
236
250
  }
237
251
  if (provider === "memory") {
@@ -240,7 +254,10 @@ export const createMemoryStore = (
240
254
 
241
255
  const kv = createRawKVStore(config);
242
256
  if (kv) {
243
- const storageKey = `poncho:${STORAGE_SCHEMA_VERSION}:${slugifyStorageComponent(agentId)}:memory:main`;
257
+ const base = `poncho:${STORAGE_SCHEMA_VERSION}:${slugifyStorageComponent(agentId)}`;
258
+ const storageKey = tenantId
259
+ ? `${base}:t:${slugifyStorageComponent(tenantId)}:memory:main`
260
+ : `${base}:memory:main`;
244
261
  return new KVBackedMemoryStore(kv, storageKey, ttl);
245
262
  }
246
263
  return new InMemoryMemoryStore(ttl);
@@ -281,9 +298,12 @@ const buildRecallSnippet = (content: string, query: string, maxChars = 360): str
281
298
  };
282
299
 
283
300
  export const createMemoryTools = (
284
- store: MemoryStore,
301
+ store: MemoryStore | ((context: ToolContext) => MemoryStore),
285
302
  options?: { maxRecallConversations?: number },
286
303
  ): ToolDefinition[] => {
304
+ const resolveStore = typeof store === "function"
305
+ ? store
306
+ : () => store;
287
307
  const maxRecallConversations = Math.max(1, options?.maxRecallConversations ?? 20);
288
308
  return [
289
309
  defineTool({
@@ -294,8 +314,8 @@ export const createMemoryTools = (
294
314
  properties: {},
295
315
  additionalProperties: false,
296
316
  },
297
- handler: async () => {
298
- const memory = await store.getMainMemory();
317
+ handler: async (_input, context) => {
318
+ const memory = await resolveStore(context).getMainMemory();
299
319
  return { memory };
300
320
  },
301
321
  }),
@@ -316,12 +336,12 @@ export const createMemoryTools = (
316
336
  required: ["content"],
317
337
  additionalProperties: false,
318
338
  },
319
- handler: async (input) => {
339
+ handler: async (input, context) => {
320
340
  const content = typeof input.content === "string" ? input.content.trim() : "";
321
341
  if (!content) {
322
342
  throw new Error("content is required");
323
343
  }
324
- const memory = await store.updateMainMemory({ content });
344
+ const memory = await resolveStore(context).updateMainMemory({ content });
325
345
  return { ok: true, memory };
326
346
  },
327
347
  }),
@@ -349,13 +369,13 @@ export const createMemoryTools = (
349
369
  required: ["old_str", "new_str"],
350
370
  additionalProperties: false,
351
371
  },
352
- handler: async (input) => {
372
+ handler: async (input, context) => {
353
373
  const oldStr = typeof input.old_str === "string" ? input.old_str : "";
354
374
  const newStr = typeof input.new_str === "string" ? input.new_str : "";
355
375
  if (!oldStr) {
356
376
  throw new Error("old_str must not be empty.");
357
377
  }
358
- const current = await store.getMainMemory();
378
+ const current = await resolveStore(context).getMainMemory();
359
379
  const content = current.content;
360
380
  const first = content.indexOf(oldStr);
361
381
  if (first === -1) {
@@ -370,7 +390,7 @@ export const createMemoryTools = (
370
390
  );
371
391
  }
372
392
  const newContent = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
373
- const memory = await store.updateMainMemory({ content: newContent });
393
+ const memory = await resolveStore(context).updateMainMemory({ content: newContent });
374
394
  return { ok: true, memory };
375
395
  },
376
396
  }),
@@ -24,6 +24,7 @@ export interface Reminder {
24
24
  createdAt: number;
25
25
  conversationId: string;
26
26
  ownerId?: string;
27
+ tenantId?: string | null;
27
28
  }
28
29
 
29
30
  export interface ReminderStore {
@@ -34,6 +35,7 @@ export interface ReminderStore {
34
35
  timezone?: string;
35
36
  conversationId: string;
36
37
  ownerId?: string;
38
+ tenantId?: string | null;
37
39
  }): Promise<Reminder>;
38
40
  cancel(id: string): Promise<Reminder>;
39
41
  delete(id: string): Promise<void>;
@@ -97,6 +99,7 @@ class InMemoryReminderStore implements ReminderStore {
97
99
  timezone?: string;
98
100
  conversationId: string;
99
101
  ownerId?: string;
102
+ tenantId?: string | null;
100
103
  }): Promise<Reminder> {
101
104
  const reminder: Reminder = {
102
105
  id: generateId(),
@@ -107,6 +110,7 @@ class InMemoryReminderStore implements ReminderStore {
107
110
  createdAt: Date.now(),
108
111
  conversationId: input.conversationId,
109
112
  ownerId: input.ownerId,
113
+ tenantId: input.tenantId,
110
114
  };
111
115
  this.reminders = pruneStale(this.reminders);
112
116
  this.reminders.push(reminder);
@@ -175,6 +179,7 @@ class FileReminderStore implements ReminderStore {
175
179
  timezone?: string;
176
180
  conversationId: string;
177
181
  ownerId?: string;
182
+ tenantId?: string | null;
178
183
  }): Promise<Reminder> {
179
184
  const reminder: Reminder = {
180
185
  id: generateId(),
@@ -185,6 +190,7 @@ class FileReminderStore implements ReminderStore {
185
190
  createdAt: Date.now(),
186
191
  conversationId: input.conversationId,
187
192
  ownerId: input.ownerId,
193
+ tenantId: input.tenantId,
188
194
  };
189
195
  let reminders = await this.readAll();
190
196
  reminders = pruneStale(reminders);
@@ -84,6 +84,7 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
84
84
  scheduledAt,
85
85
  timezone,
86
86
  conversationId,
87
+ tenantId: context.tenantId,
87
88
  });
88
89
 
89
90
  return {
@@ -116,8 +117,12 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
116
117
  },
117
118
  additionalProperties: false,
118
119
  },
119
- handler: async (input) => {
120
+ handler: async (input, context) => {
120
121
  let reminders = await store.list();
122
+ // Tenant-scoped: only show reminders belonging to the current tenant
123
+ if (context.tenantId) {
124
+ reminders = reminders.filter((r) => r.tenantId === context.tenantId);
125
+ }
121
126
  const status = typeof input.status === "string" ? input.status : undefined;
122
127
  if (status && VALID_STATUSES.includes(status as ReminderStatus)) {
123
128
  reminders = reminders.filter((r) => r.status === status);
@@ -150,9 +155,17 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
150
155
  required: ["id"],
151
156
  additionalProperties: false,
152
157
  },
153
- handler: async (input) => {
158
+ handler: async (input, context) => {
154
159
  const id = typeof input.id === "string" ? input.id.trim() : "";
155
160
  if (!id) throw new Error("id is required");
161
+ // Validate tenant ownership before cancelling
162
+ if (context.tenantId) {
163
+ const all = await store.list();
164
+ const target = all.find((r) => r.id === id);
165
+ if (target && target.tenantId !== context.tenantId) {
166
+ throw new Error("Reminder not found");
167
+ }
168
+ }
156
169
  const cancelled = await store.cancel(id);
157
170
  return {
158
171
  ok: true,