@elisym/cli 0.23.1 → 0.24.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/dist/index.js +1194 -139
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env -S node --no-deprecation
|
|
2
2
|
import { ReadableStream } from 'node:stream/web';
|
|
3
|
-
import { readFileSync, existsSync, readdirSync, statSync, renameSync, chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, existsSync, readdirSync, statSync, renameSync, chmodSync, mkdirSync, writeFileSync, appendFileSync, unlinkSync, rmdirSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, resolve, basename, extname, relative, sep } from 'node:path';
|
|
5
|
-
import { SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, formatSol, formatAssetAmount, USDC_SOLANA_DEVNET, signerFromSecretKeyBase58, ElisymClient, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, DEFAULTS, createBlossomTransport, generateSolanaWallet, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, POLICY_T_TAG, createSlidingWindowLimiter, utf8ByteLength, readAcceptedTransports, decodeJobPayload, BoundedSet, KIND_JOB_FEEDBACK, encodeSecretKeyBase58, NATIVE_SOL } from '@elisym/sdk';
|
|
6
|
-
import { ElisymYamlSchema, resolveInHome, resolveInProject, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, listAgents, loadAgent, writeYaml, agentPaths, readMediaCache, loadPoliciesFromDir, ensureGitignoreHasIrohEntry, ensureGitignoreHasX402Entries, lookupCachedUrl, newCacheEntry, writeMediaCache } from '@elisym/sdk/agent-store';
|
|
5
|
+
import { SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, formatSol, formatAssetAmount, USDC_SOLANA_DEVNET, signerFromSecretKeyBase58, ElisymClient, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, DEFAULTS, createBlossomTransport, generateSolanaWallet, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, utf8ByteLength, readAcceptedTransports, decodeJobPayload, BoundedSet, KIND_JOB_FEEDBACK, encodeSecretKeyBase58, NATIVE_SOL } from '@elisym/sdk';
|
|
6
|
+
import { ElisymYamlSchema, resolveInHome, resolveInProject, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, listAgents, loadAgent, writeYaml, agentPaths, readMediaCache, loadPoliciesFromDir, ensureGitignoreHasIrohEntry, ensureGitignoreHasX402Entries, ensureGitignoreHasSessionsEntry, lookupCachedUrl, newCacheEntry, writeMediaCache } from '@elisym/sdk/agent-store';
|
|
7
7
|
import { isAddress, createSolanaRpc, address } from '@solana/kit';
|
|
8
8
|
import { generateSecretKey, getPublicKey, nip19, verifyEvent } from 'nostr-tools';
|
|
9
9
|
import YAML from 'yaml';
|
|
@@ -17,7 +17,7 @@ import pino from 'pino';
|
|
|
17
17
|
import { mkdir, writeFile, readFile, mkdtemp, rm, rename, stat } from 'node:fs/promises';
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
19
|
import pLimit from 'p-limit';
|
|
20
|
-
import { parseSkillMd, validateSkillFrontmatter, DEFAULT_X402_MAX_INPUT_BYTES,
|
|
20
|
+
import { parseSkillMd, validateSkillFrontmatter, DEFAULT_X402_MAX_INPUT_BYTES, resolveInsidePathReal, DEFAULT_SCRIPT_TIMEOUT_MS, DynamicScriptSkill as DynamicScriptSkill$1, StaticScriptSkill as StaticScriptSkill$1, StaticFileSkill as StaticFileSkill$1, ScriptSkill as ScriptSkill$1 } from '@elisym/sdk/skills';
|
|
21
21
|
import { x402Client, wrapFetchWithPayment } from '@x402/fetch';
|
|
22
22
|
import { ExactSvmScheme } from '@x402/svm';
|
|
23
23
|
import { decodePaymentRequiredHeader } from '@x402/core/http';
|
|
@@ -126,6 +126,16 @@ async function fetchWithTimeout(url, init, signal) {
|
|
|
126
126
|
headers: response.headers
|
|
127
127
|
});
|
|
128
128
|
}
|
|
129
|
+
function sanitizeErrorBody(body, maxLength = 500) {
|
|
130
|
+
let scrubbed = body;
|
|
131
|
+
for (const pattern of SECRET_BODY_PATTERNS) {
|
|
132
|
+
scrubbed = scrubbed.replace(
|
|
133
|
+
pattern,
|
|
134
|
+
(_match, prefix) => typeof prefix === "string" ? `${prefix}[REDACTED]` : "[REDACTED]"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return scrubbed.slice(0, maxLength);
|
|
138
|
+
}
|
|
129
139
|
async function fetchWithRetry(url, init, signal) {
|
|
130
140
|
for (let attempt = 0; ; attempt++) {
|
|
131
141
|
let response;
|
|
@@ -148,13 +158,20 @@ async function fetchWithRetry(url, init, signal) {
|
|
|
148
158
|
await sleepWithSignal(delay, signal);
|
|
149
159
|
}
|
|
150
160
|
}
|
|
151
|
-
var DEFAULT_LLM_TIMEOUT_MS, LLM_TIMEOUT_MS, MAX_RETRIES, RETRYABLE_STATUSES;
|
|
161
|
+
var DEFAULT_LLM_TIMEOUT_MS, LLM_TIMEOUT_MS, MAX_RETRIES, RETRYABLE_STATUSES, SECRET_BODY_PATTERNS;
|
|
152
162
|
var init_http = __esm({
|
|
153
163
|
"src/llm/providers/http.ts"() {
|
|
154
164
|
DEFAULT_LLM_TIMEOUT_MS = 6e5;
|
|
155
165
|
LLM_TIMEOUT_MS = resolveLlmTimeoutMs();
|
|
156
166
|
MAX_RETRIES = 2;
|
|
157
167
|
RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
168
|
+
SECRET_BODY_PATTERNS = [
|
|
169
|
+
/sk-[A-Za-z0-9_-]{8,}/g,
|
|
170
|
+
/xai-[A-Za-z0-9_-]{8,}/g,
|
|
171
|
+
/AIza[A-Za-z0-9_-]{16,}/g,
|
|
172
|
+
/(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
173
|
+
/(x-api-key["':\s=]+)[A-Za-z0-9._~+/=-]{8,}/gi
|
|
174
|
+
];
|
|
158
175
|
}
|
|
159
176
|
});
|
|
160
177
|
|
|
@@ -193,7 +210,7 @@ async function verifyKey(apiKey, signal) {
|
|
|
193
210
|
await response.body?.cancel().catch(() => void 0);
|
|
194
211
|
return { ok: true };
|
|
195
212
|
}
|
|
196
|
-
const body = (await response.text().catch(() => ""))
|
|
213
|
+
const body = sanitizeErrorBody(await response.text().catch(() => ""));
|
|
197
214
|
if (response.status === 401 || response.status === 403) {
|
|
198
215
|
return { ok: false, reason: "invalid", status: response.status, body };
|
|
199
216
|
}
|
|
@@ -234,7 +251,7 @@ async function verifyKeyDeep(apiKey, model, signal) {
|
|
|
234
251
|
await response.body?.cancel().catch(() => void 0);
|
|
235
252
|
return { ok: true };
|
|
236
253
|
}
|
|
237
|
-
const body = (await response.text().catch(() => ""))
|
|
254
|
+
const body = sanitizeErrorBody(await response.text().catch(() => ""));
|
|
238
255
|
if (response.status === 401 || response.status === 403) {
|
|
239
256
|
return { ok: false, reason: "invalid", status: response.status, body };
|
|
240
257
|
}
|
|
@@ -280,7 +297,7 @@ var init_anthropic = __esm({
|
|
|
280
297
|
` [LLM] ${this.config.model} tokens: in=${inputTokens} out=${outputTokens} (total: in=${this.totalIn} out=${this.totalOut})`
|
|
281
298
|
);
|
|
282
299
|
}
|
|
283
|
-
async complete(systemPrompt, userInput, signal) {
|
|
300
|
+
async complete(systemPrompt, userInput, signal, history) {
|
|
284
301
|
const response = await fetchWithRetry(
|
|
285
302
|
"https://api.anthropic.com/v1/messages",
|
|
286
303
|
{
|
|
@@ -294,13 +311,15 @@ var init_anthropic = __esm({
|
|
|
294
311
|
model: this.config.model,
|
|
295
312
|
max_tokens: this.config.maxTokens,
|
|
296
313
|
system: systemPrompt,
|
|
297
|
-
messages: [{ role: "user", content: userInput }]
|
|
314
|
+
messages: [...history ?? [], { role: "user", content: userInput }]
|
|
298
315
|
})
|
|
299
316
|
},
|
|
300
317
|
signal
|
|
301
318
|
);
|
|
302
319
|
if (!response.ok) {
|
|
303
|
-
throw new Error(
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Anthropic API error: ${response.status} ${sanitizeErrorBody(await response.text(), 200)}`
|
|
322
|
+
);
|
|
304
323
|
}
|
|
305
324
|
const data = await response.json();
|
|
306
325
|
this.logTokens(data.usage);
|
|
@@ -342,7 +361,9 @@ var init_anthropic = __esm({
|
|
|
342
361
|
signal
|
|
343
362
|
);
|
|
344
363
|
if (!response.ok) {
|
|
345
|
-
throw new Error(
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Anthropic API error: ${response.status} ${sanitizeErrorBody(await response.text(), 200)}`
|
|
366
|
+
);
|
|
346
367
|
}
|
|
347
368
|
const data = await response.json();
|
|
348
369
|
this.logTokens(data.usage);
|
|
@@ -438,7 +459,7 @@ function createOpenAICompatibleProvider(config) {
|
|
|
438
459
|
await response.body?.cancel().catch(() => void 0);
|
|
439
460
|
return { ok: true };
|
|
440
461
|
}
|
|
441
|
-
const body = (await response.text().catch(() => ""))
|
|
462
|
+
const body = sanitizeErrorBody(await response.text().catch(() => ""));
|
|
442
463
|
if (response.status === 401 || response.status === 403) {
|
|
443
464
|
return { ok: false, reason: "invalid", status: response.status, body };
|
|
444
465
|
}
|
|
@@ -483,7 +504,7 @@ function createOpenAICompatibleProvider(config) {
|
|
|
483
504
|
await response.body?.cancel().catch(() => void 0);
|
|
484
505
|
return { ok: true };
|
|
485
506
|
}
|
|
486
|
-
const body = (await response.text().catch(() => ""))
|
|
507
|
+
const body = sanitizeErrorBody(await response.text().catch(() => ""));
|
|
487
508
|
if (response.status === 401 || response.status === 403) {
|
|
488
509
|
return { ok: false, reason: "invalid", status: response.status, body };
|
|
489
510
|
}
|
|
@@ -550,7 +571,7 @@ var init_openai_compatible = __esm({
|
|
|
550
571
|
` [LLM] ${this.config.model} tokens: in=${inputTokens} out=${outputTokens} (total: in=${this.totalIn} out=${this.totalOut})`
|
|
551
572
|
);
|
|
552
573
|
}
|
|
553
|
-
async complete(systemPrompt, userInput, signal) {
|
|
574
|
+
async complete(systemPrompt, userInput, signal, history) {
|
|
554
575
|
const response = await fetchWithRetry(
|
|
555
576
|
`${this.config.baseUrl}/chat/completions`,
|
|
556
577
|
{
|
|
@@ -564,6 +585,7 @@ var init_openai_compatible = __esm({
|
|
|
564
585
|
max_tokens: this.config.maxTokens,
|
|
565
586
|
messages: [
|
|
566
587
|
{ role: "system", content: systemPrompt },
|
|
588
|
+
...history ?? [],
|
|
567
589
|
{ role: "user", content: userInput }
|
|
568
590
|
]
|
|
569
591
|
})
|
|
@@ -572,7 +594,7 @@ var init_openai_compatible = __esm({
|
|
|
572
594
|
);
|
|
573
595
|
if (!response.ok) {
|
|
574
596
|
throw new Error(
|
|
575
|
-
`${this.config.providerLabel} API error: ${response.status} ${await response.text()}`
|
|
597
|
+
`${this.config.providerLabel} API error: ${response.status} ${sanitizeErrorBody(await response.text(), 200)}`
|
|
576
598
|
);
|
|
577
599
|
}
|
|
578
600
|
const data = await response.json();
|
|
@@ -616,7 +638,7 @@ var init_openai_compatible = __esm({
|
|
|
616
638
|
);
|
|
617
639
|
if (!response.ok) {
|
|
618
640
|
throw new Error(
|
|
619
|
-
`${this.config.providerLabel} API error: ${response.status} ${await response.text()}`
|
|
641
|
+
`${this.config.providerLabel} API error: ${response.status} ${sanitizeErrorBody(await response.text(), 200)}`
|
|
620
642
|
);
|
|
621
643
|
}
|
|
622
644
|
const data = await response.json();
|
|
@@ -730,7 +752,7 @@ async function verifyKey2(apiKey, signal) {
|
|
|
730
752
|
await response.body?.cancel().catch(() => void 0);
|
|
731
753
|
return { ok: true };
|
|
732
754
|
}
|
|
733
|
-
const body = (await response.text().catch(() => ""))
|
|
755
|
+
const body = sanitizeErrorBody(await response.text().catch(() => ""));
|
|
734
756
|
if (response.status === 401 || response.status === 403) {
|
|
735
757
|
return { ok: false, reason: "invalid", status: response.status, body };
|
|
736
758
|
}
|
|
@@ -781,7 +803,7 @@ async function verifyKeyDeep2(apiKey, model, signal) {
|
|
|
781
803
|
await response.body?.cancel().catch(() => void 0);
|
|
782
804
|
return { ok: true };
|
|
783
805
|
}
|
|
784
|
-
const body = (await response.text().catch(() => ""))
|
|
806
|
+
const body = sanitizeErrorBody(await response.text().catch(() => ""));
|
|
785
807
|
if (response.status === 401 || response.status === 403) {
|
|
786
808
|
return { ok: false, reason: "invalid", status: response.status, body };
|
|
787
809
|
}
|
|
@@ -833,7 +855,7 @@ var init_openai = __esm({
|
|
|
833
855
|
` [LLM] ${this.config.model} tokens: in=${inputTokens} out=${outputTokens} (total: in=${this.totalIn} out=${this.totalOut})`
|
|
834
856
|
);
|
|
835
857
|
}
|
|
836
|
-
async complete(systemPrompt, userInput, signal) {
|
|
858
|
+
async complete(systemPrompt, userInput, signal, history) {
|
|
837
859
|
const reasoning = this.isReasoningModel();
|
|
838
860
|
const response = await fetchWithRetry(
|
|
839
861
|
"https://api.openai.com/v1/chat/completions",
|
|
@@ -848,6 +870,7 @@ var init_openai = __esm({
|
|
|
848
870
|
...reasoning ? { max_completion_tokens: this.config.maxTokens } : { max_tokens: this.config.maxTokens },
|
|
849
871
|
messages: [
|
|
850
872
|
{ role: reasoning ? "developer" : "system", content: systemPrompt },
|
|
873
|
+
...history ?? [],
|
|
851
874
|
{ role: "user", content: userInput }
|
|
852
875
|
]
|
|
853
876
|
})
|
|
@@ -855,7 +878,9 @@ var init_openai = __esm({
|
|
|
855
878
|
signal
|
|
856
879
|
);
|
|
857
880
|
if (!response.ok) {
|
|
858
|
-
throw new Error(
|
|
881
|
+
throw new Error(
|
|
882
|
+
`OpenAI API error: ${response.status} ${sanitizeErrorBody(await response.text(), 200)}`
|
|
883
|
+
);
|
|
859
884
|
}
|
|
860
885
|
const data = await response.json();
|
|
861
886
|
this.logTokens(data.usage);
|
|
@@ -901,7 +926,9 @@ var init_openai = __esm({
|
|
|
901
926
|
signal
|
|
902
927
|
);
|
|
903
928
|
if (!response.ok) {
|
|
904
|
-
throw new Error(
|
|
929
|
+
throw new Error(
|
|
930
|
+
`OpenAI API error: ${response.status} ${sanitizeErrorBody(await response.text(), 200)}`
|
|
931
|
+
);
|
|
905
932
|
}
|
|
906
933
|
const data = await response.json();
|
|
907
934
|
this.logTokens(data.usage);
|
|
@@ -2071,6 +2098,735 @@ var MIME_BY_EXT = {
|
|
|
2071
2098
|
function mimeFromPath(path) {
|
|
2072
2099
|
return MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
2073
2100
|
}
|
|
2101
|
+
var SESSIONS_DIR_NAME = ".sessions";
|
|
2102
|
+
var SESSION_MAX_CONCURRENT_JOBS = 2;
|
|
2103
|
+
var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
2104
|
+
var SESSION_MAX_PER_CUSTOMER = 64;
|
|
2105
|
+
var SESSION_MAX_TURN_BYTES = 256 * 1024;
|
|
2106
|
+
var SESSION_MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
2107
|
+
var SESSION_GLOBAL_MAX_BYTES = 512 * 1024 * 1024;
|
|
2108
|
+
var SESSION_GLOBAL_EVICT_TO_BYTES = Math.floor(SESSION_GLOBAL_MAX_BYTES * 0.9);
|
|
2109
|
+
var SESSION_COMPACTION_TRIGGER_CHARS = 12e4;
|
|
2110
|
+
var SESSION_COMPACTION_KEEP_CHARS = Math.floor(SESSION_COMPACTION_TRIGGER_CHARS / 2);
|
|
2111
|
+
var SESSION_COMPACTION_MAX_STRIKES = 2;
|
|
2112
|
+
var SESSION_SUMMARY_PREFIX = "[Conversation summary]";
|
|
2113
|
+
var SESSION_CONTEXT_UNAVAILABLE = "[earlier context unavailable]";
|
|
2114
|
+
var TRUNCATION_MARKER = "\n[truncated]";
|
|
2115
|
+
var CUSTOMER_PUBKEY_REGEX = /^[0-9a-f]{64}$/;
|
|
2116
|
+
var DIR_MODE = 448;
|
|
2117
|
+
var FILE_MODE = 384;
|
|
2118
|
+
var SessionStore = class {
|
|
2119
|
+
root;
|
|
2120
|
+
log;
|
|
2121
|
+
ttlMs;
|
|
2122
|
+
maxPerCustomer;
|
|
2123
|
+
maxTurnBytes;
|
|
2124
|
+
maxFileBytes;
|
|
2125
|
+
globalMaxBytes;
|
|
2126
|
+
globalEvictToBytes;
|
|
2127
|
+
compactionTriggerChars;
|
|
2128
|
+
compactionKeepChars;
|
|
2129
|
+
/** Session-key -> release promise. Entry present = mutex held. */
|
|
2130
|
+
locks = /* @__PURE__ */ new Map();
|
|
2131
|
+
/** Session-key -> admitted-job counts (live intake + jobId-keyed recovery). */
|
|
2132
|
+
admitted = /* @__PURE__ */ new Map();
|
|
2133
|
+
/** Recovery registration index: jobId -> session key (for the release sweep). */
|
|
2134
|
+
recoveryByJob = /* @__PURE__ */ new Map();
|
|
2135
|
+
/** Session-key -> consecutive compaction failures (in-process, reset on restart). */
|
|
2136
|
+
compactionStrikes = /* @__PURE__ */ new Map();
|
|
2137
|
+
/** Running total of session-file bytes (established by `init()`'s scan). */
|
|
2138
|
+
globalBytes = 0;
|
|
2139
|
+
constructor(agentDir, logger, tuning) {
|
|
2140
|
+
this.root = join(agentDir, SESSIONS_DIR_NAME);
|
|
2141
|
+
this.log = logger ?? (() => {
|
|
2142
|
+
});
|
|
2143
|
+
this.ttlMs = tuning?.ttlMs ?? SESSION_TTL_MS;
|
|
2144
|
+
this.maxPerCustomer = tuning?.maxPerCustomer ?? SESSION_MAX_PER_CUSTOMER;
|
|
2145
|
+
this.maxTurnBytes = tuning?.maxTurnBytes ?? SESSION_MAX_TURN_BYTES;
|
|
2146
|
+
this.maxFileBytes = tuning?.maxFileBytes ?? SESSION_MAX_FILE_BYTES;
|
|
2147
|
+
this.globalMaxBytes = tuning?.globalMaxBytes ?? SESSION_GLOBAL_MAX_BYTES;
|
|
2148
|
+
this.globalEvictToBytes = tuning?.globalEvictToBytes ?? (tuning?.globalMaxBytes !== void 0 ? Math.floor(tuning.globalMaxBytes * 0.9) : SESSION_GLOBAL_EVICT_TO_BYTES);
|
|
2149
|
+
this.compactionTriggerChars = tuning?.compactionTriggerChars ?? SESSION_COMPACTION_TRIGGER_CHARS;
|
|
2150
|
+
this.compactionKeepChars = tuning?.compactionKeepChars ?? (tuning?.compactionTriggerChars !== void 0 ? Math.floor(tuning.compactionTriggerChars / 2) : SESSION_COMPACTION_KEEP_CHARS);
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Startup: establish the global byte counter (excluding `.corrupt.*`
|
|
2154
|
+
* sidecars) and run a GC sweep. Call BEFORE `recoverPendingJobs` (mirrors
|
|
2155
|
+
* the ledger prune-before-recovery ordering).
|
|
2156
|
+
*/
|
|
2157
|
+
init() {
|
|
2158
|
+
this.globalBytes = 0;
|
|
2159
|
+
for (const file of this.listSessionFiles()) {
|
|
2160
|
+
this.globalBytes += file.size;
|
|
2161
|
+
}
|
|
2162
|
+
this.gc();
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* TTL sweep: delete sessions idle past the TTL and `.corrupt.*` sidecars
|
|
2166
|
+
* older than the TTL, then rmdir emptied customer dirs. A victim is deleted
|
|
2167
|
+
* only while holding its try-acquired mutex with a zero admitted count -
|
|
2168
|
+
* check and unlink are synchronous, so nothing can interleave.
|
|
2169
|
+
*/
|
|
2170
|
+
gc() {
|
|
2171
|
+
const cutoff = Date.now() - this.ttlMs;
|
|
2172
|
+
for (const file of this.listSessionFiles()) {
|
|
2173
|
+
if (file.mtimeMs >= cutoff) {
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
this.deleteIfIdle(file.customerId, file.sessionId);
|
|
2177
|
+
}
|
|
2178
|
+
this.pruneSidecars(cutoff);
|
|
2179
|
+
this.pruneEmptyCustomerDirs();
|
|
2180
|
+
}
|
|
2181
|
+
// ── Mutex ──────────────────────────────────────────────────────────────
|
|
2182
|
+
/**
|
|
2183
|
+
* Acquire the session mutex. Lock order invariant (runtime-enforced): the
|
|
2184
|
+
* caller already holds its `pLimit` slot - a mutex holder never waits for a
|
|
2185
|
+
* slot, so no lock-order inversion is possible.
|
|
2186
|
+
*/
|
|
2187
|
+
async acquire(customerId, sessionId) {
|
|
2188
|
+
const key = sessionKey(customerId, sessionId);
|
|
2189
|
+
for (; ; ) {
|
|
2190
|
+
const current = this.locks.get(key);
|
|
2191
|
+
if (current === void 0) {
|
|
2192
|
+
return this.installLock(key);
|
|
2193
|
+
}
|
|
2194
|
+
await current.promise;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
/** Non-blocking acquire for GC/eviction. Returns a release fn or null. */
|
|
2198
|
+
tryAcquireSync(key) {
|
|
2199
|
+
if (this.locks.has(key)) {
|
|
2200
|
+
return null;
|
|
2201
|
+
}
|
|
2202
|
+
return this.installLock(key);
|
|
2203
|
+
}
|
|
2204
|
+
installLock(key) {
|
|
2205
|
+
let release;
|
|
2206
|
+
const promise = new Promise((resolve3) => {
|
|
2207
|
+
release = resolve3;
|
|
2208
|
+
});
|
|
2209
|
+
const entry = { promise, release };
|
|
2210
|
+
this.locks.set(key, entry);
|
|
2211
|
+
return () => {
|
|
2212
|
+
if (this.locks.get(key) === entry) {
|
|
2213
|
+
this.locks.delete(key);
|
|
2214
|
+
}
|
|
2215
|
+
entry.release();
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
// ── Admitted-job counter ───────────────────────────────────────────────
|
|
2219
|
+
admittedCount(customerId, sessionId) {
|
|
2220
|
+
const entry = this.admitted.get(sessionKey(customerId, sessionId));
|
|
2221
|
+
return entry === void 0 ? 0 : entry.live + entry.recovery.size;
|
|
2222
|
+
}
|
|
2223
|
+
/** Live-intake registration. Pair with `releaseLive` in the job's finally. */
|
|
2224
|
+
admitLive(customerId, sessionId) {
|
|
2225
|
+
const entry = this.admittedEntry(sessionKey(customerId, sessionId));
|
|
2226
|
+
entry.live++;
|
|
2227
|
+
}
|
|
2228
|
+
releaseLive(customerId, sessionId) {
|
|
2229
|
+
const key = sessionKey(customerId, sessionId);
|
|
2230
|
+
const entry = this.admitted.get(key);
|
|
2231
|
+
if (entry === void 0) {
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
entry.live = Math.max(0, entry.live - 1);
|
|
2235
|
+
this.dropAdmittedIfEmpty(key, entry);
|
|
2236
|
+
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Recovery pre-pass: register the sessions of pending `paid` entries (jobId-
|
|
2239
|
+
* keyed, idempotent across ticks) and release registrations whose ledger
|
|
2240
|
+
* entry has left `paid` (status predicate, re-checked every tick). Runs at
|
|
2241
|
+
* the top of every recovery tick, BEFORE its empty-pending early return -
|
|
2242
|
+
* releases are needed precisely when entries have left the pending set.
|
|
2243
|
+
*/
|
|
2244
|
+
syncRecoveryRegistrations(refs, isStillPaid) {
|
|
2245
|
+
for (const ref of refs) {
|
|
2246
|
+
if (this.recoveryByJob.has(ref.jobId)) {
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
const key = sessionKey(ref.customerId, ref.sessionId);
|
|
2250
|
+
this.recoveryByJob.set(ref.jobId, key);
|
|
2251
|
+
this.admittedEntry(key).recovery.add(ref.jobId);
|
|
2252
|
+
}
|
|
2253
|
+
for (const [jobId, key] of this.recoveryByJob) {
|
|
2254
|
+
if (isStillPaid(jobId)) {
|
|
2255
|
+
continue;
|
|
2256
|
+
}
|
|
2257
|
+
this.recoveryByJob.delete(jobId);
|
|
2258
|
+
const entry = this.admitted.get(key);
|
|
2259
|
+
if (entry !== void 0) {
|
|
2260
|
+
entry.recovery.delete(jobId);
|
|
2261
|
+
this.dropAdmittedIfEmpty(key, entry);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
// ── Open / append / compaction (call with the session mutex held) ──────
|
|
2266
|
+
/**
|
|
2267
|
+
* Load the session for a job. Applies the load-time TTL check (expired file
|
|
2268
|
+
* is deleted BEFORE recording - expiry is a hard reset, never a resurrection),
|
|
2269
|
+
* the global byte cap (at-cap global LRU eviction, excluding this session),
|
|
2270
|
+
* and the per-customer LRU cap. `excludeJobId` (crash recovery) removes the
|
|
2271
|
+
* recovering job's own turns from the replayed history and reports which of
|
|
2272
|
+
* its roles are already recorded.
|
|
2273
|
+
*/
|
|
2274
|
+
open(customerId, sessionId, excludeJobId) {
|
|
2275
|
+
validateKeys(customerId, sessionId);
|
|
2276
|
+
const path = this.sessionPath(customerId, sessionId);
|
|
2277
|
+
const stateless = {
|
|
2278
|
+
stateless: true,
|
|
2279
|
+
known: false,
|
|
2280
|
+
messages: [],
|
|
2281
|
+
compactionNeeded: false,
|
|
2282
|
+
recordedRoles: /* @__PURE__ */ new Set()
|
|
2283
|
+
};
|
|
2284
|
+
let exists = existsSync(path);
|
|
2285
|
+
if (exists) {
|
|
2286
|
+
const stat2 = statSync(path);
|
|
2287
|
+
if (Date.now() - stat2.mtimeMs > this.ttlMs) {
|
|
2288
|
+
this.deleteSessionFile(customerId, sessionId);
|
|
2289
|
+
exists = false;
|
|
2290
|
+
this.log(`[sessions] ${sessionId.slice(0, 8)} expired; starting fresh`);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
if (this.globalBytes >= this.globalMaxBytes) {
|
|
2294
|
+
this.evictGlobal(sessionKey(customerId, sessionId));
|
|
2295
|
+
if (this.globalBytes >= this.globalMaxBytes) {
|
|
2296
|
+
this.log(
|
|
2297
|
+
"[sessions] global byte cap reached and no evictable victim; processing stateless"
|
|
2298
|
+
);
|
|
2299
|
+
return stateless;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
if (!exists && !this.ensureCustomerCapacity(customerId, sessionId)) {
|
|
2303
|
+
this.log(
|
|
2304
|
+
`[sessions] customer session cap reached with no evictable victim; processing stateless`
|
|
2305
|
+
);
|
|
2306
|
+
return stateless;
|
|
2307
|
+
}
|
|
2308
|
+
if (!exists) {
|
|
2309
|
+
return {
|
|
2310
|
+
stateless: false,
|
|
2311
|
+
known: false,
|
|
2312
|
+
messages: [],
|
|
2313
|
+
compactionNeeded: false,
|
|
2314
|
+
recordedRoles: /* @__PURE__ */ new Set()
|
|
2315
|
+
};
|
|
2316
|
+
}
|
|
2317
|
+
const lines = this.readSessionLines(customerId, sessionId);
|
|
2318
|
+
if (lines === null) {
|
|
2319
|
+
return {
|
|
2320
|
+
stateless: false,
|
|
2321
|
+
known: false,
|
|
2322
|
+
messages: [],
|
|
2323
|
+
compactionNeeded: false,
|
|
2324
|
+
recordedRoles: /* @__PURE__ */ new Set()
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
const recordedRoles = /* @__PURE__ */ new Set();
|
|
2328
|
+
if (excludeJobId !== void 0) {
|
|
2329
|
+
for (const line of lines) {
|
|
2330
|
+
if (line.type === "turn" && line.jobId === excludeJobId) {
|
|
2331
|
+
recordedRoles.add(line.role);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
const messages = assembleReplay(lines, excludeJobId);
|
|
2336
|
+
const replayedChars = messages.reduce((sum, message) => sum + message.content.length, 0);
|
|
2337
|
+
const compactionNeeded = replayedChars > this.compactionTriggerChars;
|
|
2338
|
+
return {
|
|
2339
|
+
stateless: false,
|
|
2340
|
+
known: true,
|
|
2341
|
+
messages,
|
|
2342
|
+
compactionNeeded,
|
|
2343
|
+
summarizeText: compactionNeeded ? buildSummarizeText(lines, this.keptTailStart(lines)) : void 0,
|
|
2344
|
+
recordedRoles
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Record a completed exchange. Called after execution success, BEFORE the
|
|
2349
|
+
* ledger's `markExecuted` flush and before result seeding (§2 append timing).
|
|
2350
|
+
* Both turns go in one write; dedupe is `(jobId, role)` via `skipRoles`.
|
|
2351
|
+
*/
|
|
2352
|
+
appendExchange(customerId, sessionId, exchange) {
|
|
2353
|
+
validateKeys(customerId, sessionId);
|
|
2354
|
+
const ts = Math.floor(Date.now() / 1e3);
|
|
2355
|
+
const lines = [];
|
|
2356
|
+
if (!exchange.skipRoles?.has("user")) {
|
|
2357
|
+
lines.push({
|
|
2358
|
+
type: "turn",
|
|
2359
|
+
role: "user",
|
|
2360
|
+
content: truncateTurn(exchange.userContent, this.maxTurnBytes),
|
|
2361
|
+
jobId: exchange.jobId,
|
|
2362
|
+
capability: exchange.capability,
|
|
2363
|
+
ts
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
if (!exchange.skipRoles?.has("assistant")) {
|
|
2367
|
+
lines.push({
|
|
2368
|
+
type: "turn",
|
|
2369
|
+
role: "assistant",
|
|
2370
|
+
content: truncateTurn(exchange.assistantContent, this.maxTurnBytes),
|
|
2371
|
+
jobId: exchange.jobId,
|
|
2372
|
+
ts
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
if (lines.length === 0) {
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
const payload = lines.map((line) => JSON.stringify(line)).join("\n") + "\n";
|
|
2379
|
+
const path = this.sessionPath(customerId, sessionId);
|
|
2380
|
+
try {
|
|
2381
|
+
appendFileSync(path, payload, { mode: FILE_MODE });
|
|
2382
|
+
} catch (error) {
|
|
2383
|
+
if (error.code !== "ENOENT") {
|
|
2384
|
+
throw error;
|
|
2385
|
+
}
|
|
2386
|
+
mkdirSync(join(this.root, customerId), { recursive: true, mode: DIR_MODE });
|
|
2387
|
+
appendFileSync(path, payload, { mode: FILE_MODE });
|
|
2388
|
+
}
|
|
2389
|
+
this.globalBytes += Buffer.byteLength(payload);
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Compaction rewrite: `[summary line, ...kept tail]`, atomic (temp + rename).
|
|
2393
|
+
* Returns the fresh replayed history. Throws on an empty summary - the
|
|
2394
|
+
* caller must treat that as a compaction failure, not rewrite on it (an
|
|
2395
|
+
* empty summary line is skipped at replay, silently vaporizing everything
|
|
2396
|
+
* it summarized).
|
|
2397
|
+
*/
|
|
2398
|
+
compact(customerId, sessionId, summary) {
|
|
2399
|
+
if (summary.trim().length === 0) {
|
|
2400
|
+
throw new Error("empty summary");
|
|
2401
|
+
}
|
|
2402
|
+
const rewritten = this.rewriteWithSummary(customerId, sessionId, summary);
|
|
2403
|
+
this.compactionStrikes.delete(sessionKey(customerId, sessionId));
|
|
2404
|
+
return rewritten;
|
|
2405
|
+
}
|
|
2406
|
+
/**
|
|
2407
|
+
* Record a compaction failure (summarize error or empty summary). After
|
|
2408
|
+
* `SESSION_COMPACTION_MAX_STRIKES` consecutive failures, force-truncate
|
|
2409
|
+
* without a summarize call - same canonical `[summary, ...kept tail]` shape
|
|
2410
|
+
* with the fixed `[earlier context unavailable]` content - so a small-window
|
|
2411
|
+
* model can never permanently brick a session into paid-job failures.
|
|
2412
|
+
*/
|
|
2413
|
+
compactionFailed(customerId, sessionId) {
|
|
2414
|
+
const key = sessionKey(customerId, sessionId);
|
|
2415
|
+
const strikes = (this.compactionStrikes.get(key) ?? 0) + 1;
|
|
2416
|
+
if (strikes < SESSION_COMPACTION_MAX_STRIKES) {
|
|
2417
|
+
this.compactionStrikes.set(key, strikes);
|
|
2418
|
+
return { forced: false };
|
|
2419
|
+
}
|
|
2420
|
+
this.compactionStrikes.delete(key);
|
|
2421
|
+
const messages = this.rewriteWithSummary(customerId, sessionId, SESSION_CONTEXT_UNAVAILABLE);
|
|
2422
|
+
this.log(
|
|
2423
|
+
`[sessions] ${sessionId.slice(0, 8)} force-truncated after repeated compaction failures`
|
|
2424
|
+
);
|
|
2425
|
+
return { forced: true, messages };
|
|
2426
|
+
}
|
|
2427
|
+
// ── Internals ──────────────────────────────────────────────────────────
|
|
2428
|
+
admittedEntry(key) {
|
|
2429
|
+
let entry = this.admitted.get(key);
|
|
2430
|
+
if (entry === void 0) {
|
|
2431
|
+
entry = { live: 0, recovery: /* @__PURE__ */ new Set() };
|
|
2432
|
+
this.admitted.set(key, entry);
|
|
2433
|
+
}
|
|
2434
|
+
return entry;
|
|
2435
|
+
}
|
|
2436
|
+
dropAdmittedIfEmpty(key, entry) {
|
|
2437
|
+
if (entry.live === 0 && entry.recovery.size === 0) {
|
|
2438
|
+
this.admitted.delete(key);
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
sessionPath(customerId, sessionId) {
|
|
2442
|
+
return join(this.root, customerId, `${sessionId}.jsonl`);
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* Parse the transcript. A torn LAST line (crash mid-append) is dropped AND
|
|
2446
|
+
* repaired away: the file is atomically rewritten without it, because a
|
|
2447
|
+
* later `appendFileSync` would otherwise glue its first line onto the torn
|
|
2448
|
+
* fragment (the fragment has no trailing newline), corrupting a mid-file
|
|
2449
|
+
* line. Any other malformed line - or a file over the backstop - is treated
|
|
2450
|
+
* as corruption: the file is renamed to a `.corrupt.<ts>` sidecar (outside
|
|
2451
|
+
* the byte counter, pruned by the TTL sweep) and the session starts fresh.
|
|
2452
|
+
* Callers hold the session mutex, so the repair cannot race an append.
|
|
2453
|
+
*/
|
|
2454
|
+
readSessionLines(customerId, sessionId) {
|
|
2455
|
+
const path = this.sessionPath(customerId, sessionId);
|
|
2456
|
+
let raw;
|
|
2457
|
+
let size;
|
|
2458
|
+
try {
|
|
2459
|
+
size = statSync(path).size;
|
|
2460
|
+
raw = readFileSync(path, "utf-8");
|
|
2461
|
+
} catch {
|
|
2462
|
+
return null;
|
|
2463
|
+
}
|
|
2464
|
+
if (size > this.maxFileBytes) {
|
|
2465
|
+
this.quarantine(customerId, sessionId, size);
|
|
2466
|
+
return null;
|
|
2467
|
+
}
|
|
2468
|
+
const rawLines = raw.split("\n");
|
|
2469
|
+
const lines = [];
|
|
2470
|
+
let torn = false;
|
|
2471
|
+
for (let index = 0; index < rawLines.length; index++) {
|
|
2472
|
+
const text = rawLines[index].trim();
|
|
2473
|
+
if (text.length === 0) {
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
const parsed = parseSessionLine(text);
|
|
2477
|
+
if (parsed === null) {
|
|
2478
|
+
const isLastNonEmpty = rawLines.slice(index + 1).every((rest) => rest.trim().length === 0);
|
|
2479
|
+
if (isLastNonEmpty) {
|
|
2480
|
+
torn = true;
|
|
2481
|
+
break;
|
|
2482
|
+
}
|
|
2483
|
+
this.quarantine(customerId, sessionId, size);
|
|
2484
|
+
return null;
|
|
2485
|
+
}
|
|
2486
|
+
lines.push(parsed);
|
|
2487
|
+
}
|
|
2488
|
+
if (torn) {
|
|
2489
|
+
const payload = lines.map((line) => JSON.stringify(line)).join("\n") + (lines.length > 0 ? "\n" : "");
|
|
2490
|
+
const tempPath = `${path}.tmp`;
|
|
2491
|
+
writeFileSync(tempPath, payload, { mode: FILE_MODE });
|
|
2492
|
+
renameSync(tempPath, path);
|
|
2493
|
+
this.globalBytes = Math.max(0, this.globalBytes - size + Buffer.byteLength(payload));
|
|
2494
|
+
}
|
|
2495
|
+
return lines;
|
|
2496
|
+
}
|
|
2497
|
+
quarantine(customerId, sessionId, size) {
|
|
2498
|
+
const path = this.sessionPath(customerId, sessionId);
|
|
2499
|
+
try {
|
|
2500
|
+
renameSync(path, `${path}.corrupt.${Date.now()}`);
|
|
2501
|
+
this.globalBytes = Math.max(0, this.globalBytes - size);
|
|
2502
|
+
this.compactionStrikes.delete(sessionKey(customerId, sessionId));
|
|
2503
|
+
this.log(`[sessions] ${sessionId.slice(0, 8)} corrupt; quarantined and reset`);
|
|
2504
|
+
} catch {
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
/**
|
|
2508
|
+
* Kept-tail start index: the longest suffix of TURN lines whose combined
|
|
2509
|
+
* stored size stays within the keep budget, floored at the most recent turn.
|
|
2510
|
+
*/
|
|
2511
|
+
keptTailStart(lines) {
|
|
2512
|
+
let budget = this.compactionKeepChars;
|
|
2513
|
+
let start = lines.length;
|
|
2514
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
2515
|
+
const line = lines[index];
|
|
2516
|
+
if (line.type !== "turn") {
|
|
2517
|
+
break;
|
|
2518
|
+
}
|
|
2519
|
+
const cost = line.content.length;
|
|
2520
|
+
if (cost > budget && start < lines.length) {
|
|
2521
|
+
break;
|
|
2522
|
+
}
|
|
2523
|
+
budget -= cost;
|
|
2524
|
+
start = index;
|
|
2525
|
+
if (budget <= 0) {
|
|
2526
|
+
break;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
if (start === lines.length && lines.length > 0) {
|
|
2530
|
+
start = lines.length - 1;
|
|
2531
|
+
}
|
|
2532
|
+
return start;
|
|
2533
|
+
}
|
|
2534
|
+
rewriteWithSummary(customerId, sessionId, summary) {
|
|
2535
|
+
validateKeys(customerId, sessionId);
|
|
2536
|
+
const lines = this.readSessionLines(customerId, sessionId) ?? [];
|
|
2537
|
+
const tailStart = this.keptTailStart(lines);
|
|
2538
|
+
const tail = lines.slice(tailStart).flatMap((line) => {
|
|
2539
|
+
if (line.type !== "turn") {
|
|
2540
|
+
return [];
|
|
2541
|
+
}
|
|
2542
|
+
if (line.content.length > this.compactionKeepChars) {
|
|
2543
|
+
return [
|
|
2544
|
+
{
|
|
2545
|
+
...line,
|
|
2546
|
+
content: line.content.slice(0, this.compactionKeepChars) + TRUNCATION_MARKER
|
|
2547
|
+
}
|
|
2548
|
+
];
|
|
2549
|
+
}
|
|
2550
|
+
return [line];
|
|
2551
|
+
});
|
|
2552
|
+
const summaryLine = {
|
|
2553
|
+
type: "summary",
|
|
2554
|
+
content: summary,
|
|
2555
|
+
ts: Math.floor(Date.now() / 1e3)
|
|
2556
|
+
};
|
|
2557
|
+
const rewritten = [summaryLine, ...tail];
|
|
2558
|
+
const payload = rewritten.map((line) => JSON.stringify(line)).join("\n") + "\n";
|
|
2559
|
+
const path = this.sessionPath(customerId, sessionId);
|
|
2560
|
+
const oldSize = existsSync(path) ? statSync(path).size : 0;
|
|
2561
|
+
const tempPath = `${path}.tmp`;
|
|
2562
|
+
mkdirSync(join(this.root, customerId), { recursive: true, mode: DIR_MODE });
|
|
2563
|
+
writeFileSync(tempPath, payload, { mode: FILE_MODE });
|
|
2564
|
+
renameSync(tempPath, path);
|
|
2565
|
+
this.globalBytes = Math.max(0, this.globalBytes - oldSize + Buffer.byteLength(payload));
|
|
2566
|
+
return assembleReplay(rewritten, void 0);
|
|
2567
|
+
}
|
|
2568
|
+
/** New-session admission under the per-customer cap: LRU-evict an idle victim. */
|
|
2569
|
+
ensureCustomerCapacity(customerId, sessionId) {
|
|
2570
|
+
const dir = join(this.root, customerId);
|
|
2571
|
+
let entries;
|
|
2572
|
+
try {
|
|
2573
|
+
entries = readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
|
|
2574
|
+
} catch {
|
|
2575
|
+
return true;
|
|
2576
|
+
}
|
|
2577
|
+
if (entries.length < this.maxPerCustomer) {
|
|
2578
|
+
return true;
|
|
2579
|
+
}
|
|
2580
|
+
const candidates = entries.map((name) => {
|
|
2581
|
+
const candidateSessionId = name.slice(0, -".jsonl".length);
|
|
2582
|
+
const path = join(dir, name);
|
|
2583
|
+
try {
|
|
2584
|
+
return { sessionId: candidateSessionId, mtimeMs: statSync(path).mtimeMs };
|
|
2585
|
+
} catch {
|
|
2586
|
+
return null;
|
|
2587
|
+
}
|
|
2588
|
+
}).filter(
|
|
2589
|
+
(candidate) => candidate !== null
|
|
2590
|
+
).sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
2591
|
+
for (const candidate of candidates) {
|
|
2592
|
+
if (candidate.sessionId === sessionId) {
|
|
2593
|
+
continue;
|
|
2594
|
+
}
|
|
2595
|
+
if (this.deleteIfIdle(customerId, candidate.sessionId)) {
|
|
2596
|
+
return true;
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
return false;
|
|
2600
|
+
}
|
|
2601
|
+
/**
|
|
2602
|
+
* Global LRU eviction down to the high-water mark. Excludes `selfKey` (the
|
|
2603
|
+
* arriving job's own session must not be its own victim).
|
|
2604
|
+
*/
|
|
2605
|
+
evictGlobal(selfKey) {
|
|
2606
|
+
const files = this.listSessionFiles().sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
2607
|
+
for (const file of files) {
|
|
2608
|
+
if (this.globalBytes <= this.globalEvictToBytes) {
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
if (sessionKey(file.customerId, file.sessionId) === selfKey) {
|
|
2612
|
+
continue;
|
|
2613
|
+
}
|
|
2614
|
+
this.deleteIfIdle(file.customerId, file.sessionId);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
/**
|
|
2618
|
+
* Delete a session iff idle: try-acquired mutex AND zero admitted count.
|
|
2619
|
+
* The mutex alone is not an in-flight guard - an admitted job spends its
|
|
2620
|
+
* whole payment phase before the mutex. Check and unlink are synchronous.
|
|
2621
|
+
*/
|
|
2622
|
+
deleteIfIdle(customerId, sessionId) {
|
|
2623
|
+
const key = sessionKey(customerId, sessionId);
|
|
2624
|
+
const release = this.tryAcquireSync(key);
|
|
2625
|
+
if (release === null) {
|
|
2626
|
+
return false;
|
|
2627
|
+
}
|
|
2628
|
+
try {
|
|
2629
|
+
if (this.admittedCount(customerId, sessionId) > 0) {
|
|
2630
|
+
return false;
|
|
2631
|
+
}
|
|
2632
|
+
this.deleteSessionFile(customerId, sessionId);
|
|
2633
|
+
return true;
|
|
2634
|
+
} finally {
|
|
2635
|
+
release();
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
deleteSessionFile(customerId, sessionId) {
|
|
2639
|
+
const path = this.sessionPath(customerId, sessionId);
|
|
2640
|
+
let size = 0;
|
|
2641
|
+
try {
|
|
2642
|
+
size = statSync(path).size;
|
|
2643
|
+
unlinkSync(path);
|
|
2644
|
+
} catch {
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
this.globalBytes = Math.max(0, this.globalBytes - size);
|
|
2648
|
+
this.compactionStrikes.delete(sessionKey(customerId, sessionId));
|
|
2649
|
+
}
|
|
2650
|
+
listSessionFiles() {
|
|
2651
|
+
const out = [];
|
|
2652
|
+
let customers;
|
|
2653
|
+
try {
|
|
2654
|
+
customers = readdirSync(this.root);
|
|
2655
|
+
} catch {
|
|
2656
|
+
return out;
|
|
2657
|
+
}
|
|
2658
|
+
for (const customerId of customers) {
|
|
2659
|
+
if (!CUSTOMER_PUBKEY_REGEX.test(customerId)) {
|
|
2660
|
+
continue;
|
|
2661
|
+
}
|
|
2662
|
+
let names;
|
|
2663
|
+
try {
|
|
2664
|
+
names = readdirSync(join(this.root, customerId));
|
|
2665
|
+
} catch {
|
|
2666
|
+
continue;
|
|
2667
|
+
}
|
|
2668
|
+
for (const name of names) {
|
|
2669
|
+
if (!name.endsWith(".jsonl")) {
|
|
2670
|
+
continue;
|
|
2671
|
+
}
|
|
2672
|
+
const sessionId = name.slice(0, -".jsonl".length);
|
|
2673
|
+
if (!SESSION_ID_REGEX.test(sessionId)) {
|
|
2674
|
+
continue;
|
|
2675
|
+
}
|
|
2676
|
+
try {
|
|
2677
|
+
const stat2 = statSync(join(this.root, customerId, name));
|
|
2678
|
+
out.push({ customerId, sessionId, size: stat2.size, mtimeMs: stat2.mtimeMs });
|
|
2679
|
+
} catch {
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
return out;
|
|
2684
|
+
}
|
|
2685
|
+
pruneSidecars(cutoffMs) {
|
|
2686
|
+
let customers;
|
|
2687
|
+
try {
|
|
2688
|
+
customers = readdirSync(this.root);
|
|
2689
|
+
} catch {
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
for (const customerId of customers) {
|
|
2693
|
+
const dir = join(this.root, customerId);
|
|
2694
|
+
let names;
|
|
2695
|
+
try {
|
|
2696
|
+
names = readdirSync(dir);
|
|
2697
|
+
} catch {
|
|
2698
|
+
continue;
|
|
2699
|
+
}
|
|
2700
|
+
for (const name of names) {
|
|
2701
|
+
if (!name.includes(".corrupt.")) {
|
|
2702
|
+
continue;
|
|
2703
|
+
}
|
|
2704
|
+
const path = join(dir, name);
|
|
2705
|
+
try {
|
|
2706
|
+
if (statSync(path).mtimeMs < cutoffMs) {
|
|
2707
|
+
unlinkSync(path);
|
|
2708
|
+
}
|
|
2709
|
+
} catch {
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
pruneEmptyCustomerDirs() {
|
|
2715
|
+
let customers;
|
|
2716
|
+
try {
|
|
2717
|
+
customers = readdirSync(this.root);
|
|
2718
|
+
} catch {
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
for (const customerId of customers) {
|
|
2722
|
+
const dir = join(this.root, customerId);
|
|
2723
|
+
try {
|
|
2724
|
+
if (readdirSync(dir).length === 0) {
|
|
2725
|
+
rmdirSync(dir);
|
|
2726
|
+
}
|
|
2727
|
+
} catch {
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
};
|
|
2732
|
+
function sessionKey(customerId, sessionId) {
|
|
2733
|
+
return `${customerId}/${sessionId}`;
|
|
2734
|
+
}
|
|
2735
|
+
function validateKeys(customerId, sessionId) {
|
|
2736
|
+
if (!CUSTOMER_PUBKEY_REGEX.test(customerId)) {
|
|
2737
|
+
throw new Error("SessionStore: invalid customer pubkey");
|
|
2738
|
+
}
|
|
2739
|
+
if (!SESSION_ID_REGEX.test(sessionId)) {
|
|
2740
|
+
throw new Error("SessionStore: invalid session id");
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
function parseSessionLine(text) {
|
|
2744
|
+
let value;
|
|
2745
|
+
try {
|
|
2746
|
+
value = JSON.parse(text);
|
|
2747
|
+
} catch {
|
|
2748
|
+
return null;
|
|
2749
|
+
}
|
|
2750
|
+
if (typeof value !== "object" || value === null) {
|
|
2751
|
+
return null;
|
|
2752
|
+
}
|
|
2753
|
+
const record = value;
|
|
2754
|
+
if (record.type === "summary") {
|
|
2755
|
+
if (typeof record.content !== "string" || typeof record.ts !== "number") {
|
|
2756
|
+
return null;
|
|
2757
|
+
}
|
|
2758
|
+
return { type: "summary", content: record.content, ts: record.ts };
|
|
2759
|
+
}
|
|
2760
|
+
if (record.type === "turn") {
|
|
2761
|
+
if (record.role !== "user" && record.role !== "assistant" || typeof record.content !== "string" || typeof record.jobId !== "string" || typeof record.ts !== "number") {
|
|
2762
|
+
return null;
|
|
2763
|
+
}
|
|
2764
|
+
return {
|
|
2765
|
+
type: "turn",
|
|
2766
|
+
role: record.role,
|
|
2767
|
+
content: record.content,
|
|
2768
|
+
jobId: record.jobId,
|
|
2769
|
+
capability: typeof record.capability === "string" ? record.capability : void 0,
|
|
2770
|
+
ts: record.ts
|
|
2771
|
+
};
|
|
2772
|
+
}
|
|
2773
|
+
return null;
|
|
2774
|
+
}
|
|
2775
|
+
function assembleReplay(lines, excludeJobId) {
|
|
2776
|
+
const raw = [];
|
|
2777
|
+
for (const line of lines) {
|
|
2778
|
+
if (line.type === "summary") {
|
|
2779
|
+
raw.push({ role: "user", content: `${SESSION_SUMMARY_PREFIX}
|
|
2780
|
+
${line.content}` });
|
|
2781
|
+
} else if (line.jobId !== excludeJobId) {
|
|
2782
|
+
raw.push({ role: line.role, content: line.content });
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
const nonEmpty = raw.filter((message) => message.content.trim().length > 0);
|
|
2786
|
+
let start = 0;
|
|
2787
|
+
while (start < nonEmpty.length && nonEmpty[start].role === "assistant") {
|
|
2788
|
+
start++;
|
|
2789
|
+
}
|
|
2790
|
+
let end = nonEmpty.length;
|
|
2791
|
+
while (end > start && nonEmpty[end - 1].role === "user") {
|
|
2792
|
+
end--;
|
|
2793
|
+
}
|
|
2794
|
+
const bounded = nonEmpty.slice(start, end);
|
|
2795
|
+
const merged = [];
|
|
2796
|
+
for (const message of bounded) {
|
|
2797
|
+
const previous = merged[merged.length - 1];
|
|
2798
|
+
if (previous !== void 0 && previous.role === message.role) {
|
|
2799
|
+
previous.content = `${previous.content}
|
|
2800
|
+
|
|
2801
|
+
${message.content}`;
|
|
2802
|
+
} else {
|
|
2803
|
+
merged.push({ ...message });
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
return merged;
|
|
2807
|
+
}
|
|
2808
|
+
function buildSummarizeText(lines, tailStart) {
|
|
2809
|
+
const parts = [];
|
|
2810
|
+
for (const line of lines.slice(0, tailStart)) {
|
|
2811
|
+
if (line.type === "summary") {
|
|
2812
|
+
parts.push(`Summary of earlier conversation:
|
|
2813
|
+
${line.content}`);
|
|
2814
|
+
} else {
|
|
2815
|
+
parts.push(`${line.role === "user" ? "User" : "Assistant"}: ${line.content}`);
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
return parts.join("\n\n");
|
|
2819
|
+
}
|
|
2820
|
+
function truncateTurn(content, maxBytes) {
|
|
2821
|
+
if (Buffer.byteLength(content) <= maxBytes) {
|
|
2822
|
+
return content;
|
|
2823
|
+
}
|
|
2824
|
+
let sliced = content.slice(0, maxBytes - TRUNCATION_MARKER.length);
|
|
2825
|
+
while (Buffer.byteLength(sliced) > maxBytes - TRUNCATION_MARKER.length) {
|
|
2826
|
+
sliced = sliced.slice(0, Math.floor(sliced.length * 0.9));
|
|
2827
|
+
}
|
|
2828
|
+
return sliced + TRUNCATION_MARKER;
|
|
2829
|
+
}
|
|
2074
2830
|
|
|
2075
2831
|
// src/x402/errors.ts
|
|
2076
2832
|
var X402PreflightError = class extends Error {
|
|
@@ -2113,6 +2869,29 @@ var LEDGER_GC_INTERVAL_MS = 60 * 60 * 1e3;
|
|
|
2113
2869
|
var LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
2114
2870
|
var MAX_PAID_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
2115
2871
|
var SIG_PATH_TIMEOUT_MS = 60 * 1e3;
|
|
2872
|
+
var SESSION_SUMMARIZE_SYSTEM = "Summarize the following conversation between a user and an assistant so the conversation can continue with your summary standing in for the older turns. Preserve facts, decisions, names, numbers, constraints, and open questions. Reply with the summary only.";
|
|
2873
|
+
function buildUserRecord(data, fileName) {
|
|
2874
|
+
if (fileName === void 0) {
|
|
2875
|
+
return data;
|
|
2876
|
+
}
|
|
2877
|
+
const placeholder = `[file input: ${fileName}]`;
|
|
2878
|
+
return data.length > 0 ? `${data}
|
|
2879
|
+
${placeholder}` : placeholder;
|
|
2880
|
+
}
|
|
2881
|
+
function buildAssistantRecord(output) {
|
|
2882
|
+
let filePaths = [];
|
|
2883
|
+
if (output.filePaths !== void 0 && output.filePaths.length > 0) {
|
|
2884
|
+
filePaths = output.filePaths;
|
|
2885
|
+
} else if (output.filePath !== void 0) {
|
|
2886
|
+
filePaths = [output.filePath];
|
|
2887
|
+
}
|
|
2888
|
+
if (filePaths.length === 0) {
|
|
2889
|
+
return output.data;
|
|
2890
|
+
}
|
|
2891
|
+
const placeholders = filePaths.map((path) => `[file result: ${basename(path)}]`).join("\n");
|
|
2892
|
+
return output.data.length > 0 ? `${output.data}
|
|
2893
|
+
${placeholders}` : placeholders;
|
|
2894
|
+
}
|
|
2116
2895
|
function resolveJobPrice(tags, skills) {
|
|
2117
2896
|
const skill = skills.route(tags);
|
|
2118
2897
|
return skill?.priceSubunits ?? 0;
|
|
@@ -2229,7 +3008,7 @@ var PAID_GLOBAL_MAX_JOBS_PER_WINDOW = 2e3;
|
|
|
2229
3008
|
var MAX_TRACKED_CUSTOMERS = 1e3;
|
|
2230
3009
|
var GLOBAL_LIMITER_KEY = "__global__";
|
|
2231
3010
|
var AgentRuntime = class {
|
|
2232
|
-
constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor, irohTransport, identity, blossomTransport) {
|
|
3011
|
+
constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor, irohTransport, identity, blossomTransport, sessionStore) {
|
|
2233
3012
|
this.transport = transport;
|
|
2234
3013
|
this.skills = skills;
|
|
2235
3014
|
this.skillCtx = skillCtx;
|
|
@@ -2240,6 +3019,7 @@ var AgentRuntime = class {
|
|
|
2240
3019
|
this.irohTransport = irohTransport;
|
|
2241
3020
|
this.identity = identity;
|
|
2242
3021
|
this.blossomTransport = blossomTransport;
|
|
3022
|
+
this.sessionStore = sessionStore;
|
|
2243
3023
|
this.limit = pLimit(config.maxConcurrentJobs);
|
|
2244
3024
|
this.maxQueueSize = config.maxQueueSize ?? config.maxConcurrentJobs * 10;
|
|
2245
3025
|
}
|
|
@@ -2287,6 +3067,102 @@ var AgentRuntime = class {
|
|
|
2287
3067
|
* the operator's API key.
|
|
2288
3068
|
*/
|
|
2289
3069
|
freeLlmLimiters = createFreeLlmLimiterSet();
|
|
3070
|
+
/**
|
|
3071
|
+
* Whether a job takes the session path: session ref present (transport only
|
|
3072
|
+
* populates it for NIP-44-encrypted requests) AND the matched skill opted in
|
|
3073
|
+
* (`context: true` - llm or dynamic-script mode) AND a session store is
|
|
3074
|
+
* wired. Everything else processes stateless - deterministically, never
|
|
3075
|
+
* implicitly per-pubkey.
|
|
3076
|
+
*/
|
|
3077
|
+
resolveJobSession(job, skill) {
|
|
3078
|
+
if (job.session === void 0 || this.sessionStore === void 0 || skill === null || skill === void 0 || skill.mode !== "llm" && skill.mode !== "dynamic-script" || skill.context !== true) {
|
|
3079
|
+
return null;
|
|
3080
|
+
}
|
|
3081
|
+
return {
|
|
3082
|
+
store: this.sessionStore,
|
|
3083
|
+
customerId: job.customerId,
|
|
3084
|
+
sessionId: job.session.id,
|
|
3085
|
+
skill
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* Run a session-path execution: history load -> execute -> append, under the
|
|
3090
|
+
* session mutex. Lock-order invariant: callers already hold their `pLimit`
|
|
3091
|
+
* slot (live and recovery paths alike), so a mutex holder never waits for a
|
|
3092
|
+
* slot. The mutex is released after the append, BEFORE result seeding and
|
|
3093
|
+
* delivery - it never spans that network I/O (the execute callback's own LLM
|
|
3094
|
+
* call, and the compaction summarize below, are bounded by the LLM HTTP
|
|
3095
|
+
* timeout). Appends only happen on execution success; a failed job leaves
|
|
3096
|
+
* the transcript untouched.
|
|
3097
|
+
*/
|
|
3098
|
+
async runSessionExchange(params) {
|
|
3099
|
+
const { store, customerId, sessionId, skill } = params.session;
|
|
3100
|
+
const release = await store.acquire(customerId, sessionId);
|
|
3101
|
+
try {
|
|
3102
|
+
const opened = store.open(
|
|
3103
|
+
customerId,
|
|
3104
|
+
sessionId,
|
|
3105
|
+
params.excludeOwnTurns === true ? params.jobId : void 0
|
|
3106
|
+
);
|
|
3107
|
+
let messages = opened.messages;
|
|
3108
|
+
if (!opened.stateless && opened.compactionNeeded && opened.summarizeText !== void 0) {
|
|
3109
|
+
messages = await this.compactSession({
|
|
3110
|
+
store,
|
|
3111
|
+
customerId,
|
|
3112
|
+
sessionId,
|
|
3113
|
+
skill,
|
|
3114
|
+
summarizeText: opened.summarizeText,
|
|
3115
|
+
fallbackMessages: messages,
|
|
3116
|
+
signal: params.signal,
|
|
3117
|
+
log: params.log,
|
|
3118
|
+
jobId: params.jobId
|
|
3119
|
+
});
|
|
3120
|
+
}
|
|
3121
|
+
const history = opened.stateless || messages.length === 0 ? void 0 : messages;
|
|
3122
|
+
const output = await params.execute(history);
|
|
3123
|
+
if (!opened.stateless) {
|
|
3124
|
+
store.appendExchange(customerId, sessionId, {
|
|
3125
|
+
jobId: params.jobId,
|
|
3126
|
+
capability: skill.name,
|
|
3127
|
+
userContent: params.userRecord,
|
|
3128
|
+
assistantContent: buildAssistantRecord(output),
|
|
3129
|
+
skipRoles: opened.recordedRoles
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
return output;
|
|
3133
|
+
} finally {
|
|
3134
|
+
release();
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
/**
|
|
3138
|
+
* Compaction: one summarize call on the invoked skill's own resolved LLM,
|
|
3139
|
+
* then an atomic rewrite to `[summary, ...kept tail]`. Any failure - thrown
|
|
3140
|
+
* error, missing client, or an empty-string summary (rewriting on it would
|
|
3141
|
+
* silently vaporize the summarized context, since empty messages are skipped
|
|
3142
|
+
* at replay) - counts a strike and processes this job with the uncompacted
|
|
3143
|
+
* history; after 2 consecutive strikes the store force-truncates without a
|
|
3144
|
+
* summary so a small-window model can never permanently brick a session.
|
|
3145
|
+
*/
|
|
3146
|
+
async compactSession(params) {
|
|
3147
|
+
const { store, customerId, sessionId, skill } = params;
|
|
3148
|
+
try {
|
|
3149
|
+
const llm = this.skillCtx.getLlm?.(skill.llmOverride) ?? this.skillCtx.llm;
|
|
3150
|
+
if (llm === void 0) {
|
|
3151
|
+
throw new Error("no LLM client available for session compaction");
|
|
3152
|
+
}
|
|
3153
|
+
const summary = await llm.complete(
|
|
3154
|
+
SESSION_SUMMARIZE_SYSTEM,
|
|
3155
|
+
params.summarizeText,
|
|
3156
|
+
params.signal
|
|
3157
|
+
);
|
|
3158
|
+
return store.compact(customerId, sessionId, summary);
|
|
3159
|
+
} catch (err) {
|
|
3160
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
3161
|
+
params.log(`[${params.jobId.slice(0, 8)}] Session compaction failed: ${detail}`);
|
|
3162
|
+
const outcome = store.compactionFailed(customerId, sessionId);
|
|
3163
|
+
return outcome.forced && outcome.messages !== void 0 ? outcome.messages : params.fallbackMessages;
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
2290
3166
|
/**
|
|
2291
3167
|
* Inspect an error thrown by `skill.execute()` and, when it carries a
|
|
2292
3168
|
* billing/invalid signal from the LLM provider, flip the matching
|
|
@@ -2431,6 +3307,7 @@ var AgentRuntime = class {
|
|
|
2431
3307
|
async run() {
|
|
2432
3308
|
const log = this.callbacks.onLog ?? console.log;
|
|
2433
3309
|
this.ledger.pruneOldEntries(LEDGER_RETENTION_MS);
|
|
3310
|
+
this.sessionStore?.init();
|
|
2434
3311
|
await this.recoverPendingJobs();
|
|
2435
3312
|
this.recoveryInterval = setInterval(
|
|
2436
3313
|
() => this.recoverPendingJobs().catch((e) => log(`Recovery error: ${e}`)),
|
|
@@ -2442,6 +3319,11 @@ var AgentRuntime = class {
|
|
|
2442
3319
|
} catch (e) {
|
|
2443
3320
|
log(`GC error: ${e.message}`);
|
|
2444
3321
|
}
|
|
3322
|
+
try {
|
|
3323
|
+
this.sessionStore?.gc();
|
|
3324
|
+
} catch (e) {
|
|
3325
|
+
log(`Session GC error: ${e.message}`);
|
|
3326
|
+
}
|
|
2445
3327
|
this.cleanupRateLimits();
|
|
2446
3328
|
if (!this.transport.isHealthy()) {
|
|
2447
3329
|
log("Warning: no events received in 30+ minutes. Check relay connectivity.");
|
|
@@ -2466,6 +3348,15 @@ var AgentRuntime = class {
|
|
|
2466
3348
|
const isPaid = matched ? matched.priceSubunits > 0 : false;
|
|
2467
3349
|
const customerLimiter = isPaid ? this.paidCustomerLimiter : this.freeCustomerLimiter;
|
|
2468
3350
|
const globalLimiter = isPaid ? this.paidGlobalLimiter : this.freeGlobalLimiter;
|
|
3351
|
+
const jobSession = this.resolveJobSession(job, matched);
|
|
3352
|
+
if (jobSession && jobSession.store.admittedCount(jobSession.customerId, jobSession.sessionId) >= SESSION_MAX_CONCURRENT_JOBS) {
|
|
3353
|
+
this.transport.sendFeedback(job, {
|
|
3354
|
+
type: "error",
|
|
3355
|
+
message: "Session busy - wait for the previous message to finish, then retry."
|
|
3356
|
+
}).catch(() => {
|
|
3357
|
+
});
|
|
3358
|
+
return;
|
|
3359
|
+
}
|
|
2469
3360
|
if (!customerLimiter.peek(job.customerId).allowed) {
|
|
2470
3361
|
this.transport.sendFeedback(job, { type: "error", message: "Rate limited, try again later" }).catch(() => {
|
|
2471
3362
|
});
|
|
@@ -2505,11 +3396,17 @@ var AgentRuntime = class {
|
|
|
2505
3396
|
this.callbacks.onJobReceived?.(job);
|
|
2506
3397
|
this.inFlight.add(job.jobId);
|
|
2507
3398
|
this.pending++;
|
|
3399
|
+
if (jobSession) {
|
|
3400
|
+
jobSession.store.admitLive(jobSession.customerId, jobSession.sessionId);
|
|
3401
|
+
}
|
|
2508
3402
|
this.limit(() => this.processJob(job)).catch((e) => {
|
|
2509
3403
|
this.callbacks.onJobError?.(job.jobId, e.message);
|
|
2510
3404
|
}).finally(() => {
|
|
2511
3405
|
this.inFlight.delete(job.jobId);
|
|
2512
3406
|
this.pending--;
|
|
3407
|
+
if (jobSession) {
|
|
3408
|
+
jobSession.store.releaseLive(jobSession.customerId, jobSession.sessionId);
|
|
3409
|
+
}
|
|
2513
3410
|
});
|
|
2514
3411
|
});
|
|
2515
3412
|
log("Agent runtime started. Listening for jobs...");
|
|
@@ -2704,68 +3601,82 @@ var AgentRuntime = class {
|
|
|
2704
3601
|
throw new Error("No skill matched for tags: " + job.tags.join(", "));
|
|
2705
3602
|
}
|
|
2706
3603
|
log(`[${job.jobId.slice(0, 8)}] Executing skill: ${skill.name}`);
|
|
2707
|
-
let output;
|
|
2708
3604
|
const budgetMs = this.resolveExecutionBudgetMs(skill);
|
|
2709
|
-
|
|
2710
|
-
const
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
execAbort.abort();
|
|
2715
|
-
} else {
|
|
2716
|
-
signal.addEventListener("abort", onOuterAbort);
|
|
2717
|
-
}
|
|
2718
|
-
}
|
|
2719
|
-
let budgetTimer;
|
|
2720
|
-
try {
|
|
2721
|
-
const execPromise = skill.execute(
|
|
2722
|
-
{
|
|
2723
|
-
data: inputFile?.inlineText ?? job.input,
|
|
2724
|
-
inputType: job.inputType,
|
|
2725
|
-
tags: job.tags,
|
|
2726
|
-
jobId: job.jobId,
|
|
2727
|
-
filePath: inputFile?.filePath
|
|
2728
|
-
},
|
|
2729
|
-
{ ...this.skillCtx, signal: execAbort.signal }
|
|
2730
|
-
);
|
|
2731
|
-
execPromise.catch(() => {
|
|
2732
|
-
});
|
|
2733
|
-
if (budgetMs > 0) {
|
|
2734
|
-
output = await Promise.race([
|
|
2735
|
-
execPromise,
|
|
2736
|
-
new Promise((_resolve, reject) => {
|
|
2737
|
-
budgetTimer = setTimeout(() => {
|
|
2738
|
-
budgetExceeded = true;
|
|
2739
|
-
execAbort.abort();
|
|
2740
|
-
reject(new ExecutionBudgetExceededError(budgetMs));
|
|
2741
|
-
}, budgetMs);
|
|
2742
|
-
})
|
|
2743
|
-
]);
|
|
2744
|
-
} else {
|
|
2745
|
-
output = await execPromise;
|
|
2746
|
-
}
|
|
2747
|
-
} catch (err) {
|
|
2748
|
-
if (budgetExceeded) {
|
|
2749
|
-
log(`[${job.jobId.slice(0, 8)}] Execution exceeded budget (${budgetMs / 1e3}s)`);
|
|
2750
|
-
throw new ExecutionBudgetExceededError(budgetMs);
|
|
2751
|
-
}
|
|
2752
|
-
const flippedToUnhealthy = this.markHealthFromExecuteError(skill, err, log, job.jobId);
|
|
2753
|
-
if (flippedToUnhealthy) {
|
|
2754
|
-
throw new AgentUnavailableError();
|
|
2755
|
-
}
|
|
2756
|
-
throw err;
|
|
2757
|
-
} finally {
|
|
2758
|
-
if (budgetTimer) {
|
|
2759
|
-
clearTimeout(budgetTimer);
|
|
2760
|
-
}
|
|
3605
|
+
const jobSession = this.resolveJobSession(job, skill);
|
|
3606
|
+
const runExecution = async (history) => {
|
|
3607
|
+
let budgetExceeded = false;
|
|
3608
|
+
const execAbort = new AbortController();
|
|
3609
|
+
const onOuterAbort = () => execAbort.abort();
|
|
2761
3610
|
if (signal) {
|
|
2762
|
-
signal.
|
|
3611
|
+
if (signal.aborted) {
|
|
3612
|
+
execAbort.abort();
|
|
3613
|
+
} else {
|
|
3614
|
+
signal.addEventListener("abort", onOuterAbort);
|
|
3615
|
+
}
|
|
2763
3616
|
}
|
|
2764
|
-
|
|
2765
|
-
|
|
3617
|
+
let budgetTimer;
|
|
3618
|
+
try {
|
|
3619
|
+
const execPromise = skill.execute(
|
|
3620
|
+
{
|
|
3621
|
+
data: inputFile?.inlineText ?? job.input,
|
|
3622
|
+
inputType: job.inputType,
|
|
3623
|
+
tags: job.tags,
|
|
3624
|
+
jobId: job.jobId,
|
|
3625
|
+
filePath: inputFile?.filePath,
|
|
3626
|
+
history,
|
|
3627
|
+
...jobSession !== null ? { sessionId: jobSession.sessionId } : {}
|
|
3628
|
+
},
|
|
3629
|
+
{ ...this.skillCtx, signal: execAbort.signal }
|
|
3630
|
+
);
|
|
3631
|
+
execPromise.catch(() => {
|
|
2766
3632
|
});
|
|
3633
|
+
if (budgetMs > 0) {
|
|
3634
|
+
return await Promise.race([
|
|
3635
|
+
execPromise,
|
|
3636
|
+
new Promise((_resolve, reject) => {
|
|
3637
|
+
budgetTimer = setTimeout(() => {
|
|
3638
|
+
budgetExceeded = true;
|
|
3639
|
+
execAbort.abort();
|
|
3640
|
+
reject(new ExecutionBudgetExceededError(budgetMs));
|
|
3641
|
+
}, budgetMs);
|
|
3642
|
+
})
|
|
3643
|
+
]);
|
|
3644
|
+
}
|
|
3645
|
+
return await execPromise;
|
|
3646
|
+
} catch (err) {
|
|
3647
|
+
if (budgetExceeded) {
|
|
3648
|
+
log(`[${job.jobId.slice(0, 8)}] Execution exceeded budget (${budgetMs / 1e3}s)`);
|
|
3649
|
+
throw new ExecutionBudgetExceededError(budgetMs);
|
|
3650
|
+
}
|
|
3651
|
+
const flippedToUnhealthy = this.markHealthFromExecuteError(skill, err, log, job.jobId);
|
|
3652
|
+
if (flippedToUnhealthy) {
|
|
3653
|
+
throw new AgentUnavailableError();
|
|
3654
|
+
}
|
|
3655
|
+
throw err;
|
|
3656
|
+
} finally {
|
|
3657
|
+
if (budgetTimer) {
|
|
3658
|
+
clearTimeout(budgetTimer);
|
|
3659
|
+
}
|
|
3660
|
+
if (signal) {
|
|
3661
|
+
signal.removeEventListener("abort", onOuterAbort);
|
|
3662
|
+
}
|
|
3663
|
+
if (inputFile) {
|
|
3664
|
+
await inputFile.cleanup().catch(() => {
|
|
3665
|
+
});
|
|
3666
|
+
}
|
|
2767
3667
|
}
|
|
2768
|
-
}
|
|
3668
|
+
};
|
|
3669
|
+
const output = jobSession === null ? await runExecution() : await this.runSessionExchange({
|
|
3670
|
+
session: jobSession,
|
|
3671
|
+
jobId: job.jobId,
|
|
3672
|
+
userRecord: buildUserRecord(
|
|
3673
|
+
inputFile?.inlineText ?? job.input,
|
|
3674
|
+
inputFile?.filePath !== void 0 ? job.attachment?.name ?? "attachment" : void 0
|
|
3675
|
+
),
|
|
3676
|
+
signal,
|
|
3677
|
+
log,
|
|
3678
|
+
execute: runExecution
|
|
3679
|
+
});
|
|
2769
3680
|
const { attachments, deliveredContent } = await this.buildResultAttachment(
|
|
2770
3681
|
job.jobId,
|
|
2771
3682
|
output,
|
|
@@ -3272,8 +4183,52 @@ var AgentRuntime = class {
|
|
|
3272
4183
|
}
|
|
3273
4184
|
throw new PaymentTimeoutError();
|
|
3274
4185
|
}
|
|
4186
|
+
/**
|
|
4187
|
+
* Session refs of pending `paid` entries, for the recovery pre-pass. `paid`
|
|
4188
|
+
* only: `executed` entries do no session work and the status-predicate
|
|
4189
|
+
* release would drop them at the first re-check anyway. Applies the same
|
|
4190
|
+
* session-path scope as live intake (encrypted-only decode + routed skill
|
|
4191
|
+
* with `context: true`, llm or dynamic-script mode) - a context-off or x402
|
|
4192
|
+
* entry parked for hours must not pin session slots for a job that never
|
|
4193
|
+
* touches the transcript.
|
|
4194
|
+
*/
|
|
4195
|
+
collectRecoverySessionRefs(pending) {
|
|
4196
|
+
const refs = [];
|
|
4197
|
+
for (const entry of pending) {
|
|
4198
|
+
if (entry.status !== "paid" || entry.raw_event_json === void 0) {
|
|
4199
|
+
continue;
|
|
4200
|
+
}
|
|
4201
|
+
const skill = this.skills.route(entry.tags);
|
|
4202
|
+
if (!skill || skill.mode !== "llm" && skill.mode !== "dynamic-script" || skill.context !== true) {
|
|
4203
|
+
continue;
|
|
4204
|
+
}
|
|
4205
|
+
try {
|
|
4206
|
+
const rawEvent = JSON.parse(entry.raw_event_json);
|
|
4207
|
+
const encrypted = rawEvent.tags?.some((tag) => tag[0] === "encrypted" && tag[1] === "nip44") === true;
|
|
4208
|
+
if (!encrypted || typeof rawEvent.content !== "string") {
|
|
4209
|
+
continue;
|
|
4210
|
+
}
|
|
4211
|
+
const session = decodeJobPayload(rawEvent.content).session;
|
|
4212
|
+
if (session !== void 0) {
|
|
4213
|
+
refs.push({
|
|
4214
|
+
jobId: entry.job_id,
|
|
4215
|
+
customerId: entry.customer_id,
|
|
4216
|
+
sessionId: session.id
|
|
4217
|
+
});
|
|
4218
|
+
}
|
|
4219
|
+
} catch {
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
return refs;
|
|
4223
|
+
}
|
|
3275
4224
|
async recoverPendingJobs() {
|
|
3276
4225
|
const pending = this.ledger.pendingJobs().filter((e) => !this.inFlight.has(e.job_id));
|
|
4226
|
+
if (this.sessionStore !== void 0) {
|
|
4227
|
+
this.sessionStore.syncRecoveryRegistrations(
|
|
4228
|
+
this.collectRecoverySessionRefs(pending),
|
|
4229
|
+
(jobId) => this.ledger.getStatus(jobId) === "paid"
|
|
4230
|
+
);
|
|
4231
|
+
}
|
|
3277
4232
|
if (pending.length === 0) {
|
|
3278
4233
|
return;
|
|
3279
4234
|
}
|
|
@@ -3304,7 +4259,7 @@ var AgentRuntime = class {
|
|
|
3304
4259
|
for (const entry of pending) {
|
|
3305
4260
|
const ageMs = (Math.floor(Date.now() / 1e3) - entry.created_at) * 1e3;
|
|
3306
4261
|
const expired = ageMs > MAX_PAID_AGE_MS;
|
|
3307
|
-
const exhaustedRetries = entry.retry_count >= this.config.recoveryMaxRetries;
|
|
4262
|
+
const exhaustedRetries = entry.status === "paid" && entry.retry_count >= this.config.recoveryMaxRetries;
|
|
3308
4263
|
if (expired || exhaustedRetries) {
|
|
3309
4264
|
this.ledger.markFailed(entry.job_id);
|
|
3310
4265
|
const reason = expired ? "Job permanently failed: agent did not recover within 24 hours" : "Job permanently failed after maximum retries";
|
|
@@ -3444,14 +4399,17 @@ var AgentRuntime = class {
|
|
|
3444
4399
|
}
|
|
3445
4400
|
}
|
|
3446
4401
|
let recoveryInputFile;
|
|
4402
|
+
let recoverySession;
|
|
3447
4403
|
try {
|
|
3448
4404
|
const encrypted = rawEvent.tags?.some(
|
|
3449
4405
|
(tag) => tag[0] === "encrypted" && tag[1] === "nip44"
|
|
3450
4406
|
);
|
|
3451
4407
|
const iTag = rawEvent.tags?.find((tag) => tag[0] === "i");
|
|
3452
4408
|
const rawInput = encrypted ? rawEvent.content : iTag?.[1] ?? rawEvent.content;
|
|
4409
|
+
const decodedPayload = decodeJobPayload(rawInput);
|
|
4410
|
+
recoverySession = encrypted ? decodedPayload.session : void 0;
|
|
3453
4411
|
recoveryInputFile = await this.resolveInputFile(
|
|
3454
|
-
|
|
4412
|
+
decodedPayload.attachment,
|
|
3455
4413
|
entry.customer_id,
|
|
3456
4414
|
recoveryAbort.signal
|
|
3457
4415
|
);
|
|
@@ -3461,46 +4419,64 @@ var AgentRuntime = class {
|
|
|
3461
4419
|
return;
|
|
3462
4420
|
}
|
|
3463
4421
|
const recoveryBudgetMs = this.resolveExecutionBudgetMs(skill);
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
recoveryAbort.abort();
|
|
3485
|
-
reject(new ExecutionBudgetExceededError(recoveryBudgetMs));
|
|
3486
|
-
}, recoveryBudgetMs);
|
|
3487
|
-
})
|
|
3488
|
-
]);
|
|
3489
|
-
} else {
|
|
3490
|
-
output = await execPromise;
|
|
3491
|
-
}
|
|
3492
|
-
} catch (err) {
|
|
3493
|
-
this.markHealthFromExecuteError(skill, err, log, entry.job_id);
|
|
3494
|
-
throw err;
|
|
3495
|
-
} finally {
|
|
3496
|
-
if (budgetTimer) {
|
|
3497
|
-
clearTimeout(budgetTimer);
|
|
3498
|
-
}
|
|
3499
|
-
if (recoveryInputFile) {
|
|
3500
|
-
await recoveryInputFile.cleanup().catch(() => {
|
|
4422
|
+
const recoveryJobSession = this.resolveJobSession(
|
|
4423
|
+
{ session: recoverySession, customerId: entry.customer_id },
|
|
4424
|
+
skill
|
|
4425
|
+
);
|
|
4426
|
+
const runRecoveryExecution = async (history) => {
|
|
4427
|
+
let budgetTimer;
|
|
4428
|
+
try {
|
|
4429
|
+
const execPromise = skill.execute(
|
|
4430
|
+
{
|
|
4431
|
+
data: recoveryInputFile?.inlineText ?? entry.input,
|
|
4432
|
+
inputType: entry.input_type,
|
|
4433
|
+
tags: entry.tags,
|
|
4434
|
+
jobId: entry.job_id,
|
|
4435
|
+
filePath: recoveryInputFile?.filePath,
|
|
4436
|
+
history,
|
|
4437
|
+
...recoveryJobSession !== null ? { sessionId: recoveryJobSession.sessionId } : {}
|
|
4438
|
+
},
|
|
4439
|
+
{ ...this.skillCtx, signal: recoveryAbort.signal }
|
|
4440
|
+
);
|
|
4441
|
+
execPromise.catch(() => {
|
|
3501
4442
|
});
|
|
4443
|
+
if (recoveryBudgetMs > 0) {
|
|
4444
|
+
return await Promise.race([
|
|
4445
|
+
execPromise,
|
|
4446
|
+
new Promise((_resolve, reject) => {
|
|
4447
|
+
budgetTimer = setTimeout(() => {
|
|
4448
|
+
recoveryAbort.abort();
|
|
4449
|
+
reject(new ExecutionBudgetExceededError(recoveryBudgetMs));
|
|
4450
|
+
}, recoveryBudgetMs);
|
|
4451
|
+
})
|
|
4452
|
+
]);
|
|
4453
|
+
}
|
|
4454
|
+
return await execPromise;
|
|
4455
|
+
} catch (err) {
|
|
4456
|
+
this.markHealthFromExecuteError(skill, err, log, entry.job_id);
|
|
4457
|
+
throw err;
|
|
4458
|
+
} finally {
|
|
4459
|
+
if (budgetTimer) {
|
|
4460
|
+
clearTimeout(budgetTimer);
|
|
4461
|
+
}
|
|
4462
|
+
if (recoveryInputFile) {
|
|
4463
|
+
await recoveryInputFile.cleanup().catch(() => {
|
|
4464
|
+
});
|
|
4465
|
+
}
|
|
3502
4466
|
}
|
|
3503
|
-
}
|
|
4467
|
+
};
|
|
4468
|
+
const output = recoveryJobSession === null ? await runRecoveryExecution() : await this.runSessionExchange({
|
|
4469
|
+
session: recoveryJobSession,
|
|
4470
|
+
jobId: entry.job_id,
|
|
4471
|
+
userRecord: buildUserRecord(
|
|
4472
|
+
recoveryInputFile?.inlineText ?? entry.input,
|
|
4473
|
+
recoveryInputFile?.filePath !== void 0 ? "attachment" : void 0
|
|
4474
|
+
),
|
|
4475
|
+
signal: recoveryAbort.signal,
|
|
4476
|
+
log,
|
|
4477
|
+
execute: runRecoveryExecution,
|
|
4478
|
+
excludeOwnTurns: true
|
|
4479
|
+
});
|
|
3504
4480
|
const { attachments: resultAttachments, deliveredContent } = await this.buildResultAttachment(
|
|
3505
4481
|
entry.job_id,
|
|
3506
4482
|
output,
|
|
@@ -3615,6 +4591,9 @@ var SkillRegistry = class {
|
|
|
3615
4591
|
return this.skills;
|
|
3616
4592
|
}
|
|
3617
4593
|
};
|
|
4594
|
+
|
|
4595
|
+
// src/skill/loader.ts
|
|
4596
|
+
init_llm();
|
|
3618
4597
|
var StaticFileSkill = class {
|
|
3619
4598
|
name;
|
|
3620
4599
|
description;
|
|
@@ -3644,6 +4623,7 @@ var StaticFileSkill = class {
|
|
|
3644
4623
|
priceSubunits: BigInt(Math.round(params.priceSubunits)),
|
|
3645
4624
|
asset: params.asset,
|
|
3646
4625
|
outputFilePath: params.outputFilePath,
|
|
4626
|
+
skillDir: params.dir,
|
|
3647
4627
|
image: params.image,
|
|
3648
4628
|
imageFile: params.imageFile
|
|
3649
4629
|
});
|
|
@@ -3709,6 +4689,7 @@ var DynamicScriptSkill = class {
|
|
|
3709
4689
|
inputMime;
|
|
3710
4690
|
inputText;
|
|
3711
4691
|
outputMime;
|
|
4692
|
+
context;
|
|
3712
4693
|
inner;
|
|
3713
4694
|
constructor(params) {
|
|
3714
4695
|
this.name = params.name;
|
|
@@ -3720,6 +4701,7 @@ var DynamicScriptSkill = class {
|
|
|
3720
4701
|
this.imageFile = params.imageFile;
|
|
3721
4702
|
this.dir = params.dir;
|
|
3722
4703
|
this.llmOverride = params.llmOverride;
|
|
4704
|
+
this.context = params.context;
|
|
3723
4705
|
this.inputMime = params.inputMime;
|
|
3724
4706
|
this.inputText = params.inputText;
|
|
3725
4707
|
this.outputMime = params.outputMime;
|
|
@@ -3735,7 +4717,8 @@ var DynamicScriptSkill = class {
|
|
|
3735
4717
|
scriptEnv: params.scriptEnv,
|
|
3736
4718
|
image: params.image,
|
|
3737
4719
|
imageFile: params.imageFile,
|
|
3738
|
-
outputMime: params.outputMime
|
|
4720
|
+
outputMime: params.outputMime,
|
|
4721
|
+
context: params.context
|
|
3739
4722
|
});
|
|
3740
4723
|
}
|
|
3741
4724
|
async execute(input, ctx) {
|
|
@@ -3750,11 +4733,12 @@ var ScriptSkill = class {
|
|
|
3750
4733
|
asset;
|
|
3751
4734
|
mode = "llm";
|
|
3752
4735
|
llmOverride;
|
|
4736
|
+
context;
|
|
3753
4737
|
image;
|
|
3754
4738
|
imageFile;
|
|
3755
4739
|
dir;
|
|
3756
4740
|
inner;
|
|
3757
|
-
constructor(name, description, capabilities, priceSubunits, assetOrImage, imageOrImageFile, imageFileOrDir, dirOrPrompt, promptOrTools, toolsOrRounds, rounds, llmOverride) {
|
|
4741
|
+
constructor(name, description, capabilities, priceSubunits, assetOrImage, imageOrImageFile, imageFileOrDir, dirOrPrompt, promptOrTools, toolsOrRounds, rounds, llmOverride, context) {
|
|
3758
4742
|
let asset;
|
|
3759
4743
|
let image;
|
|
3760
4744
|
let imageFile;
|
|
@@ -3785,6 +4769,7 @@ var ScriptSkill = class {
|
|
|
3785
4769
|
this.priceSubunits = priceSubunits;
|
|
3786
4770
|
this.asset = asset;
|
|
3787
4771
|
this.llmOverride = llmOverride;
|
|
4772
|
+
this.context = context;
|
|
3788
4773
|
this.image = image;
|
|
3789
4774
|
this.imageFile = imageFile;
|
|
3790
4775
|
this.dir = skillDir;
|
|
@@ -3799,6 +4784,7 @@ var ScriptSkill = class {
|
|
|
3799
4784
|
tools,
|
|
3800
4785
|
maxToolRounds,
|
|
3801
4786
|
llmOverride,
|
|
4787
|
+
context,
|
|
3802
4788
|
image,
|
|
3803
4789
|
imageFile
|
|
3804
4790
|
});
|
|
@@ -3859,11 +4845,23 @@ var X402Skill = class {
|
|
|
3859
4845
|
};
|
|
3860
4846
|
|
|
3861
4847
|
// src/skill/loader.ts
|
|
4848
|
+
function scopeLlmKeys(scriptEnv, declaredProvider) {
|
|
4849
|
+
if (scriptEnv === void 0) {
|
|
4850
|
+
return void 0;
|
|
4851
|
+
}
|
|
4852
|
+
const env = { ...scriptEnv };
|
|
4853
|
+
for (const descriptor of listLlmProviders()) {
|
|
4854
|
+
if (descriptor.id !== declaredProvider) {
|
|
4855
|
+
delete env[descriptor.envVar];
|
|
4856
|
+
}
|
|
4857
|
+
}
|
|
4858
|
+
return env;
|
|
4859
|
+
}
|
|
3862
4860
|
function buildCliSkill(parsed, entryPath, scriptEnv) {
|
|
3863
4861
|
let safeImageFile = parsed.imageFile;
|
|
3864
|
-
if (safeImageFile !== void 0 &&
|
|
4862
|
+
if (safeImageFile !== void 0 && resolveInsidePathReal(entryPath, safeImageFile) === null) {
|
|
3865
4863
|
console.warn(
|
|
3866
|
-
`SKILL.md "${parsed.name}": ignoring "image_file" that resolves outside the skill directory: ${safeImageFile}`
|
|
4864
|
+
`SKILL.md "${parsed.name}": ignoring "image_file" that resolves outside the skill directory (directly or via symlink): ${safeImageFile}`
|
|
3867
4865
|
);
|
|
3868
4866
|
safeImageFile = void 0;
|
|
3869
4867
|
}
|
|
@@ -3882,7 +4880,8 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
|
|
|
3882
4880
|
parsed.systemPrompt,
|
|
3883
4881
|
parsed.tools,
|
|
3884
4882
|
parsed.maxToolRounds,
|
|
3885
|
-
parsed.llmOverride
|
|
4883
|
+
parsed.llmOverride,
|
|
4884
|
+
parsed.context
|
|
3886
4885
|
);
|
|
3887
4886
|
break;
|
|
3888
4887
|
case "static-file": {
|
|
@@ -3891,7 +4890,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
|
|
|
3891
4890
|
`SKILL.md "${parsed.name}": internal error - outputFile missing for mode 'static-file'`
|
|
3892
4891
|
);
|
|
3893
4892
|
}
|
|
3894
|
-
const outputFilePath =
|
|
4893
|
+
const outputFilePath = resolveInsidePathReal(entryPath, parsed.outputFile);
|
|
3895
4894
|
if (!outputFilePath) {
|
|
3896
4895
|
throw new Error(
|
|
3897
4896
|
`SKILL.md "${parsed.name}": "output_file" must stay inside the skill directory`
|
|
@@ -3918,7 +4917,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
|
|
|
3918
4917
|
`SKILL.md "${parsed.name}": internal error - script missing for mode '${parsed.mode}'`
|
|
3919
4918
|
);
|
|
3920
4919
|
}
|
|
3921
|
-
const scriptPath =
|
|
4920
|
+
const scriptPath = resolveInsidePathReal(entryPath, parsed.script);
|
|
3922
4921
|
if (!scriptPath) {
|
|
3923
4922
|
throw new Error(`SKILL.md "${parsed.name}": "script" must stay inside the skill directory`);
|
|
3924
4923
|
}
|
|
@@ -3931,7 +4930,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
|
|
|
3931
4930
|
scriptPath,
|
|
3932
4931
|
scriptArgs: parsed.scriptArgs,
|
|
3933
4932
|
scriptTimeoutMs: parsed.scriptTimeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS,
|
|
3934
|
-
scriptEnv,
|
|
4933
|
+
scriptEnv: scopeLlmKeys(scriptEnv, parsed.llmOverride?.provider),
|
|
3935
4934
|
image: parsed.image,
|
|
3936
4935
|
imageFile: safeImageFile,
|
|
3937
4936
|
dir: entryPath,
|
|
@@ -3941,7 +4940,8 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
|
|
|
3941
4940
|
...scriptParams,
|
|
3942
4941
|
outputMime: parsed.outputMime,
|
|
3943
4942
|
inputMime: parsed.inputMime,
|
|
3944
|
-
inputText: parsed.inputText
|
|
4943
|
+
inputText: parsed.inputText,
|
|
4944
|
+
context: parsed.context
|
|
3945
4945
|
}) : new StaticScriptSkill(scriptParams);
|
|
3946
4946
|
break;
|
|
3947
4947
|
}
|
|
@@ -4059,10 +5059,12 @@ var NostrTransport = class {
|
|
|
4059
5059
|
const rawInput = encrypted ? event.content : iTag?.[1] ?? event.content;
|
|
4060
5060
|
let input;
|
|
4061
5061
|
let attachment;
|
|
5062
|
+
let session;
|
|
4062
5063
|
try {
|
|
4063
5064
|
const decoded = decodeJobPayload(rawInput);
|
|
4064
5065
|
input = decoded.text ?? "";
|
|
4065
5066
|
attachment = decoded.attachment;
|
|
5067
|
+
session = encrypted ? decoded.session : void 0;
|
|
4066
5068
|
} catch {
|
|
4067
5069
|
return;
|
|
4068
5070
|
}
|
|
@@ -4075,7 +5077,8 @@ var NostrTransport = class {
|
|
|
4075
5077
|
bid: parseBidTag(bidTag?.[1]),
|
|
4076
5078
|
encrypted,
|
|
4077
5079
|
rawEvent: event,
|
|
4078
|
-
attachment
|
|
5080
|
+
attachment,
|
|
5081
|
+
session
|
|
4079
5082
|
});
|
|
4080
5083
|
}
|
|
4081
5084
|
);
|
|
@@ -5158,6 +6161,9 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
5158
6161
|
}
|
|
5159
6162
|
const scriptEnv = { ...process.env };
|
|
5160
6163
|
delete scriptEnv.ELISYM_PASSPHRASE;
|
|
6164
|
+
if (scriptEnv.SOLANA_RPC_URL !== void 0 && !isPublicSolanaRpcUrl(scriptEnv.SOLANA_RPC_URL)) {
|
|
6165
|
+
delete scriptEnv.SOLANA_RPC_URL;
|
|
6166
|
+
}
|
|
5161
6167
|
const llmKeys = loaded.secrets.llm_api_keys ?? {};
|
|
5162
6168
|
for (const descriptor of listLlmProviders()) {
|
|
5163
6169
|
const secretValue = llmKeys[descriptor.id];
|
|
@@ -5456,6 +6462,7 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
5456
6462
|
const url = await uploadOrReuse(
|
|
5457
6463
|
cacheKey,
|
|
5458
6464
|
absPath,
|
|
6465
|
+
skillRoot,
|
|
5459
6466
|
mediaCache,
|
|
5460
6467
|
client.blossom,
|
|
5461
6468
|
identity,
|
|
@@ -5546,12 +6553,12 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
5546
6553
|
continue;
|
|
5547
6554
|
}
|
|
5548
6555
|
const type = event.tags.find((tag) => tag[0] === "policy_type")?.[1];
|
|
5549
|
-
if (!type) {
|
|
6556
|
+
if (!type || dTag !== `${POLICY_D_TAG_PREFIX}${type}`) {
|
|
5550
6557
|
continue;
|
|
5551
6558
|
}
|
|
5552
6559
|
try {
|
|
5553
6560
|
await client.policies.deletePolicy(identity, type);
|
|
5554
|
-
console.log(` Removed stale policy: ${type}`);
|
|
6561
|
+
console.log(` Removed stale policy: ${sanitizeForTerminal(type)}`);
|
|
5555
6562
|
} catch {
|
|
5556
6563
|
}
|
|
5557
6564
|
}
|
|
@@ -5569,6 +6576,9 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
5569
6576
|
...skill.inputMime ? { inputMime: skill.inputMime } : {},
|
|
5570
6577
|
...skill.inputText ? { inputText: skill.inputText } : {},
|
|
5571
6578
|
...skill.outputMime ? { outputMime: skill.outputMime } : {},
|
|
6579
|
+
// Conversation-context flag: clients gate chat affordances (session-carrying
|
|
6580
|
+
// sends) on it. Strict true only - the read side coerces anything else away.
|
|
6581
|
+
...skill.context === true ? { context: true } : {},
|
|
5572
6582
|
payment: solanaAddress ? {
|
|
5573
6583
|
chain: "solana",
|
|
5574
6584
|
network: walletNetwork,
|
|
@@ -5638,9 +6648,9 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
5638
6648
|
}
|
|
5639
6649
|
try {
|
|
5640
6650
|
const card = JSON.parse(ev.content);
|
|
5641
|
-
if (card.name) {
|
|
6651
|
+
if (card.name && toDTag(card.name) === dTag) {
|
|
5642
6652
|
await client.discovery.deleteCapability(identity, card.name);
|
|
5643
|
-
console.log(` Removed stale capability: ${card.name}`);
|
|
6653
|
+
console.log(` Removed stale capability: ${sanitizeForTerminal(card.name)}`);
|
|
5644
6654
|
}
|
|
5645
6655
|
} catch {
|
|
5646
6656
|
}
|
|
@@ -5665,6 +6675,10 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
5665
6675
|
if (x402Skills.length > 0) {
|
|
5666
6676
|
await ensureGitignoreHasX402Entries(dirname(loaded.dir));
|
|
5667
6677
|
}
|
|
6678
|
+
const hasContextSkills = registry.all().some((skill) => skill.context === true);
|
|
6679
|
+
if (hasContextSkills) {
|
|
6680
|
+
await ensureGitignoreHasSessionsEntry(dirname(loaded.dir));
|
|
6681
|
+
}
|
|
5668
6682
|
const runtimeConfig = {
|
|
5669
6683
|
paymentTimeoutSecs: DEFAULTS.PAYMENT_EXPIRY_SECS,
|
|
5670
6684
|
maxConcurrentJobs: MAX_CONCURRENT_JOBS,
|
|
@@ -5710,6 +6724,10 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
5710
6724
|
diagLog("LLM health monitor armed (lazy recovery, 5min interval).");
|
|
5711
6725
|
}
|
|
5712
6726
|
const blossomTransport = createBlossomTransport({ blossom: client.blossom, identity });
|
|
6727
|
+
const sessionStore = hasContextSkills ? new SessionStore(loaded.dir, diagLog) : void 0;
|
|
6728
|
+
if (sessionStore) {
|
|
6729
|
+
diagLog("Conversation sessions enabled (context: true skills present).");
|
|
6730
|
+
}
|
|
5713
6731
|
const runtime = new AgentRuntime(
|
|
5714
6732
|
transport,
|
|
5715
6733
|
registry,
|
|
@@ -5743,7 +6761,8 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
5743
6761
|
healthMonitor,
|
|
5744
6762
|
irohTransport,
|
|
5745
6763
|
identity,
|
|
5746
|
-
blossomTransport
|
|
6764
|
+
blossomTransport,
|
|
6765
|
+
sessionStore
|
|
5747
6766
|
);
|
|
5748
6767
|
console.log(" * Running. Press Ctrl+C to stop.\n");
|
|
5749
6768
|
await runtime.run();
|
|
@@ -5768,6 +6787,14 @@ function stripRpcSecrets(raw) {
|
|
|
5768
6787
|
return "[unparseable RPC URL]";
|
|
5769
6788
|
}
|
|
5770
6789
|
}
|
|
6790
|
+
function isPublicSolanaRpcUrl(raw) {
|
|
6791
|
+
try {
|
|
6792
|
+
const parsed = new URL(raw);
|
|
6793
|
+
return PUBLIC_SOLANA_RPC_HOSTS.has(parsed.hostname) && !parsed.username && !parsed.password && !parsed.search && (parsed.pathname === "" || parsed.pathname === "/");
|
|
6794
|
+
} catch {
|
|
6795
|
+
return false;
|
|
6796
|
+
}
|
|
6797
|
+
}
|
|
5771
6798
|
function redactRpcUrlsInText(text) {
|
|
5772
6799
|
return text.replace(/https?:\/\/[^\s)'"]+/g, (url) => stripRpcSecrets(url));
|
|
5773
6800
|
}
|
|
@@ -5783,7 +6810,15 @@ async function resolveMediaField(value, agentDir, cache, blossom, identity, onCa
|
|
|
5783
6810
|
console.warn(` ! Skipping media field "${value}": path must stay inside the agent directory.`);
|
|
5784
6811
|
return void 0;
|
|
5785
6812
|
}
|
|
5786
|
-
return uploadOrReuse(
|
|
6813
|
+
return uploadOrReuse(
|
|
6814
|
+
value,
|
|
6815
|
+
absPath,
|
|
6816
|
+
agentDir,
|
|
6817
|
+
cache,
|
|
6818
|
+
blossom,
|
|
6819
|
+
identity,
|
|
6820
|
+
() => onCacheUpdate(true)
|
|
6821
|
+
);
|
|
5787
6822
|
}
|
|
5788
6823
|
function resolveInsideAgentDir(value, agentDir) {
|
|
5789
6824
|
const agentRoot = resolve(agentDir);
|
|
@@ -5794,14 +6829,34 @@ function resolveInsideAgentDir(value, agentDir) {
|
|
|
5794
6829
|
}
|
|
5795
6830
|
return candidate;
|
|
5796
6831
|
}
|
|
5797
|
-
|
|
6832
|
+
function realpathInsideDir(absPath, rootDir) {
|
|
6833
|
+
try {
|
|
6834
|
+
const realRoot = realpathSync(resolve(rootDir));
|
|
6835
|
+
const realCandidate = realpathSync(absPath);
|
|
6836
|
+
const rel = relative(realRoot, realCandidate);
|
|
6837
|
+
if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
6838
|
+
return null;
|
|
6839
|
+
}
|
|
6840
|
+
return realCandidate;
|
|
6841
|
+
} catch {
|
|
6842
|
+
return null;
|
|
6843
|
+
}
|
|
6844
|
+
}
|
|
6845
|
+
async function uploadOrReuse(cacheKey, absPath, containRoot, cache, blossom, identity, onCacheUpdate) {
|
|
6846
|
+
const realPath = realpathInsideDir(absPath, containRoot);
|
|
6847
|
+
if (realPath === null) {
|
|
6848
|
+
console.warn(
|
|
6849
|
+
` ! Skipping upload of ${basename(absPath)}: unreadable or escapes its directory.`
|
|
6850
|
+
);
|
|
6851
|
+
return void 0;
|
|
6852
|
+
}
|
|
5798
6853
|
try {
|
|
5799
|
-
const cached = await lookupCachedUrl(cache, cacheKey,
|
|
6854
|
+
const cached = await lookupCachedUrl(cache, cacheKey, realPath);
|
|
5800
6855
|
if (cached) {
|
|
5801
6856
|
return cached;
|
|
5802
6857
|
}
|
|
5803
|
-
console.log(` Uploading ${basename(
|
|
5804
|
-
const data = readFileSync(
|
|
6858
|
+
console.log(` Uploading ${basename(realPath)}...`);
|
|
6859
|
+
const data = readFileSync(realPath);
|
|
5805
6860
|
const sha256 = createHash("sha256").update(data).digest("hex");
|
|
5806
6861
|
const blob = new Blob([data], { type: mimeFromPath(absPath) });
|
|
5807
6862
|
const descriptor = await blossom.upload(identity, blob);
|