@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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +12 -0
- package/dist/index.d.ts +34 -1
- package/dist/index.js +645 -337
- package/package.json +1 -1
- package/src/config.ts +4 -0
- package/src/harness.ts +140 -40
- package/src/kv-store.ts +206 -0
- package/src/memory.ts +88 -336
- package/src/todo-tools.ts +363 -0
- package/test/harness.test.ts +129 -2
- package/test/memory.test.ts +100 -15
package/package.json
CHANGED
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
|
|
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
|
-
|
|
716
|
+
patterns.add(pattern);
|
|
707
717
|
}
|
|
708
718
|
}
|
|
709
|
-
|
|
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
|
|
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
|
-
|
|
744
|
+
patterns.add(pattern);
|
|
738
745
|
}
|
|
739
746
|
}
|
|
740
|
-
|
|
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
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
//
|
|
917
|
-
//
|
|
918
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
-
|
|
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,
|
package/src/kv-store.ts
ADDED
|
@@ -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
|
+
};
|