@poncho-ai/harness 0.24.0 → 0.26.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.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.ts CHANGED
@@ -42,6 +42,10 @@ export type BuiltInToolToggles = {
42
42
  edit_file?: boolean;
43
43
  delete_file?: boolean;
44
44
  delete_directory?: boolean;
45
+ todo_list?: boolean;
46
+ todo_add?: boolean;
47
+ todo_update?: boolean;
48
+ todo_remove?: boolean;
45
49
  };
46
50
 
47
51
  export interface MessagingChannelConfig {
package/src/harness.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
2
4
  import type {
3
5
  AgentEvent,
4
6
  ContentPart,
@@ -13,14 +15,15 @@ import type {
13
15
  import { getTextContent } from "@poncho-ai/sdk";
14
16
  import type { UploadStore } from "./upload-store.js";
15
17
  import { PONCHO_UPLOAD_SCHEME, deriveUploadKey } from "./upload-store.js";
16
- import { parseAgentFile, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
17
- import { loadPonchoConfig, resolveMemoryConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
18
+ import { parseAgentFile, parseAgentMarkdown, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
19
+ import { loadPonchoConfig, resolveMemoryConfig, resolveStateConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
18
20
  import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createWriteTool, ponchoDocsTool } from "./default-tools.js";
19
21
  import {
20
22
  createMemoryStore,
21
23
  createMemoryTools,
22
24
  type MemoryStore,
23
25
  } from "./memory.js";
26
+ import { createTodoStore, createTodoTools, type TodoItem, type TodoStore } from "./todo-tools.js";
24
27
  import { LocalMcpBridge } from "./mcp.js";
25
28
  import { createModelProvider, getModelContextWindow, type ModelProviderFactory, type ProviderConfig } from "./model-factory.js";
26
29
  import { buildSkillContextWindow, loadSkillMetadata } from "./skill-context.js";
@@ -548,6 +551,7 @@ export class AgentHarness {
548
551
  readonly uploadStore?: UploadStore;
549
552
  private skillContextWindow = "";
550
553
  private memoryStore?: MemoryStore;
554
+ private todoStore?: TodoStore;
551
555
  private loadedConfig?: PonchoConfig;
552
556
  private loadedSkills: SkillMetadata[] = [];
553
557
  private skillFingerprint = "";
@@ -563,6 +567,7 @@ export class AgentHarness {
563
567
  };
564
568
 
565
569
  private parsedAgent?: ParsedAgent;
570
+ private agentFileFingerprint = "";
566
571
  private mcpBridge?: LocalMcpBridge;
567
572
  private subagentManager?: SubagentManager;
568
573
 
@@ -675,6 +680,11 @@ export class AgentHarness {
675
680
  return this.parsedAgent?.frontmatter;
676
681
  }
677
682
 
683
+ async getTodos(conversationId: string): Promise<TodoItem[]> {
684
+ if (!this.todoStore) return [];
685
+ return this.todoStore.get(conversationId);
686
+ }
687
+
678
688
  private listActiveSkills(): string[] {
679
689
  return [...this.activeSkillNames].sort();
680
690
  }
@@ -696,20 +706,17 @@ export class AgentHarness {
696
706
  }
697
707
 
698
708
  private getRequestedMcpPatterns(): string[] {
699
- const skillPatterns = new Set<string>();
709
+ const patterns = new Set<string>(this.getAgentMcpIntent());
700
710
  for (const skillName of this.activeSkillNames) {
701
711
  const skill = this.loadedSkills.find((entry) => entry.name === skillName);
702
712
  if (!skill) {
703
713
  continue;
704
714
  }
705
715
  for (const pattern of skill.allowedTools.mcp) {
706
- skillPatterns.add(pattern);
716
+ patterns.add(pattern);
707
717
  }
708
718
  }
709
- if (skillPatterns.size > 0) {
710
- return [...skillPatterns];
711
- }
712
- return this.getAgentMcpIntent();
719
+ return [...patterns];
713
720
  }
714
721
 
715
722
  private getRequestedScriptPatterns(): string[] {
@@ -727,20 +734,17 @@ export class AgentHarness {
727
734
  }
728
735
 
729
736
  private getRequestedMcpApprovalPatterns(): string[] {
730
- const skillPatterns = new Set<string>();
737
+ const patterns = new Set<string>(this.getAgentMcpApprovalPatterns());
731
738
  for (const skillName of this.activeSkillNames) {
732
739
  const skill = this.loadedSkills.find((entry) => entry.name === skillName);
733
740
  if (!skill) {
734
741
  continue;
735
742
  }
736
743
  for (const pattern of skill.approvalRequired.mcp) {
737
- skillPatterns.add(pattern);
744
+ patterns.add(pattern);
738
745
  }
739
746
  }
740
- if (skillPatterns.size > 0) {
741
- return [...skillPatterns];
742
- }
743
- return this.getAgentMcpApprovalPatterns();
747
+ return [...patterns];
744
748
  }
745
749
 
746
750
  private getRequestedScriptApprovalPatterns(): string[] {
@@ -891,13 +895,59 @@ export class AgentHarness {
891
895
 
892
896
  private static readonly SKILL_REFRESH_DEBOUNCE_MS = 3000;
893
897
 
894
- private async refreshSkillsIfChanged(): Promise<void> {
898
+ /**
899
+ * Re-read AGENT.md and update the parsed agent when the file has changed
900
+ * on disk. Returns `true` when the agent was actually re-parsed.
901
+ *
902
+ * Preserves the agent identity (id) across reloads so conversation
903
+ * continuity isn't broken.
904
+ */
905
+ private async refreshAgentIfChanged(): Promise<boolean> {
895
906
  if (this.environment !== "development") {
896
- return;
907
+ return false;
897
908
  }
898
- const elapsed = Date.now() - this.lastSkillRefreshAt;
899
- if (this.lastSkillRefreshAt > 0 && elapsed < AgentHarness.SKILL_REFRESH_DEBOUNCE_MS) {
900
- return;
909
+ try {
910
+ const agentFilePath = resolve(this.workingDir, "AGENT.md");
911
+ const rawContent = await readFile(agentFilePath, "utf8");
912
+ if (rawContent === this.agentFileFingerprint) {
913
+ return false;
914
+ }
915
+ const parsed = parseAgentMarkdown(rawContent);
916
+ // Preserve the resolved agent identity so existing conversations
917
+ // keep working after an AGENT.md edit.
918
+ if (!parsed.frontmatter.id && this.parsedAgent?.frontmatter.id) {
919
+ parsed.frontmatter.id = this.parsedAgent.frontmatter.id;
920
+ }
921
+ this.parsedAgent = parsed;
922
+ this.agentFileFingerprint = rawContent;
923
+ return true;
924
+ } catch (error) {
925
+ console.warn(
926
+ `[poncho][agent] Failed to refresh AGENT.md in development mode: ${
927
+ error instanceof Error ? error.message : String(error)
928
+ }`,
929
+ );
930
+ return false;
931
+ }
932
+ }
933
+
934
+ /**
935
+ * Re-scan skill directories and update metadata, tools, and context window
936
+ * when skills have changed on disk. Returns `true` when the skill set was
937
+ * actually updated.
938
+ *
939
+ * @param force - bypass the time-based debounce (used for mid-run refreshes
940
+ * after the agent may have written new skill files).
941
+ */
942
+ private async refreshSkillsIfChanged(force = false): Promise<boolean> {
943
+ if (this.environment !== "development") {
944
+ return false;
945
+ }
946
+ if (!force) {
947
+ const elapsed = Date.now() - this.lastSkillRefreshAt;
948
+ if (this.lastSkillRefreshAt > 0 && elapsed < AgentHarness.SKILL_REFRESH_DEBOUNCE_MS) {
949
+ return false;
950
+ }
901
951
  }
902
952
  this.lastSkillRefreshAt = Date.now();
903
953
  try {
@@ -907,27 +957,44 @@ export class AgentHarness {
907
957
  );
908
958
  const nextFingerprint = this.buildSkillFingerprint(latestSkills);
909
959
  if (nextFingerprint === this.skillFingerprint) {
910
- return;
960
+ return false;
911
961
  }
912
962
  this.loadedSkills = latestSkills;
913
963
  this.skillContextWindow = buildSkillContextWindow(latestSkills);
914
964
  this.skillFingerprint = nextFingerprint;
915
965
  this.registerSkillTools(latestSkills);
916
- // Skill metadata or layout changed; force re-activation to avoid stale
917
- // instructions/tooling when files are renamed or moved during development.
918
- this.activeSkillNames.clear();
966
+ // Prune active skills that no longer exist in the updated metadata,
967
+ // but preserve ones that were merely updated (same name). This keeps
968
+ // MCP tools from active skills registered when their allowed-tools
969
+ // list changes, instead of forcing the agent to re-activate.
970
+ const latestSkillNames = new Set(latestSkills.map(s => s.name));
971
+ for (const name of this.activeSkillNames) {
972
+ if (!latestSkillNames.has(name)) {
973
+ this.activeSkillNames.delete(name);
974
+ }
975
+ }
976
+ // Re-discover MCP server catalogs so newly advertised tools are visible,
977
+ // then refresh the registered tool set with updated skill patterns.
978
+ if (this.mcpBridge) {
979
+ await this.mcpBridge.discoverTools();
980
+ }
919
981
  await this.refreshMcpTools("skills:changed");
982
+ return true;
920
983
  } catch (error) {
921
984
  console.warn(
922
985
  `[poncho][skills] Failed to refresh skills in development mode: ${
923
986
  error instanceof Error ? error.message : String(error)
924
987
  }`,
925
988
  );
989
+ return false;
926
990
  }
927
991
  }
928
992
 
929
993
  async initialize(): Promise<void> {
930
- this.parsedAgent = await parseAgentFile(this.workingDir);
994
+ const agentFilePath = resolve(this.workingDir, "AGENT.md");
995
+ const agentRawContent = await readFile(agentFilePath, "utf8");
996
+ this.parsedAgent = parseAgentMarkdown(agentRawContent);
997
+ this.agentFileFingerprint = agentRawContent;
931
998
  const identity = await ensureAgentIdentity(this.workingDir);
932
999
  if (!this.parsedAgent.frontmatter.id) {
933
1000
  this.parsedAgent.frontmatter.id = identity.id;
@@ -948,8 +1015,9 @@ export class AgentHarness {
948
1015
  this.skillContextWindow = buildSkillContextWindow(skillMetadata);
949
1016
  this.skillFingerprint = this.buildSkillFingerprint(skillMetadata);
950
1017
  this.registerSkillTools(skillMetadata);
1018
+ const agentId = this.parsedAgent.frontmatter.id ?? this.parsedAgent.frontmatter.name;
1019
+
951
1020
  if (memoryConfig?.enabled) {
952
- const agentId = this.parsedAgent.frontmatter.id ?? this.parsedAgent.frontmatter.name;
953
1021
  this.memoryStore = createMemoryStore(
954
1022
  agentId,
955
1023
  memoryConfig,
@@ -962,6 +1030,14 @@ export class AgentHarness {
962
1030
  );
963
1031
  }
964
1032
 
1033
+ const stateConfig = resolveStateConfig(config);
1034
+ this.todoStore = createTodoStore(agentId, stateConfig, { workingDir: this.workingDir });
1035
+ for (const tool of createTodoTools(this.todoStore)) {
1036
+ if (this.isToolEnabled(tool.name)) {
1037
+ this.registerIfMissing(tool);
1038
+ }
1039
+ }
1040
+
965
1041
  if (config?.browser) {
966
1042
  await this.initBrowserTools(config)
967
1043
  .catch((e) => {
@@ -1286,10 +1362,11 @@ export class AgentHarness {
1286
1362
  if (!this.parsedAgent) {
1287
1363
  await this.initialize();
1288
1364
  }
1289
- // Start memory fetch early so it overlaps with skill refresh I/O
1365
+ // Start memory fetch early so it overlaps with refresh I/O
1290
1366
  const memoryPromise = this.memoryStore
1291
1367
  ? this.memoryStore.getMainMemory()
1292
1368
  : undefined;
1369
+ await this.refreshAgentIfChanged();
1293
1370
  await this.refreshSkillsIfChanged();
1294
1371
 
1295
1372
  // Track which conversation/owner this run belongs to so browser & subagent tools resolve correctly
@@ -1299,7 +1376,7 @@ export class AgentHarness {
1299
1376
  this._currentRunOwnerId = ownerParam;
1300
1377
  }
1301
1378
 
1302
- const agent = this.parsedAgent as ParsedAgent;
1379
+ let agent = this.parsedAgent as ParsedAgent;
1303
1380
  const runId = `run_${randomUUID()}`;
1304
1381
  const start = now();
1305
1382
  const maxSteps = agent.frontmatter.limits?.maxSteps ?? 50;
@@ -1315,15 +1392,16 @@ export class AgentHarness {
1315
1392
  const inputMessageCount = messages.length;
1316
1393
  const events: AgentEvent[] = [];
1317
1394
 
1318
- const systemPrompt = renderAgentPrompt(agent, {
1319
- parameters: input.parameters,
1320
- runtime: {
1321
- runId,
1322
- agentId: agent.frontmatter.id ?? agent.frontmatter.name,
1323
- environment: this.environment,
1324
- workingDir: this.workingDir,
1325
- },
1326
- });
1395
+ const renderCurrentAgentPrompt = (): string =>
1396
+ renderAgentPrompt(this.parsedAgent!, {
1397
+ parameters: input.parameters,
1398
+ runtime: {
1399
+ runId,
1400
+ agentId: this.parsedAgent!.frontmatter.id ?? this.parsedAgent!.frontmatter.name,
1401
+ environment: this.environment,
1402
+ workingDir: this.workingDir,
1403
+ },
1404
+ });
1327
1405
  const developmentContext =
1328
1406
  this.environment === "development" ? `\n\n${DEVELOPMENT_MODE_CONTEXT}` : "";
1329
1407
  const browserContext = this._browserSession
@@ -1346,9 +1424,6 @@ Browser sessions (cookies, localStorage, login state) are automatically saved an
1346
1424
  ### Tabs and resources
1347
1425
  Each conversation gets its own browser tab sharing a single browser instance. Call \`browser_close\` when done to free the tab. If you don't close it, the tab stays open and the user can continue interacting with it.`
1348
1426
  : "";
1349
- const promptWithSkills = this.skillContextWindow
1350
- ? `${systemPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}`
1351
- : `${systemPrompt}${developmentContext}${browserContext}`;
1352
1427
  const mainMemory = await memoryPromise;
1353
1428
  const boundedMainMemory =
1354
1429
  mainMemory && mainMemory.content.length > 4000
@@ -1361,7 +1436,13 @@ Each conversation gets its own browser tab sharing a single browser instance. Ca
1361
1436
 
1362
1437
  ${boundedMainMemory.trim()}`
1363
1438
  : "";
1364
- const integrityPrompt = `${promptWithSkills}${memoryContext}
1439
+
1440
+ const buildSystemPrompt = (): string => {
1441
+ const agentPrompt = renderCurrentAgentPrompt();
1442
+ const promptWithSkills = this.skillContextWindow
1443
+ ? `${agentPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}`
1444
+ : `${agentPrompt}${developmentContext}${browserContext}`;
1445
+ return `${promptWithSkills}${memoryContext}
1365
1446
 
1366
1447
  ## Execution Integrity
1367
1448
 
@@ -1369,6 +1450,9 @@ ${boundedMainMemory.trim()}`
1369
1450
  - Do not fabricate "Tool Used" or "Tool Result" logs as plain text.
1370
1451
  - Never output faux execution transcripts, markdown tool logs, or "Tool Used/Result" sections.
1371
1452
  - If no suitable tool is available, explicitly say that and ask for guidance.`;
1453
+ };
1454
+ let integrityPrompt = buildSystemPrompt();
1455
+ let lastPromptFingerprint = `${this.agentFileFingerprint}\n${this.skillFingerprint}`;
1372
1456
 
1373
1457
  const pushEvent = (event: AgentEvent): AgentEvent => {
1374
1458
  events.push(event);
@@ -2260,6 +2344,22 @@ ${boundedMainMemory.trim()}`
2260
2344
  metadata: toolMsgMeta as Message["metadata"],
2261
2345
  });
2262
2346
 
2347
+ // In development, re-read AGENT.md and re-scan skills after tool
2348
+ // execution so changes are available on the next step without
2349
+ // requiring a server restart.
2350
+ if (this.environment === "development") {
2351
+ const agentChanged = await this.refreshAgentIfChanged();
2352
+ const skillsChanged = await this.refreshSkillsIfChanged(true);
2353
+ if (agentChanged || skillsChanged) {
2354
+ agent = this.parsedAgent as ParsedAgent;
2355
+ const currentFingerprint = `${this.agentFileFingerprint}\n${this.skillFingerprint}`;
2356
+ if (currentFingerprint !== lastPromptFingerprint) {
2357
+ integrityPrompt = buildSystemPrompt();
2358
+ lastPromptFingerprint = currentFingerprint;
2359
+ }
2360
+ }
2361
+ }
2362
+
2263
2363
  yield pushEvent({
2264
2364
  type: "step:completed",
2265
2365
  step,
@@ -0,0 +1,206 @@
1
+ import type { StateConfig } from "./state.js";
2
+
3
+ /**
4
+ * Minimal raw key-value interface shared by MemoryStore, TodoStore, and any
5
+ * future stores that sit on top of the same user-configured backend.
6
+ */
7
+ export interface RawKVStore {
8
+ get(key: string): Promise<string | undefined>;
9
+ set(key: string, value: string): Promise<void>;
10
+ setWithTtl(key: string, value: string, ttlSeconds: number): Promise<void>;
11
+ }
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Upstash
15
+ // ---------------------------------------------------------------------------
16
+
17
+ class UpstashKVStore implements RawKVStore {
18
+ private readonly baseUrl: string;
19
+ private readonly token: string;
20
+
21
+ constructor(baseUrl: string, token: string) {
22
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
23
+ this.token = token;
24
+ }
25
+
26
+ private headers(): HeadersInit {
27
+ return { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" };
28
+ }
29
+
30
+ async get(key: string): Promise<string | undefined> {
31
+ const response = await fetch(`${this.baseUrl}/get/${encodeURIComponent(key)}`, {
32
+ method: "POST",
33
+ headers: this.headers(),
34
+ });
35
+ if (!response.ok) return undefined;
36
+ const payload = (await response.json()) as { result?: string | null };
37
+ return payload.result ?? undefined;
38
+ }
39
+
40
+ async set(key: string, value: string): Promise<void> {
41
+ await fetch(
42
+ `${this.baseUrl}/set/${encodeURIComponent(key)}/${encodeURIComponent(value)}`,
43
+ { method: "POST", headers: this.headers() },
44
+ );
45
+ }
46
+
47
+ async setWithTtl(key: string, value: string, ttl: number): Promise<void> {
48
+ await fetch(
49
+ `${this.baseUrl}/setex/${encodeURIComponent(key)}/${Math.max(1, ttl)}/${encodeURIComponent(value)}`,
50
+ { method: "POST", headers: this.headers() },
51
+ );
52
+ }
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Redis
57
+ // ---------------------------------------------------------------------------
58
+
59
+ class RedisKVStore implements RawKVStore {
60
+ private readonly clientPromise: Promise<
61
+ | {
62
+ get: (key: string) => Promise<string | null>;
63
+ set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
64
+ }
65
+ | undefined
66
+ >;
67
+
68
+ constructor(url: string) {
69
+ this.clientPromise = (async () => {
70
+ try {
71
+ const redisModule = (await import("redis")) as unknown as {
72
+ createClient: (args: { url: string }) => {
73
+ connect: () => Promise<unknown>;
74
+ get: (key: string) => Promise<string | null>;
75
+ set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
76
+ };
77
+ };
78
+ const client = redisModule.createClient({ url });
79
+ await client.connect();
80
+ return client;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ })();
85
+ }
86
+
87
+ async get(key: string): Promise<string | undefined> {
88
+ const client = await this.clientPromise;
89
+ if (!client) throw new Error("Redis unavailable");
90
+ const value = await client.get(key);
91
+ return value ?? undefined;
92
+ }
93
+
94
+ async set(key: string, value: string): Promise<void> {
95
+ const client = await this.clientPromise;
96
+ if (!client) throw new Error("Redis unavailable");
97
+ await client.set(key, value);
98
+ }
99
+
100
+ async setWithTtl(key: string, value: string, ttl: number): Promise<void> {
101
+ const client = await this.clientPromise;
102
+ if (!client) throw new Error("Redis unavailable");
103
+ await client.set(key, value, { EX: Math.max(1, ttl) });
104
+ }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // DynamoDB
109
+ // ---------------------------------------------------------------------------
110
+
111
+ class DynamoDbKVStore implements RawKVStore {
112
+ private readonly table: string;
113
+ private readonly clientPromise: Promise<
114
+ | {
115
+ send: (command: unknown) => Promise<unknown>;
116
+ GetItemCommand: new (input: unknown) => unknown;
117
+ PutItemCommand: new (input: unknown) => unknown;
118
+ }
119
+ | undefined
120
+ >;
121
+
122
+ constructor(table: string, region?: string) {
123
+ this.table = table;
124
+ this.clientPromise = (async () => {
125
+ try {
126
+ const module = (await import("@aws-sdk/client-dynamodb")) as {
127
+ DynamoDBClient: new (input: { region?: string }) => {
128
+ send: (command: unknown) => Promise<unknown>;
129
+ };
130
+ GetItemCommand: new (input: unknown) => unknown;
131
+ PutItemCommand: new (input: unknown) => unknown;
132
+ };
133
+ const client = new module.DynamoDBClient({ region });
134
+ return {
135
+ send: client.send.bind(client),
136
+ GetItemCommand: module.GetItemCommand,
137
+ PutItemCommand: module.PutItemCommand,
138
+ };
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ })();
143
+ }
144
+
145
+ async get(key: string): Promise<string | undefined> {
146
+ const client = await this.clientPromise;
147
+ if (!client) throw new Error("DynamoDB unavailable");
148
+ const result = (await client.send(
149
+ new client.GetItemCommand({ TableName: this.table, Key: { runId: { S: key } } }),
150
+ )) as { Item?: { value?: { S?: string } } };
151
+ return result.Item?.value?.S;
152
+ }
153
+
154
+ async set(key: string, value: string): Promise<void> {
155
+ const client = await this.clientPromise;
156
+ if (!client) throw new Error("DynamoDB unavailable");
157
+ await client.send(
158
+ new client.PutItemCommand({
159
+ TableName: this.table,
160
+ Item: { runId: { S: key }, value: { S: value } },
161
+ }),
162
+ );
163
+ }
164
+
165
+ async setWithTtl(key: string, value: string, ttl: number): Promise<void> {
166
+ const client = await this.clientPromise;
167
+ if (!client) throw new Error("DynamoDB unavailable");
168
+ const ttlEpoch = Math.floor(Date.now() / 1000) + Math.max(1, ttl);
169
+ await client.send(
170
+ new client.PutItemCommand({
171
+ TableName: this.table,
172
+ Item: { runId: { S: key }, value: { S: value }, ttl: { N: String(ttlEpoch) } },
173
+ }),
174
+ );
175
+ }
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Factory — resolves the user's storage config into a RawKVStore, or
180
+ // undefined when the provider is "local" or "memory" (handled by callers).
181
+ // ---------------------------------------------------------------------------
182
+
183
+ export const createRawKVStore = (config?: StateConfig): RawKVStore | undefined => {
184
+ const provider = config?.provider ?? "local";
185
+
186
+ if (provider === "upstash") {
187
+ const urlEnv = config?.urlEnv ?? (process.env.UPSTASH_REDIS_REST_URL ? "UPSTASH_REDIS_REST_URL" : "KV_REST_API_URL");
188
+ const tokenEnv = config?.tokenEnv ?? (process.env.UPSTASH_REDIS_REST_TOKEN ? "UPSTASH_REDIS_REST_TOKEN" : "KV_REST_API_TOKEN");
189
+ const url = process.env[urlEnv] ?? "";
190
+ const token = process.env[tokenEnv] ?? "";
191
+ if (url && token) return new UpstashKVStore(url, token);
192
+ }
193
+
194
+ if (provider === "redis") {
195
+ const urlEnv = config?.urlEnv ?? "REDIS_URL";
196
+ const url = process.env[urlEnv] ?? "";
197
+ if (url) return new RedisKVStore(url);
198
+ }
199
+
200
+ if (provider === "dynamodb") {
201
+ const table = config?.table ?? process.env.PONCHO_DYNAMODB_TABLE ?? "";
202
+ if (table) return new DynamoDbKVStore(table, config?.region);
203
+ }
204
+
205
+ return undefined;
206
+ };