@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 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, resolveInsidePath, DEFAULT_SCRIPT_TIMEOUT_MS, DynamicScriptSkill as DynamicScriptSkill$1, StaticScriptSkill as StaticScriptSkill$1, StaticFileSkill as StaticFileSkill$1, ScriptSkill as ScriptSkill$1 } from '@elisym/sdk/skills';
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(() => "")).slice(0, 500);
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(() => "")).slice(0, 500);
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(`Anthropic API error: ${response.status} ${await response.text()}`);
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(`Anthropic API error: ${response.status} ${await response.text()}`);
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(() => "")).slice(0, 500);
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(() => "")).slice(0, 500);
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(() => "")).slice(0, 500);
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(() => "")).slice(0, 500);
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(`OpenAI API error: ${response.status} ${await response.text()}`);
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(`OpenAI API error: ${response.status} ${await response.text()}`);
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
- let budgetExceeded = false;
2710
- const execAbort = new AbortController();
2711
- const onOuterAbort = () => execAbort.abort();
2712
- if (signal) {
2713
- if (signal.aborted) {
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.removeEventListener("abort", onOuterAbort);
3611
+ if (signal.aborted) {
3612
+ execAbort.abort();
3613
+ } else {
3614
+ signal.addEventListener("abort", onOuterAbort);
3615
+ }
2763
3616
  }
2764
- if (inputFile) {
2765
- await inputFile.cleanup().catch(() => {
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
- decodeJobPayload(rawInput).attachment,
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
- let budgetTimer;
3465
- let output;
3466
- try {
3467
- const execPromise = skill.execute(
3468
- {
3469
- data: recoveryInputFile?.inlineText ?? entry.input,
3470
- inputType: entry.input_type,
3471
- tags: entry.tags,
3472
- jobId: entry.job_id,
3473
- filePath: recoveryInputFile?.filePath
3474
- },
3475
- { ...this.skillCtx, signal: recoveryAbort.signal }
3476
- );
3477
- execPromise.catch(() => {
3478
- });
3479
- if (recoveryBudgetMs > 0) {
3480
- output = await Promise.race([
3481
- execPromise,
3482
- new Promise((_resolve, reject) => {
3483
- budgetTimer = setTimeout(() => {
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 && resolveInsidePath(entryPath, safeImageFile) === null) {
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 = resolveInsidePath(entryPath, parsed.outputFile);
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 = resolveInsidePath(entryPath, parsed.script);
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(value, absPath, cache, blossom, identity, () => onCacheUpdate(true));
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
- async function uploadOrReuse(cacheKey, absPath, cache, blossom, identity, onCacheUpdate) {
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, absPath);
6854
+ const cached = await lookupCachedUrl(cache, cacheKey, realPath);
5800
6855
  if (cached) {
5801
6856
  return cached;
5802
6857
  }
5803
- console.log(` Uploading ${basename(absPath)}...`);
5804
- const data = readFileSync(absPath);
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);