@elisym/cli 0.23.2 → 0.25.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, KIND_EXTERNAL_IDENTITIES, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, DEFAULTS, createBlossomTransport, verifyAgentIdentities, generateSolanaWallet, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, utf8ByteLength, readAcceptedTransports, decodeJobPayload, BoundedSet, KIND_JOB_FEEDBACK, GITHUB_USERNAME_REGEX, X_USERNAME_REGEX, normalizeNip05Identifier, splitNip05Identifier, encodeSecretKeyBase58, NATIVE_SOL, GIST_ID_REGEX, TWEET_ID_REGEX } 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';
@@ -52,9 +52,9 @@ function sleepWithSignal(ms, signal) {
52
52
  return Promise.reject(createAbortError());
53
53
  }
54
54
  if (!signal) {
55
- return new Promise((resolve3) => setTimeout(resolve3, ms));
55
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
56
56
  }
57
- return new Promise((resolve3, reject) => {
57
+ return new Promise((resolve4, reject) => {
58
58
  const cleanup = () => {
59
59
  clearTimeout(timer);
60
60
  signal.removeEventListener("abort", onAbort);
@@ -65,7 +65,7 @@ function sleepWithSignal(ms, signal) {
65
65
  };
66
66
  const timer = setTimeout(() => {
67
67
  cleanup();
68
- resolve3();
68
+ resolve4();
69
69
  }, ms);
70
70
  signal.addEventListener("abort", onAbort, { once: true });
71
71
  });
@@ -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);
@@ -1415,6 +1442,538 @@ var init_init = __esm({
1415
1442
  init_llm();
1416
1443
  }
1417
1444
  });
1445
+ var IDENTITY_PLATFORMS = ["github", "x", "website"];
1446
+ var X_PROOF_URL_HOSTS = /* @__PURE__ */ new Set([
1447
+ "x.com",
1448
+ "www.x.com",
1449
+ "mobile.x.com",
1450
+ "twitter.com",
1451
+ "www.twitter.com",
1452
+ "mobile.twitter.com"
1453
+ ]);
1454
+ function githubProofText(npub) {
1455
+ return `Verifying that I control the following Nostr public key: ${npub}`;
1456
+ }
1457
+ function xProofText(npub) {
1458
+ return `Verifying my account on nostr My Public Key: "${npub}"`;
1459
+ }
1460
+ function websiteNostrJson(local, pubkeyHex) {
1461
+ return JSON.stringify({ names: { [local]: pubkeyHex } }, null, 2);
1462
+ }
1463
+ function parseIdentityPlatform(raw) {
1464
+ const lowered = raw.trim().toLowerCase();
1465
+ const match = IDENTITY_PLATFORMS.find((platform) => platform === lowered);
1466
+ if (match === void 0) {
1467
+ throw new Error(`Unknown platform "${raw}". Expected github, x, or website.`);
1468
+ }
1469
+ return match;
1470
+ }
1471
+ function parseGithubProofInput(raw) {
1472
+ const trimmed = raw.trim();
1473
+ const bareId = trimmed.toLowerCase();
1474
+ if (GIST_ID_REGEX.test(bareId)) {
1475
+ return { proofId: bareId };
1476
+ }
1477
+ let url;
1478
+ try {
1479
+ url = new URL(trimmed);
1480
+ } catch {
1481
+ return null;
1482
+ }
1483
+ if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "gist.github.com") {
1484
+ return null;
1485
+ }
1486
+ const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
1487
+ const [userSegment, gistSegment] = segments;
1488
+ if (userSegment === void 0 || gistSegment === void 0) {
1489
+ return null;
1490
+ }
1491
+ const username = userSegment.toLowerCase();
1492
+ const gistId = gistSegment.toLowerCase();
1493
+ if (!GITHUB_USERNAME_REGEX.test(username) || !GIST_ID_REGEX.test(gistId)) {
1494
+ return null;
1495
+ }
1496
+ return { proofId: gistId, username };
1497
+ }
1498
+ function parseXProofInput(raw) {
1499
+ const trimmed = raw.trim();
1500
+ if (TWEET_ID_REGEX.test(trimmed)) {
1501
+ return { proofId: trimmed };
1502
+ }
1503
+ let url;
1504
+ try {
1505
+ url = new URL(trimmed);
1506
+ } catch {
1507
+ return null;
1508
+ }
1509
+ if (url.protocol !== "https:" || !X_PROOF_URL_HOSTS.has(url.hostname.toLowerCase())) {
1510
+ return null;
1511
+ }
1512
+ const segments = url.pathname.split("/").filter((segment) => segment.length > 0);
1513
+ const [userSegment, statusLiteral, idSegment] = segments;
1514
+ if (userSegment === void 0 || statusLiteral !== "status" || idSegment === void 0) {
1515
+ return null;
1516
+ }
1517
+ const username = userSegment.toLowerCase();
1518
+ if (!X_USERNAME_REGEX.test(username) || !TWEET_ID_REGEX.test(idSegment)) {
1519
+ return null;
1520
+ }
1521
+ return { proofId: idSegment, username };
1522
+ }
1523
+ function buildIdentityClaimInputs(identities) {
1524
+ const claims = [];
1525
+ if (identities?.github !== void 0) {
1526
+ claims.push({
1527
+ platform: "github",
1528
+ handle: identities.github.username,
1529
+ proofId: identities.github.gist
1530
+ });
1531
+ }
1532
+ if (identities?.x !== void 0) {
1533
+ claims.push({ platform: "x", handle: identities.x.username, proofId: identities.x.tweet });
1534
+ }
1535
+ return claims;
1536
+ }
1537
+ function identitiesFromYaml(identities) {
1538
+ const claims = [];
1539
+ if (identities === void 0) {
1540
+ return claims;
1541
+ }
1542
+ if (identities.github !== void 0) {
1543
+ claims.push({
1544
+ platform: "github",
1545
+ handle: identities.github.username,
1546
+ proofUrl: `https://gist.github.com/${encodeURIComponent(identities.github.username)}/${encodeURIComponent(identities.github.gist)}`
1547
+ });
1548
+ }
1549
+ if (identities.x !== void 0) {
1550
+ claims.push({
1551
+ platform: "x",
1552
+ handle: identities.x.username,
1553
+ proofUrl: `https://x.com/${encodeURIComponent(identities.x.username)}/status/${encodeURIComponent(identities.x.tweet)}`
1554
+ });
1555
+ }
1556
+ if (identities.website !== void 0) {
1557
+ const identifier = normalizeNip05Identifier(identities.website);
1558
+ if (identifier !== null) {
1559
+ const { domain } = splitNip05Identifier(identifier);
1560
+ claims.push({ platform: "website", handle: identifier, proofUrl: `https://${domain}` });
1561
+ }
1562
+ }
1563
+ return claims;
1564
+ }
1565
+ async function createInquirerPrompter() {
1566
+ const { default: inquirer } = await import('inquirer');
1567
+ return {
1568
+ async input(message) {
1569
+ const { value } = await inquirer.prompt([{ type: "input", name: "value", message }]);
1570
+ return String(value);
1571
+ },
1572
+ async confirm(message, defaultValue) {
1573
+ const { value } = await inquirer.prompt([
1574
+ { type: "confirm", name: "value", message, default: defaultValue }
1575
+ ]);
1576
+ return Boolean(value);
1577
+ },
1578
+ async select(message, choices) {
1579
+ const { value } = await inquirer.prompt([{ type: "list", name: "value", message, choices }]);
1580
+ return String(value);
1581
+ }
1582
+ };
1583
+ }
1584
+ function createDefaultPublisher(relays) {
1585
+ const client = new ElisymClient({ relays });
1586
+ return {
1587
+ fetchExternalIdentityClaims: (pubkey) => client.discovery.fetchExternalIdentityClaims(pubkey),
1588
+ publishExternalIdentities: (identity2, claims) => client.discovery.publishExternalIdentities(identity2, claims),
1589
+ publishProfile: (identity2, name, about, picture, banner, nip05) => client.discovery.publishProfile(identity2, name, about, picture, banner, nip05),
1590
+ close: () => client.close()
1591
+ };
1592
+ }
1593
+ async function resolveAndLoadAgent(nameArg, cwd, prompter) {
1594
+ let name = nameArg;
1595
+ if (!name) {
1596
+ const agents = await listAgents(cwd);
1597
+ if (agents.length === 0) {
1598
+ throw new Error("No agents found. Run `npx @elisym/cli init` first.");
1599
+ }
1600
+ name = await prompter.select(
1601
+ "Select agent:",
1602
+ agents.map((agent) => ({ name: `${agent.name} (${agent.source})`, value: agent.name }))
1603
+ );
1604
+ }
1605
+ const passphrase = process.env.ELISYM_PASSPHRASE;
1606
+ const loaded = await loadAgent(name, cwd, passphrase);
1607
+ return { name, loaded };
1608
+ }
1609
+ function relaysFor(yaml) {
1610
+ return yaml.relays.length > 0 ? yaml.relays : [...RELAYS];
1611
+ }
1612
+ async function resolvePreservedMediaUrl(configured, agentDir, cache, publishedUrl) {
1613
+ if (!configured) {
1614
+ return void 0;
1615
+ }
1616
+ if (/^https?:\/\//i.test(configured)) {
1617
+ return configured;
1618
+ }
1619
+ const agentRoot = resolve(agentDir);
1620
+ const candidate = resolve(agentRoot, configured);
1621
+ const rel = relative(agentRoot, candidate);
1622
+ if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
1623
+ return publishedUrl;
1624
+ }
1625
+ const cached = await lookupCachedUrl(cache, configured, candidate);
1626
+ return cached ?? publishedUrl;
1627
+ }
1628
+ async function republishAgentProfile(publisher, identity2, agentName, agentDir, yaml) {
1629
+ let publishedProfile;
1630
+ try {
1631
+ const published = await publisher.fetchExternalIdentityClaims(identity2.publicKey);
1632
+ publishedProfile = published.profile;
1633
+ } catch {
1634
+ }
1635
+ const mediaCache = await readMediaCache(agentDir);
1636
+ const picture = await resolvePreservedMediaUrl(
1637
+ yaml.picture,
1638
+ agentDir,
1639
+ mediaCache,
1640
+ publishedProfile?.picture
1641
+ );
1642
+ const banner = await resolvePreservedMediaUrl(
1643
+ yaml.banner,
1644
+ agentDir,
1645
+ mediaCache,
1646
+ publishedProfile?.banner
1647
+ );
1648
+ const unresolved = [];
1649
+ if (yaml.picture && picture === void 0) {
1650
+ unresolved.push("picture");
1651
+ }
1652
+ if (yaml.banner && banner === void 0) {
1653
+ unresolved.push("banner");
1654
+ }
1655
+ if (unresolved.length > 0) {
1656
+ throw new Error(
1657
+ `${unresolved.join(" and ")} carry-over unavailable (no media cache entry and no currently-published URL - the relay read may have failed), so republishing kind 0 would wipe it from the profile`
1658
+ );
1659
+ }
1660
+ await publisher.publishProfile(
1661
+ identity2,
1662
+ yaml.display_name ?? agentName,
1663
+ yaml.description ?? "",
1664
+ picture,
1665
+ banner,
1666
+ yaml.identities?.website
1667
+ );
1668
+ }
1669
+ async function sweepStaleIdentityClaims(discovery, identity2) {
1670
+ try {
1671
+ const published = await discovery.fetchExternalIdentityClaims(identity2.publicKey);
1672
+ const staleClaims = published.identities.filter((claim) => claim.platform !== "website");
1673
+ if (staleClaims.length === 0) {
1674
+ return false;
1675
+ }
1676
+ await discovery.publishExternalIdentities(identity2, []);
1677
+ return true;
1678
+ } catch {
1679
+ return false;
1680
+ }
1681
+ }
1682
+ async function promptGithubLink(loaded, npub, prompter) {
1683
+ console.log(
1684
+ "\n 1. Create a PUBLIC gist (https://gist.github.com/new) containing ONLY this\n single line (the proof statement - a standard NIP-39 text that any\n Nostr client can verify; do not paste these instructions):"
1685
+ );
1686
+ console.log(`
1687
+ ${githubProofText(npub)}
1688
+ `);
1689
+ console.log(" 2. Paste the gist URL (or its id) below.\n");
1690
+ const rawProof = await prompter.input("Gist URL or id:");
1691
+ const parsed = parseGithubProofInput(rawProof);
1692
+ if (parsed === null) {
1693
+ throw new Error("Expected a gist URL (https://gist.github.com/<user>/<id>) or a hex gist id.");
1694
+ }
1695
+ let username = parsed.username;
1696
+ if (username === void 0) {
1697
+ const rawUsername = await prompter.input("GitHub username:");
1698
+ username = rawUsername.trim().toLowerCase();
1699
+ }
1700
+ if (!GITHUB_USERNAME_REGEX.test(username)) {
1701
+ throw new Error(`Invalid GitHub username: "${username}" (alphanumeric or hyphen, 1-39 chars).`);
1702
+ }
1703
+ return {
1704
+ nextIdentities: {
1705
+ ...loaded.yaml.identities,
1706
+ github: { username, gist: parsed.proofId }
1707
+ }
1708
+ };
1709
+ }
1710
+ async function promptXLink(loaded, npub, prompter) {
1711
+ console.log(
1712
+ "\n 1. Post a tweet whose text is ONLY this line, including the double quotes\n (the proof statement - a standard NIP-39 text; the account you post\n from becomes the linked identity):"
1713
+ );
1714
+ console.log(`
1715
+ ${xProofText(npub)}
1716
+ `);
1717
+ console.log(" 2. Paste the tweet URL (or its status id) below.\n");
1718
+ const rawProof = await prompter.input("Tweet URL or id:");
1719
+ const parsed = parseXProofInput(rawProof);
1720
+ if (parsed === null) {
1721
+ throw new Error(
1722
+ "Expected a tweet URL (https://x.com/<user>/status/<id>) or a numeric status id."
1723
+ );
1724
+ }
1725
+ let username = parsed.username;
1726
+ if (username === void 0) {
1727
+ const rawUsername = await prompter.input("X username (without @):");
1728
+ username = rawUsername.trim().replace(/^@/, "").toLowerCase();
1729
+ }
1730
+ if (!X_USERNAME_REGEX.test(username)) {
1731
+ throw new Error(`Invalid X username: "${username}" (alphanumeric or underscore, 1-15 chars).`);
1732
+ }
1733
+ return {
1734
+ nextIdentities: {
1735
+ ...loaded.yaml.identities,
1736
+ x: { username, tweet: parsed.proofId }
1737
+ }
1738
+ };
1739
+ }
1740
+ async function promptWebsiteLink(loaded, pubkeyHex, prompter) {
1741
+ const rawIdentifier = await prompter.input("NIP-05 identifier (name@domain or bare domain):");
1742
+ const identifier = normalizeNip05Identifier(rawIdentifier.trim());
1743
+ if (identifier === null) {
1744
+ throw new Error(
1745
+ `Invalid NIP-05 identifier: "${rawIdentifier.trim()}". Expected name@domain or a bare domain (ASCII hostname labels, no IP literals).`
1746
+ );
1747
+ }
1748
+ const { local, domain } = splitNip05Identifier(identifier);
1749
+ console.log(`
1750
+ Serve this JSON at https://${domain}/.well-known/nostr.json :
1751
+ `);
1752
+ for (const line of websiteNostrJson(local, pubkeyHex).split("\n")) {
1753
+ console.log(` ${line}`);
1754
+ }
1755
+ console.log(
1756
+ `
1757
+ It must answer https://${domain}/.well-known/nostr.json?name=${local} over https,`
1758
+ );
1759
+ console.log(" without redirects - NIP-05 forbids following them. The verifier consults only");
1760
+ console.log(` this exact host (no www/apex twin fallback), so the file must live at ${domain}.`);
1761
+ console.log(" Sending `Access-Control-Allow-Origin: *` is recommended (NIP-05), so browser");
1762
+ console.log(" clients can verify it too.\n");
1763
+ const live = await prompter.confirm("Is the file deployed and reachable? Verify now", true);
1764
+ if (!live) {
1765
+ throw new Error(
1766
+ "aborted - deploy the file, then re-run `npx @elisym/cli identity link website`. Nothing was written."
1767
+ );
1768
+ }
1769
+ return {
1770
+ nextIdentities: {
1771
+ ...loaded.yaml.identities,
1772
+ website: identifier
1773
+ }
1774
+ };
1775
+ }
1776
+ function describeBrokenProof(platform, claim) {
1777
+ switch (platform) {
1778
+ case "github":
1779
+ return `the gist at ${claim.proofUrl} is missing, belongs to another user, or does not contain the exact proof text with this agent's npub`;
1780
+ case "x":
1781
+ return `the tweet at ${claim.proofUrl} is deleted, belongs to another account, or does not contain the exact proof text with this agent's npub`;
1782
+ case "website":
1783
+ return `${claim.proofUrl}/.well-known/nostr.json does not map "${claim.handle}" to this agent's pubkey`;
1784
+ }
1785
+ }
1786
+ async function cmdIdentityLink(platformArg, agentName, deps = {}) {
1787
+ const platform = parseIdentityPlatform(platformArg);
1788
+ const cwd = deps.cwd ?? process.cwd();
1789
+ const prompter = deps.prompter ?? await createInquirerPrompter();
1790
+ const verify = deps.verify ?? verifyAgentIdentities;
1791
+ const { name, loaded } = await resolveAndLoadAgent(agentName, cwd, prompter);
1792
+ const identity2 = ElisymIdentity.fromHex(loaded.secrets.nostr_secret_key);
1793
+ console.log(`
1794
+ Linking ${platform} to agent "${name}" (${identity2.npub}).`);
1795
+ let prompted;
1796
+ if (platform === "github") {
1797
+ prompted = await promptGithubLink(loaded, identity2.npub, prompter);
1798
+ } else if (platform === "x") {
1799
+ prompted = await promptXLink(loaded, identity2.npub, prompter);
1800
+ } else {
1801
+ prompted = await promptWebsiteLink(loaded, identity2.publicKey, prompter);
1802
+ }
1803
+ const claim = identitiesFromYaml(prompted.nextIdentities).find(
1804
+ (candidate) => candidate.platform === platform
1805
+ );
1806
+ if (claim === void 0) {
1807
+ throw new Error("Internal: failed to build the identity claim from the prompt input.");
1808
+ }
1809
+ console.log("\n Verifying the proof (live, cache bypassed)...");
1810
+ const results = await verify(identity2.publicKey, [claim], { bypassCache: true });
1811
+ const result = results[0];
1812
+ if (result === void 0) {
1813
+ throw new Error("Internal: the verifier returned no result.");
1814
+ }
1815
+ if (result.status === "broken") {
1816
+ throw new Error(
1817
+ `proof check failed: ${describeBrokenProof(platform, claim)}. Nothing was written.`
1818
+ );
1819
+ }
1820
+ if (result.status === "unverifiable") {
1821
+ console.warn(
1822
+ " ! Could not check the proof (network error, rate limit, or the proof is not reachable yet)."
1823
+ );
1824
+ console.warn(" ! This is neutral - the proof may be fine - but it was NOT verified.");
1825
+ const proceed = await prompter.confirm(
1826
+ "Link anyway (write elisym.yaml and publish the unverified claim)?",
1827
+ false
1828
+ );
1829
+ if (!proceed) {
1830
+ throw new Error("aborted - nothing was written.");
1831
+ }
1832
+ } else {
1833
+ console.log(" Proof verified.");
1834
+ }
1835
+ const nextYaml = { ...loaded.yaml, identities: prompted.nextIdentities };
1836
+ await writeYaml(loaded.dir, nextYaml);
1837
+ console.log(` Wrote identities.${platform} to elisym.yaml.`);
1838
+ const createPublisher = deps.createPublisher ?? createDefaultPublisher;
1839
+ const publisher = createPublisher(relaysFor(loaded.yaml));
1840
+ try {
1841
+ const claims = buildIdentityClaimInputs(prompted.nextIdentities);
1842
+ try {
1843
+ await publisher.publishExternalIdentities(identity2, claims);
1844
+ console.log(` Published identity claims (kind 10011, ${claims.length} tag(s)).`);
1845
+ if (platform === "website") {
1846
+ await republishAgentProfile(publisher, identity2, name, loaded.dir, nextYaml);
1847
+ console.log(" Republished profile (kind 0) with nip05.");
1848
+ }
1849
+ } catch (error) {
1850
+ const message = error instanceof Error ? error.message : String(error);
1851
+ throw new Error(
1852
+ `linked locally, but publishing failed: ${message}. Run \`npx @elisym/cli start ${name}\` to publish, or re-run this command.`
1853
+ );
1854
+ }
1855
+ } finally {
1856
+ publisher.close();
1857
+ }
1858
+ console.log(`
1859
+ Linked ${platform} to agent "${name}".
1860
+ `);
1861
+ }
1862
+ function removeIdentityEntry(identities, platform) {
1863
+ const next = { ...identities };
1864
+ delete next[platform];
1865
+ const hasEntries = next.github !== void 0 || next.x !== void 0 || next.website !== void 0;
1866
+ return hasEntries ? next : void 0;
1867
+ }
1868
+ async function cmdIdentityUnlink(platformArg, agentName, deps = {}) {
1869
+ const platform = parseIdentityPlatform(platformArg);
1870
+ const cwd = deps.cwd ?? process.cwd();
1871
+ const prompter = deps.prompter ?? await createInquirerPrompter();
1872
+ const { name, loaded } = await resolveAndLoadAgent(agentName, cwd, prompter);
1873
+ const identity2 = ElisymIdentity.fromHex(loaded.secrets.nostr_secret_key);
1874
+ const current = loaded.yaml.identities;
1875
+ if (current === void 0 || current[platform] === void 0) {
1876
+ console.log(`
1877
+ ${platform} is not linked for agent "${name}" - nothing to unlink.
1878
+ `);
1879
+ return;
1880
+ }
1881
+ const nextIdentities = removeIdentityEntry(current, platform);
1882
+ const nextYaml = { ...loaded.yaml };
1883
+ if (nextIdentities === void 0) {
1884
+ delete nextYaml.identities;
1885
+ } else {
1886
+ nextYaml.identities = nextIdentities;
1887
+ }
1888
+ await writeYaml(loaded.dir, nextYaml);
1889
+ console.log(`
1890
+ Removed identities.${platform} from elisym.yaml.`);
1891
+ const createPublisher = deps.createPublisher ?? createDefaultPublisher;
1892
+ const publisher = createPublisher(relaysFor(loaded.yaml));
1893
+ try {
1894
+ const remaining = buildIdentityClaimInputs(nextIdentities);
1895
+ try {
1896
+ await publisher.publishExternalIdentities(identity2, remaining);
1897
+ console.log(
1898
+ ` Republished identity claims (kind 10011, ${remaining.length} tag(s) remaining).`
1899
+ );
1900
+ if (platform === "website") {
1901
+ await republishAgentProfile(publisher, identity2, name, loaded.dir, nextYaml);
1902
+ console.log(" Republished profile (kind 0) without nip05.");
1903
+ }
1904
+ } catch (error) {
1905
+ const message = error instanceof Error ? error.message : String(error);
1906
+ throw new Error(
1907
+ `unlinked locally, but publishing the retraction failed: ${message}. Run \`npx @elisym/cli start ${name}\` to propagate it.`
1908
+ );
1909
+ }
1910
+ } finally {
1911
+ publisher.close();
1912
+ }
1913
+ console.log(`
1914
+ Unlinked ${platform} from agent "${name}".
1915
+ `);
1916
+ }
1917
+ function sameClaim(left, right) {
1918
+ return left.platform === right.platform && left.handle.toLowerCase() === right.handle.toLowerCase() && left.proofUrl.toLowerCase() === right.proofUrl.toLowerCase();
1919
+ }
1920
+ function buildStatusRows(results, publishedClaims) {
1921
+ return results.map((result) => ({
1922
+ platform: result.identity.platform,
1923
+ handle: result.identity.handle,
1924
+ proofUrl: result.identity.proofUrl,
1925
+ status: result.status,
1926
+ published: publishedClaims === void 0 ? void 0 : publishedClaims.some((claim) => sameClaim(claim, result.identity))
1927
+ }));
1928
+ }
1929
+ async function cmdIdentityStatus(agentName, deps = {}) {
1930
+ const cwd = deps.cwd ?? process.cwd();
1931
+ const prompter = deps.prompter ?? await createInquirerPrompter();
1932
+ const verify = deps.verify ?? verifyAgentIdentities;
1933
+ const { name, loaded } = await resolveAndLoadAgent(agentName, cwd, prompter);
1934
+ const identity2 = ElisymIdentity.fromHex(loaded.secrets.nostr_secret_key);
1935
+ const localClaims = identitiesFromYaml(loaded.yaml.identities);
1936
+ const createPublisher = deps.createPublisher ?? createDefaultPublisher;
1937
+ const publisher = createPublisher(relaysFor(loaded.yaml));
1938
+ let published;
1939
+ try {
1940
+ published = await publisher.fetchExternalIdentityClaims(identity2.publicKey);
1941
+ } catch {
1942
+ console.warn(" ! Could not query the relays for published claims - drift check skipped.");
1943
+ } finally {
1944
+ publisher.close();
1945
+ }
1946
+ if (localClaims.length === 0) {
1947
+ console.log(`
1948
+ No identities linked in elisym.yaml for agent "${name}".`);
1949
+ console.log(" Link one: npx @elisym/cli identity link <github|x|website>\n");
1950
+ if (published !== void 0 && published.identities.length > 0) {
1951
+ console.log(
1952
+ ` ! The relays still hold published identity claims - run \`npx @elisym/cli start ${name}\` to retract them.
1953
+ `
1954
+ );
1955
+ }
1956
+ return;
1957
+ }
1958
+ console.log(`
1959
+ Verifying ${localClaims.length} proof(s) (live, cache bypassed)...
1960
+ `);
1961
+ const results = await verify(identity2.publicKey, localClaims, { bypassCache: true });
1962
+ const rows = buildStatusRows(results, published?.identities);
1963
+ console.log(` Identities for agent "${name}" (${identity2.npub}):
1964
+ `);
1965
+ for (const row of rows) {
1966
+ const displayHandle = row.handle.startsWith("_@") ? row.handle.slice(2) : row.handle;
1967
+ console.log(` ${row.platform.padEnd(8)} ${row.status.padEnd(13)} ${displayHandle}`);
1968
+ console.log(` proof: ${row.proofUrl}`);
1969
+ if (row.published === false) {
1970
+ console.log(
1971
+ ` ! linked locally but not published - run \`npx @elisym/cli start ${name}\` to publish`
1972
+ );
1973
+ }
1974
+ }
1975
+ console.log();
1976
+ }
1418
1977
 
1419
1978
  // src/index.ts
1420
1979
  init_init();
@@ -1669,7 +2228,7 @@ function parseRelayUrl(url) {
1669
2228
  }
1670
2229
  }
1671
2230
  async function tcpProbe(host, port, timeoutMs) {
1672
- return new Promise((resolve3) => {
2231
+ return new Promise((resolve4) => {
1673
2232
  const started = Date.now();
1674
2233
  const socket = new Socket();
1675
2234
  let settled = false;
@@ -1679,7 +2238,7 @@ async function tcpProbe(host, port, timeoutMs) {
1679
2238
  }
1680
2239
  settled = true;
1681
2240
  socket.destroy();
1682
- resolve3(result);
2241
+ resolve4(result);
1683
2242
  };
1684
2243
  const timer = setTimeout(() => finish({ error: "timeout" }), timeoutMs);
1685
2244
  socket.once("connect", () => {
@@ -1996,80 +2555,809 @@ function resolveProviderApiKey(input) {
1996
2555
  error: `Provider "${provider}" is not registered (required by skill(s): ${skillList2}).`
1997
2556
  };
1998
2557
  }
1999
- const perProviderValue = secrets.llm_api_keys?.[provider];
2000
- if (typeof perProviderValue === "string" && perProviderValue.length > 0) {
2001
- return { apiKey: perProviderValue, origin: "secrets-per-provider" };
2558
+ const perProviderValue = secrets.llm_api_keys?.[provider];
2559
+ if (typeof perProviderValue === "string" && perProviderValue.length > 0) {
2560
+ return { apiKey: perProviderValue, origin: "secrets-per-provider" };
2561
+ }
2562
+ const envValue = process.env[descriptor.envVar];
2563
+ if (envValue) {
2564
+ return { apiKey: envValue, origin: "env" };
2565
+ }
2566
+ const skillList = dependentSkills.length > 0 ? dependentSkills.join(", ") : "<none>";
2567
+ return {
2568
+ error: `Provider "${provider}" needs an API key (required by skill(s): ${skillList}). Set secrets.llm_api_keys.${provider} via 'npx @elisym/cli profile <agent>' or export ${descriptor.envVar}.`
2569
+ };
2570
+ }
2571
+ function sanitizeForTerminal(value) {
2572
+ return value.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
2573
+ }
2574
+ function resolveLevel(options) {
2575
+ if (options.level) {
2576
+ return options.level;
2577
+ }
2578
+ if (options.verbose) {
2579
+ return "debug";
2580
+ }
2581
+ const envLevel = process.env.LOG_LEVEL;
2582
+ if (envLevel) {
2583
+ return envLevel;
2584
+ }
2585
+ return "info";
2586
+ }
2587
+ function createLogger(options = {}) {
2588
+ const level = resolveLevel(options);
2589
+ const baseOptions = {
2590
+ name: "elisym-cli",
2591
+ level,
2592
+ redact: {
2593
+ paths: DEFAULT_REDACT_PATHS,
2594
+ censor: makeCensor()
2595
+ }
2596
+ };
2597
+ let logger;
2598
+ if (options.destination) {
2599
+ logger = pino(baseOptions, options.destination);
2600
+ } else if (options.tty === true) {
2601
+ logger = pino({
2602
+ ...baseOptions,
2603
+ transport: {
2604
+ target: "pino-pretty",
2605
+ options: { destination: 2, colorize: true, translateTime: "HH:MM:ss" }
2606
+ }
2607
+ });
2608
+ } else {
2609
+ logger = pino(baseOptions, pino.destination(2));
2610
+ }
2611
+ function logWithIndent(line) {
2612
+ process.stdout.write(` ${sanitizeForTerminal(line)}
2613
+ `);
2614
+ }
2615
+ return {
2616
+ logger,
2617
+ logWithIndent,
2618
+ bannerLog: logWithIndent
2619
+ };
2620
+ }
2621
+ var MIME_BY_EXT = {
2622
+ ".png": "image/png",
2623
+ ".jpg": "image/jpeg",
2624
+ ".jpeg": "image/jpeg",
2625
+ ".gif": "image/gif",
2626
+ ".webp": "image/webp",
2627
+ ".svg": "image/svg+xml",
2628
+ ".avif": "image/avif"
2629
+ };
2630
+ function mimeFromPath(path) {
2631
+ return MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
2632
+ }
2633
+ var SESSIONS_DIR_NAME = ".sessions";
2634
+ var SESSION_MAX_CONCURRENT_JOBS = 2;
2635
+ var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
2636
+ var SESSION_MAX_PER_CUSTOMER = 64;
2637
+ var SESSION_MAX_TURN_BYTES = 256 * 1024;
2638
+ var SESSION_MAX_FILE_BYTES = 8 * 1024 * 1024;
2639
+ var SESSION_GLOBAL_MAX_BYTES = 512 * 1024 * 1024;
2640
+ var SESSION_GLOBAL_EVICT_TO_BYTES = Math.floor(SESSION_GLOBAL_MAX_BYTES * 0.9);
2641
+ var SESSION_COMPACTION_TRIGGER_CHARS = 12e4;
2642
+ var SESSION_COMPACTION_KEEP_CHARS = Math.floor(SESSION_COMPACTION_TRIGGER_CHARS / 2);
2643
+ var SESSION_COMPACTION_MAX_STRIKES = 2;
2644
+ var SESSION_SUMMARY_PREFIX = "[Conversation summary]";
2645
+ var SESSION_CONTEXT_UNAVAILABLE = "[earlier context unavailable]";
2646
+ var TRUNCATION_MARKER = "\n[truncated]";
2647
+ var CUSTOMER_PUBKEY_REGEX = /^[0-9a-f]{64}$/;
2648
+ var DIR_MODE = 448;
2649
+ var FILE_MODE = 384;
2650
+ var SessionStore = class {
2651
+ root;
2652
+ log;
2653
+ ttlMs;
2654
+ maxPerCustomer;
2655
+ maxTurnBytes;
2656
+ maxFileBytes;
2657
+ globalMaxBytes;
2658
+ globalEvictToBytes;
2659
+ compactionTriggerChars;
2660
+ compactionKeepChars;
2661
+ /** Session-key -> release promise. Entry present = mutex held. */
2662
+ locks = /* @__PURE__ */ new Map();
2663
+ /** Session-key -> admitted-job counts (live intake + jobId-keyed recovery). */
2664
+ admitted = /* @__PURE__ */ new Map();
2665
+ /** Recovery registration index: jobId -> session key (for the release sweep). */
2666
+ recoveryByJob = /* @__PURE__ */ new Map();
2667
+ /** Session-key -> consecutive compaction failures (in-process, reset on restart). */
2668
+ compactionStrikes = /* @__PURE__ */ new Map();
2669
+ /** Running total of session-file bytes (established by `init()`'s scan). */
2670
+ globalBytes = 0;
2671
+ constructor(agentDir, logger, tuning) {
2672
+ this.root = join(agentDir, SESSIONS_DIR_NAME);
2673
+ this.log = logger ?? (() => {
2674
+ });
2675
+ this.ttlMs = tuning?.ttlMs ?? SESSION_TTL_MS;
2676
+ this.maxPerCustomer = tuning?.maxPerCustomer ?? SESSION_MAX_PER_CUSTOMER;
2677
+ this.maxTurnBytes = tuning?.maxTurnBytes ?? SESSION_MAX_TURN_BYTES;
2678
+ this.maxFileBytes = tuning?.maxFileBytes ?? SESSION_MAX_FILE_BYTES;
2679
+ this.globalMaxBytes = tuning?.globalMaxBytes ?? SESSION_GLOBAL_MAX_BYTES;
2680
+ this.globalEvictToBytes = tuning?.globalEvictToBytes ?? (tuning?.globalMaxBytes !== void 0 ? Math.floor(tuning.globalMaxBytes * 0.9) : SESSION_GLOBAL_EVICT_TO_BYTES);
2681
+ this.compactionTriggerChars = tuning?.compactionTriggerChars ?? SESSION_COMPACTION_TRIGGER_CHARS;
2682
+ this.compactionKeepChars = tuning?.compactionKeepChars ?? (tuning?.compactionTriggerChars !== void 0 ? Math.floor(tuning.compactionTriggerChars / 2) : SESSION_COMPACTION_KEEP_CHARS);
2683
+ }
2684
+ /**
2685
+ * Startup: establish the global byte counter (excluding `.corrupt.*`
2686
+ * sidecars) and run a GC sweep. Call BEFORE `recoverPendingJobs` (mirrors
2687
+ * the ledger prune-before-recovery ordering).
2688
+ */
2689
+ init() {
2690
+ this.globalBytes = 0;
2691
+ for (const file of this.listSessionFiles()) {
2692
+ this.globalBytes += file.size;
2693
+ }
2694
+ this.gc();
2695
+ }
2696
+ /**
2697
+ * TTL sweep: delete sessions idle past the TTL and `.corrupt.*` sidecars
2698
+ * older than the TTL, then rmdir emptied customer dirs. A victim is deleted
2699
+ * only while holding its try-acquired mutex with a zero admitted count -
2700
+ * check and unlink are synchronous, so nothing can interleave.
2701
+ */
2702
+ gc() {
2703
+ const cutoff = Date.now() - this.ttlMs;
2704
+ for (const file of this.listSessionFiles()) {
2705
+ if (file.mtimeMs >= cutoff) {
2706
+ continue;
2707
+ }
2708
+ this.deleteIfIdle(file.customerId, file.sessionId);
2709
+ }
2710
+ this.pruneSidecars(cutoff);
2711
+ this.pruneEmptyCustomerDirs();
2712
+ }
2713
+ // ── Mutex ──────────────────────────────────────────────────────────────
2714
+ /**
2715
+ * Acquire the session mutex. Lock order invariant (runtime-enforced): the
2716
+ * caller already holds its `pLimit` slot - a mutex holder never waits for a
2717
+ * slot, so no lock-order inversion is possible.
2718
+ */
2719
+ async acquire(customerId, sessionId) {
2720
+ const key = sessionKey(customerId, sessionId);
2721
+ for (; ; ) {
2722
+ const current = this.locks.get(key);
2723
+ if (current === void 0) {
2724
+ return this.installLock(key);
2725
+ }
2726
+ await current.promise;
2727
+ }
2728
+ }
2729
+ /** Non-blocking acquire for GC/eviction. Returns a release fn or null. */
2730
+ tryAcquireSync(key) {
2731
+ if (this.locks.has(key)) {
2732
+ return null;
2733
+ }
2734
+ return this.installLock(key);
2735
+ }
2736
+ installLock(key) {
2737
+ let release;
2738
+ const promise = new Promise((resolve4) => {
2739
+ release = resolve4;
2740
+ });
2741
+ const entry = { promise, release };
2742
+ this.locks.set(key, entry);
2743
+ return () => {
2744
+ if (this.locks.get(key) === entry) {
2745
+ this.locks.delete(key);
2746
+ }
2747
+ entry.release();
2748
+ };
2749
+ }
2750
+ // ── Admitted-job counter ───────────────────────────────────────────────
2751
+ admittedCount(customerId, sessionId) {
2752
+ const entry = this.admitted.get(sessionKey(customerId, sessionId));
2753
+ return entry === void 0 ? 0 : entry.live + entry.recovery.size;
2754
+ }
2755
+ /** Live-intake registration. Pair with `releaseLive` in the job's finally. */
2756
+ admitLive(customerId, sessionId) {
2757
+ const entry = this.admittedEntry(sessionKey(customerId, sessionId));
2758
+ entry.live++;
2759
+ }
2760
+ releaseLive(customerId, sessionId) {
2761
+ const key = sessionKey(customerId, sessionId);
2762
+ const entry = this.admitted.get(key);
2763
+ if (entry === void 0) {
2764
+ return;
2765
+ }
2766
+ entry.live = Math.max(0, entry.live - 1);
2767
+ this.dropAdmittedIfEmpty(key, entry);
2768
+ }
2769
+ /**
2770
+ * Recovery pre-pass: register the sessions of pending `paid` entries (jobId-
2771
+ * keyed, idempotent across ticks) and release registrations whose ledger
2772
+ * entry has left `paid` (status predicate, re-checked every tick). Runs at
2773
+ * the top of every recovery tick, BEFORE its empty-pending early return -
2774
+ * releases are needed precisely when entries have left the pending set.
2775
+ */
2776
+ syncRecoveryRegistrations(refs, isStillPaid) {
2777
+ for (const ref of refs) {
2778
+ if (this.recoveryByJob.has(ref.jobId)) {
2779
+ continue;
2780
+ }
2781
+ const key = sessionKey(ref.customerId, ref.sessionId);
2782
+ this.recoveryByJob.set(ref.jobId, key);
2783
+ this.admittedEntry(key).recovery.add(ref.jobId);
2784
+ }
2785
+ for (const [jobId, key] of this.recoveryByJob) {
2786
+ if (isStillPaid(jobId)) {
2787
+ continue;
2788
+ }
2789
+ this.recoveryByJob.delete(jobId);
2790
+ const entry = this.admitted.get(key);
2791
+ if (entry !== void 0) {
2792
+ entry.recovery.delete(jobId);
2793
+ this.dropAdmittedIfEmpty(key, entry);
2794
+ }
2795
+ }
2796
+ }
2797
+ // ── Open / append / compaction (call with the session mutex held) ──────
2798
+ /**
2799
+ * Load the session for a job. Applies the load-time TTL check (expired file
2800
+ * is deleted BEFORE recording - expiry is a hard reset, never a resurrection),
2801
+ * the global byte cap (at-cap global LRU eviction, excluding this session),
2802
+ * and the per-customer LRU cap. `excludeJobId` (crash recovery) removes the
2803
+ * recovering job's own turns from the replayed history and reports which of
2804
+ * its roles are already recorded.
2805
+ */
2806
+ open(customerId, sessionId, excludeJobId) {
2807
+ validateKeys(customerId, sessionId);
2808
+ const path = this.sessionPath(customerId, sessionId);
2809
+ const stateless = {
2810
+ stateless: true,
2811
+ known: false,
2812
+ messages: [],
2813
+ compactionNeeded: false,
2814
+ recordedRoles: /* @__PURE__ */ new Set()
2815
+ };
2816
+ let exists = existsSync(path);
2817
+ if (exists) {
2818
+ const stat2 = statSync(path);
2819
+ if (Date.now() - stat2.mtimeMs > this.ttlMs) {
2820
+ this.deleteSessionFile(customerId, sessionId);
2821
+ exists = false;
2822
+ this.log(`[sessions] ${sessionId.slice(0, 8)} expired; starting fresh`);
2823
+ }
2824
+ }
2825
+ if (this.globalBytes >= this.globalMaxBytes) {
2826
+ this.evictGlobal(sessionKey(customerId, sessionId));
2827
+ if (this.globalBytes >= this.globalMaxBytes) {
2828
+ this.log(
2829
+ "[sessions] global byte cap reached and no evictable victim; processing stateless"
2830
+ );
2831
+ return stateless;
2832
+ }
2833
+ }
2834
+ if (!exists && !this.ensureCustomerCapacity(customerId, sessionId)) {
2835
+ this.log(
2836
+ `[sessions] customer session cap reached with no evictable victim; processing stateless`
2837
+ );
2838
+ return stateless;
2839
+ }
2840
+ if (!exists) {
2841
+ return {
2842
+ stateless: false,
2843
+ known: false,
2844
+ messages: [],
2845
+ compactionNeeded: false,
2846
+ recordedRoles: /* @__PURE__ */ new Set()
2847
+ };
2848
+ }
2849
+ const lines = this.readSessionLines(customerId, sessionId);
2850
+ if (lines === null) {
2851
+ return {
2852
+ stateless: false,
2853
+ known: false,
2854
+ messages: [],
2855
+ compactionNeeded: false,
2856
+ recordedRoles: /* @__PURE__ */ new Set()
2857
+ };
2858
+ }
2859
+ const recordedRoles = /* @__PURE__ */ new Set();
2860
+ if (excludeJobId !== void 0) {
2861
+ for (const line of lines) {
2862
+ if (line.type === "turn" && line.jobId === excludeJobId) {
2863
+ recordedRoles.add(line.role);
2864
+ }
2865
+ }
2866
+ }
2867
+ const messages = assembleReplay(lines, excludeJobId);
2868
+ const replayedChars = messages.reduce((sum, message) => sum + message.content.length, 0);
2869
+ const compactionNeeded = replayedChars > this.compactionTriggerChars;
2870
+ return {
2871
+ stateless: false,
2872
+ known: true,
2873
+ messages,
2874
+ compactionNeeded,
2875
+ summarizeText: compactionNeeded ? buildSummarizeText(lines, this.keptTailStart(lines)) : void 0,
2876
+ recordedRoles
2877
+ };
2878
+ }
2879
+ /**
2880
+ * Record a completed exchange. Called after execution success, BEFORE the
2881
+ * ledger's `markExecuted` flush and before result seeding (§2 append timing).
2882
+ * Both turns go in one write; dedupe is `(jobId, role)` via `skipRoles`.
2883
+ */
2884
+ appendExchange(customerId, sessionId, exchange) {
2885
+ validateKeys(customerId, sessionId);
2886
+ const ts = Math.floor(Date.now() / 1e3);
2887
+ const lines = [];
2888
+ if (!exchange.skipRoles?.has("user")) {
2889
+ lines.push({
2890
+ type: "turn",
2891
+ role: "user",
2892
+ content: truncateTurn(exchange.userContent, this.maxTurnBytes),
2893
+ jobId: exchange.jobId,
2894
+ capability: exchange.capability,
2895
+ ts
2896
+ });
2897
+ }
2898
+ if (!exchange.skipRoles?.has("assistant")) {
2899
+ lines.push({
2900
+ type: "turn",
2901
+ role: "assistant",
2902
+ content: truncateTurn(exchange.assistantContent, this.maxTurnBytes),
2903
+ jobId: exchange.jobId,
2904
+ ts
2905
+ });
2906
+ }
2907
+ if (lines.length === 0) {
2908
+ return;
2909
+ }
2910
+ const payload = lines.map((line) => JSON.stringify(line)).join("\n") + "\n";
2911
+ const path = this.sessionPath(customerId, sessionId);
2912
+ try {
2913
+ appendFileSync(path, payload, { mode: FILE_MODE });
2914
+ } catch (error) {
2915
+ if (error.code !== "ENOENT") {
2916
+ throw error;
2917
+ }
2918
+ mkdirSync(join(this.root, customerId), { recursive: true, mode: DIR_MODE });
2919
+ appendFileSync(path, payload, { mode: FILE_MODE });
2920
+ }
2921
+ this.globalBytes += Buffer.byteLength(payload);
2922
+ }
2923
+ /**
2924
+ * Compaction rewrite: `[summary line, ...kept tail]`, atomic (temp + rename).
2925
+ * Returns the fresh replayed history. Throws on an empty summary - the
2926
+ * caller must treat that as a compaction failure, not rewrite on it (an
2927
+ * empty summary line is skipped at replay, silently vaporizing everything
2928
+ * it summarized).
2929
+ */
2930
+ compact(customerId, sessionId, summary) {
2931
+ if (summary.trim().length === 0) {
2932
+ throw new Error("empty summary");
2933
+ }
2934
+ const rewritten = this.rewriteWithSummary(customerId, sessionId, summary);
2935
+ this.compactionStrikes.delete(sessionKey(customerId, sessionId));
2936
+ return rewritten;
2937
+ }
2938
+ /**
2939
+ * Record a compaction failure (summarize error or empty summary). After
2940
+ * `SESSION_COMPACTION_MAX_STRIKES` consecutive failures, force-truncate
2941
+ * without a summarize call - same canonical `[summary, ...kept tail]` shape
2942
+ * with the fixed `[earlier context unavailable]` content - so a small-window
2943
+ * model can never permanently brick a session into paid-job failures.
2944
+ */
2945
+ compactionFailed(customerId, sessionId) {
2946
+ const key = sessionKey(customerId, sessionId);
2947
+ const strikes = (this.compactionStrikes.get(key) ?? 0) + 1;
2948
+ if (strikes < SESSION_COMPACTION_MAX_STRIKES) {
2949
+ this.compactionStrikes.set(key, strikes);
2950
+ return { forced: false };
2951
+ }
2952
+ this.compactionStrikes.delete(key);
2953
+ const messages = this.rewriteWithSummary(customerId, sessionId, SESSION_CONTEXT_UNAVAILABLE);
2954
+ this.log(
2955
+ `[sessions] ${sessionId.slice(0, 8)} force-truncated after repeated compaction failures`
2956
+ );
2957
+ return { forced: true, messages };
2958
+ }
2959
+ // ── Internals ──────────────────────────────────────────────────────────
2960
+ admittedEntry(key) {
2961
+ let entry = this.admitted.get(key);
2962
+ if (entry === void 0) {
2963
+ entry = { live: 0, recovery: /* @__PURE__ */ new Set() };
2964
+ this.admitted.set(key, entry);
2965
+ }
2966
+ return entry;
2967
+ }
2968
+ dropAdmittedIfEmpty(key, entry) {
2969
+ if (entry.live === 0 && entry.recovery.size === 0) {
2970
+ this.admitted.delete(key);
2971
+ }
2972
+ }
2973
+ sessionPath(customerId, sessionId) {
2974
+ return join(this.root, customerId, `${sessionId}.jsonl`);
2975
+ }
2976
+ /**
2977
+ * Parse the transcript. A torn LAST line (crash mid-append) is dropped AND
2978
+ * repaired away: the file is atomically rewritten without it, because a
2979
+ * later `appendFileSync` would otherwise glue its first line onto the torn
2980
+ * fragment (the fragment has no trailing newline), corrupting a mid-file
2981
+ * line. Any other malformed line - or a file over the backstop - is treated
2982
+ * as corruption: the file is renamed to a `.corrupt.<ts>` sidecar (outside
2983
+ * the byte counter, pruned by the TTL sweep) and the session starts fresh.
2984
+ * Callers hold the session mutex, so the repair cannot race an append.
2985
+ */
2986
+ readSessionLines(customerId, sessionId) {
2987
+ const path = this.sessionPath(customerId, sessionId);
2988
+ let raw;
2989
+ let size;
2990
+ try {
2991
+ size = statSync(path).size;
2992
+ raw = readFileSync(path, "utf-8");
2993
+ } catch {
2994
+ return null;
2995
+ }
2996
+ if (size > this.maxFileBytes) {
2997
+ this.quarantine(customerId, sessionId, size);
2998
+ return null;
2999
+ }
3000
+ const rawLines = raw.split("\n");
3001
+ const lines = [];
3002
+ let torn = false;
3003
+ for (let index = 0; index < rawLines.length; index++) {
3004
+ const text = rawLines[index].trim();
3005
+ if (text.length === 0) {
3006
+ continue;
3007
+ }
3008
+ const parsed = parseSessionLine(text);
3009
+ if (parsed === null) {
3010
+ const isLastNonEmpty = rawLines.slice(index + 1).every((rest) => rest.trim().length === 0);
3011
+ if (isLastNonEmpty) {
3012
+ torn = true;
3013
+ break;
3014
+ }
3015
+ this.quarantine(customerId, sessionId, size);
3016
+ return null;
3017
+ }
3018
+ lines.push(parsed);
3019
+ }
3020
+ if (torn) {
3021
+ const payload = lines.map((line) => JSON.stringify(line)).join("\n") + (lines.length > 0 ? "\n" : "");
3022
+ const tempPath = `${path}.tmp`;
3023
+ writeFileSync(tempPath, payload, { mode: FILE_MODE });
3024
+ renameSync(tempPath, path);
3025
+ this.globalBytes = Math.max(0, this.globalBytes - size + Buffer.byteLength(payload));
3026
+ }
3027
+ return lines;
3028
+ }
3029
+ quarantine(customerId, sessionId, size) {
3030
+ const path = this.sessionPath(customerId, sessionId);
3031
+ try {
3032
+ renameSync(path, `${path}.corrupt.${Date.now()}`);
3033
+ this.globalBytes = Math.max(0, this.globalBytes - size);
3034
+ this.compactionStrikes.delete(sessionKey(customerId, sessionId));
3035
+ this.log(`[sessions] ${sessionId.slice(0, 8)} corrupt; quarantined and reset`);
3036
+ } catch {
3037
+ }
3038
+ }
3039
+ /**
3040
+ * Kept-tail start index: the longest suffix of TURN lines whose combined
3041
+ * stored size stays within the keep budget, floored at the most recent turn.
3042
+ */
3043
+ keptTailStart(lines) {
3044
+ let budget = this.compactionKeepChars;
3045
+ let start = lines.length;
3046
+ for (let index = lines.length - 1; index >= 0; index--) {
3047
+ const line = lines[index];
3048
+ if (line.type !== "turn") {
3049
+ break;
3050
+ }
3051
+ const cost = line.content.length;
3052
+ if (cost > budget && start < lines.length) {
3053
+ break;
3054
+ }
3055
+ budget -= cost;
3056
+ start = index;
3057
+ if (budget <= 0) {
3058
+ break;
3059
+ }
3060
+ }
3061
+ if (start === lines.length && lines.length > 0) {
3062
+ start = lines.length - 1;
3063
+ }
3064
+ return start;
3065
+ }
3066
+ rewriteWithSummary(customerId, sessionId, summary) {
3067
+ validateKeys(customerId, sessionId);
3068
+ const lines = this.readSessionLines(customerId, sessionId) ?? [];
3069
+ const tailStart = this.keptTailStart(lines);
3070
+ const tail = lines.slice(tailStart).flatMap((line) => {
3071
+ if (line.type !== "turn") {
3072
+ return [];
3073
+ }
3074
+ if (line.content.length > this.compactionKeepChars) {
3075
+ return [
3076
+ {
3077
+ ...line,
3078
+ content: line.content.slice(0, this.compactionKeepChars) + TRUNCATION_MARKER
3079
+ }
3080
+ ];
3081
+ }
3082
+ return [line];
3083
+ });
3084
+ const summaryLine = {
3085
+ type: "summary",
3086
+ content: summary,
3087
+ ts: Math.floor(Date.now() / 1e3)
3088
+ };
3089
+ const rewritten = [summaryLine, ...tail];
3090
+ const payload = rewritten.map((line) => JSON.stringify(line)).join("\n") + "\n";
3091
+ const path = this.sessionPath(customerId, sessionId);
3092
+ const oldSize = existsSync(path) ? statSync(path).size : 0;
3093
+ const tempPath = `${path}.tmp`;
3094
+ mkdirSync(join(this.root, customerId), { recursive: true, mode: DIR_MODE });
3095
+ writeFileSync(tempPath, payload, { mode: FILE_MODE });
3096
+ renameSync(tempPath, path);
3097
+ this.globalBytes = Math.max(0, this.globalBytes - oldSize + Buffer.byteLength(payload));
3098
+ return assembleReplay(rewritten, void 0);
3099
+ }
3100
+ /** New-session admission under the per-customer cap: LRU-evict an idle victim. */
3101
+ ensureCustomerCapacity(customerId, sessionId) {
3102
+ const dir = join(this.root, customerId);
3103
+ let entries;
3104
+ try {
3105
+ entries = readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
3106
+ } catch {
3107
+ return true;
3108
+ }
3109
+ if (entries.length < this.maxPerCustomer) {
3110
+ return true;
3111
+ }
3112
+ const candidates = entries.map((name) => {
3113
+ const candidateSessionId = name.slice(0, -".jsonl".length);
3114
+ const path = join(dir, name);
3115
+ try {
3116
+ return { sessionId: candidateSessionId, mtimeMs: statSync(path).mtimeMs };
3117
+ } catch {
3118
+ return null;
3119
+ }
3120
+ }).filter(
3121
+ (candidate) => candidate !== null
3122
+ ).sort((a, b) => a.mtimeMs - b.mtimeMs);
3123
+ for (const candidate of candidates) {
3124
+ if (candidate.sessionId === sessionId) {
3125
+ continue;
3126
+ }
3127
+ if (this.deleteIfIdle(customerId, candidate.sessionId)) {
3128
+ return true;
3129
+ }
3130
+ }
3131
+ return false;
3132
+ }
3133
+ /**
3134
+ * Global LRU eviction down to the high-water mark. Excludes `selfKey` (the
3135
+ * arriving job's own session must not be its own victim).
3136
+ */
3137
+ evictGlobal(selfKey) {
3138
+ const files = this.listSessionFiles().sort((a, b) => a.mtimeMs - b.mtimeMs);
3139
+ for (const file of files) {
3140
+ if (this.globalBytes <= this.globalEvictToBytes) {
3141
+ return;
3142
+ }
3143
+ if (sessionKey(file.customerId, file.sessionId) === selfKey) {
3144
+ continue;
3145
+ }
3146
+ this.deleteIfIdle(file.customerId, file.sessionId);
3147
+ }
3148
+ }
3149
+ /**
3150
+ * Delete a session iff idle: try-acquired mutex AND zero admitted count.
3151
+ * The mutex alone is not an in-flight guard - an admitted job spends its
3152
+ * whole payment phase before the mutex. Check and unlink are synchronous.
3153
+ */
3154
+ deleteIfIdle(customerId, sessionId) {
3155
+ const key = sessionKey(customerId, sessionId);
3156
+ const release = this.tryAcquireSync(key);
3157
+ if (release === null) {
3158
+ return false;
3159
+ }
3160
+ try {
3161
+ if (this.admittedCount(customerId, sessionId) > 0) {
3162
+ return false;
3163
+ }
3164
+ this.deleteSessionFile(customerId, sessionId);
3165
+ return true;
3166
+ } finally {
3167
+ release();
3168
+ }
3169
+ }
3170
+ deleteSessionFile(customerId, sessionId) {
3171
+ const path = this.sessionPath(customerId, sessionId);
3172
+ let size = 0;
3173
+ try {
3174
+ size = statSync(path).size;
3175
+ unlinkSync(path);
3176
+ } catch {
3177
+ return;
3178
+ }
3179
+ this.globalBytes = Math.max(0, this.globalBytes - size);
3180
+ this.compactionStrikes.delete(sessionKey(customerId, sessionId));
3181
+ }
3182
+ listSessionFiles() {
3183
+ const out = [];
3184
+ let customers;
3185
+ try {
3186
+ customers = readdirSync(this.root);
3187
+ } catch {
3188
+ return out;
3189
+ }
3190
+ for (const customerId of customers) {
3191
+ if (!CUSTOMER_PUBKEY_REGEX.test(customerId)) {
3192
+ continue;
3193
+ }
3194
+ let names;
3195
+ try {
3196
+ names = readdirSync(join(this.root, customerId));
3197
+ } catch {
3198
+ continue;
3199
+ }
3200
+ for (const name of names) {
3201
+ if (!name.endsWith(".jsonl")) {
3202
+ continue;
3203
+ }
3204
+ const sessionId = name.slice(0, -".jsonl".length);
3205
+ if (!SESSION_ID_REGEX.test(sessionId)) {
3206
+ continue;
3207
+ }
3208
+ try {
3209
+ const stat2 = statSync(join(this.root, customerId, name));
3210
+ out.push({ customerId, sessionId, size: stat2.size, mtimeMs: stat2.mtimeMs });
3211
+ } catch {
3212
+ }
3213
+ }
3214
+ }
3215
+ return out;
3216
+ }
3217
+ pruneSidecars(cutoffMs) {
3218
+ let customers;
3219
+ try {
3220
+ customers = readdirSync(this.root);
3221
+ } catch {
3222
+ return;
3223
+ }
3224
+ for (const customerId of customers) {
3225
+ const dir = join(this.root, customerId);
3226
+ let names;
3227
+ try {
3228
+ names = readdirSync(dir);
3229
+ } catch {
3230
+ continue;
3231
+ }
3232
+ for (const name of names) {
3233
+ if (!name.includes(".corrupt.")) {
3234
+ continue;
3235
+ }
3236
+ const path = join(dir, name);
3237
+ try {
3238
+ if (statSync(path).mtimeMs < cutoffMs) {
3239
+ unlinkSync(path);
3240
+ }
3241
+ } catch {
3242
+ }
3243
+ }
3244
+ }
3245
+ }
3246
+ pruneEmptyCustomerDirs() {
3247
+ let customers;
3248
+ try {
3249
+ customers = readdirSync(this.root);
3250
+ } catch {
3251
+ return;
3252
+ }
3253
+ for (const customerId of customers) {
3254
+ const dir = join(this.root, customerId);
3255
+ try {
3256
+ if (readdirSync(dir).length === 0) {
3257
+ rmdirSync(dir);
3258
+ }
3259
+ } catch {
3260
+ }
3261
+ }
3262
+ }
3263
+ };
3264
+ function sessionKey(customerId, sessionId) {
3265
+ return `${customerId}/${sessionId}`;
3266
+ }
3267
+ function validateKeys(customerId, sessionId) {
3268
+ if (!CUSTOMER_PUBKEY_REGEX.test(customerId)) {
3269
+ throw new Error("SessionStore: invalid customer pubkey");
2002
3270
  }
2003
- const envValue = process.env[descriptor.envVar];
2004
- if (envValue) {
2005
- return { apiKey: envValue, origin: "env" };
3271
+ if (!SESSION_ID_REGEX.test(sessionId)) {
3272
+ throw new Error("SessionStore: invalid session id");
2006
3273
  }
2007
- const skillList = dependentSkills.length > 0 ? dependentSkills.join(", ") : "<none>";
2008
- return {
2009
- error: `Provider "${provider}" needs an API key (required by skill(s): ${skillList}). Set secrets.llm_api_keys.${provider} via 'npx @elisym/cli profile <agent>' or export ${descriptor.envVar}.`
2010
- };
2011
3274
  }
2012
- function sanitizeForTerminal(value) {
2013
- return value.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
2014
- }
2015
- function resolveLevel(options) {
2016
- if (options.level) {
2017
- return options.level;
3275
+ function parseSessionLine(text) {
3276
+ let value;
3277
+ try {
3278
+ value = JSON.parse(text);
3279
+ } catch {
3280
+ return null;
2018
3281
  }
2019
- if (options.verbose) {
2020
- return "debug";
3282
+ if (typeof value !== "object" || value === null) {
3283
+ return null;
2021
3284
  }
2022
- const envLevel = process.env.LOG_LEVEL;
2023
- if (envLevel) {
2024
- return envLevel;
3285
+ const record = value;
3286
+ if (record.type === "summary") {
3287
+ if (typeof record.content !== "string" || typeof record.ts !== "number") {
3288
+ return null;
3289
+ }
3290
+ return { type: "summary", content: record.content, ts: record.ts };
2025
3291
  }
2026
- return "info";
3292
+ if (record.type === "turn") {
3293
+ if (record.role !== "user" && record.role !== "assistant" || typeof record.content !== "string" || typeof record.jobId !== "string" || typeof record.ts !== "number") {
3294
+ return null;
3295
+ }
3296
+ return {
3297
+ type: "turn",
3298
+ role: record.role,
3299
+ content: record.content,
3300
+ jobId: record.jobId,
3301
+ capability: typeof record.capability === "string" ? record.capability : void 0,
3302
+ ts: record.ts
3303
+ };
3304
+ }
3305
+ return null;
2027
3306
  }
2028
- function createLogger(options = {}) {
2029
- const level = resolveLevel(options);
2030
- const baseOptions = {
2031
- name: "elisym-cli",
2032
- level,
2033
- redact: {
2034
- paths: DEFAULT_REDACT_PATHS,
2035
- censor: makeCensor()
3307
+ function assembleReplay(lines, excludeJobId) {
3308
+ const raw = [];
3309
+ for (const line of lines) {
3310
+ if (line.type === "summary") {
3311
+ raw.push({ role: "user", content: `${SESSION_SUMMARY_PREFIX}
3312
+ ${line.content}` });
3313
+ } else if (line.jobId !== excludeJobId) {
3314
+ raw.push({ role: line.role, content: line.content });
3315
+ }
3316
+ }
3317
+ const nonEmpty = raw.filter((message) => message.content.trim().length > 0);
3318
+ let start = 0;
3319
+ while (start < nonEmpty.length && nonEmpty[start].role === "assistant") {
3320
+ start++;
3321
+ }
3322
+ let end = nonEmpty.length;
3323
+ while (end > start && nonEmpty[end - 1].role === "user") {
3324
+ end--;
3325
+ }
3326
+ const bounded = nonEmpty.slice(start, end);
3327
+ const merged = [];
3328
+ for (const message of bounded) {
3329
+ const previous = merged[merged.length - 1];
3330
+ if (previous !== void 0 && previous.role === message.role) {
3331
+ previous.content = `${previous.content}
3332
+
3333
+ ${message.content}`;
3334
+ } else {
3335
+ merged.push({ ...message });
2036
3336
  }
2037
- };
2038
- let logger;
2039
- if (options.destination) {
2040
- logger = pino(baseOptions, options.destination);
2041
- } else if (options.tty === true) {
2042
- logger = pino({
2043
- ...baseOptions,
2044
- transport: {
2045
- target: "pino-pretty",
2046
- options: { destination: 2, colorize: true, translateTime: "HH:MM:ss" }
2047
- }
2048
- });
2049
- } else {
2050
- logger = pino(baseOptions, pino.destination(2));
2051
3337
  }
2052
- function logWithIndent(line) {
2053
- process.stdout.write(` ${sanitizeForTerminal(line)}
2054
- `);
3338
+ return merged;
3339
+ }
3340
+ function buildSummarizeText(lines, tailStart) {
3341
+ const parts = [];
3342
+ for (const line of lines.slice(0, tailStart)) {
3343
+ if (line.type === "summary") {
3344
+ parts.push(`Summary of earlier conversation:
3345
+ ${line.content}`);
3346
+ } else {
3347
+ parts.push(`${line.role === "user" ? "User" : "Assistant"}: ${line.content}`);
3348
+ }
2055
3349
  }
2056
- return {
2057
- logger,
2058
- logWithIndent,
2059
- bannerLog: logWithIndent
2060
- };
3350
+ return parts.join("\n\n");
2061
3351
  }
2062
- var MIME_BY_EXT = {
2063
- ".png": "image/png",
2064
- ".jpg": "image/jpeg",
2065
- ".jpeg": "image/jpeg",
2066
- ".gif": "image/gif",
2067
- ".webp": "image/webp",
2068
- ".svg": "image/svg+xml",
2069
- ".avif": "image/avif"
2070
- };
2071
- function mimeFromPath(path) {
2072
- return MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
3352
+ function truncateTurn(content, maxBytes) {
3353
+ if (Buffer.byteLength(content) <= maxBytes) {
3354
+ return content;
3355
+ }
3356
+ let sliced = content.slice(0, maxBytes - TRUNCATION_MARKER.length);
3357
+ while (Buffer.byteLength(sliced) > maxBytes - TRUNCATION_MARKER.length) {
3358
+ sliced = sliced.slice(0, Math.floor(sliced.length * 0.9));
3359
+ }
3360
+ return sliced + TRUNCATION_MARKER;
2073
3361
  }
2074
3362
 
2075
3363
  // src/x402/errors.ts
@@ -2113,6 +3401,29 @@ var LEDGER_GC_INTERVAL_MS = 60 * 60 * 1e3;
2113
3401
  var LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
2114
3402
  var MAX_PAID_AGE_MS = 24 * 60 * 60 * 1e3;
2115
3403
  var SIG_PATH_TIMEOUT_MS = 60 * 1e3;
3404
+ 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.";
3405
+ function buildUserRecord(data, fileName) {
3406
+ if (fileName === void 0) {
3407
+ return data;
3408
+ }
3409
+ const placeholder = `[file input: ${fileName}]`;
3410
+ return data.length > 0 ? `${data}
3411
+ ${placeholder}` : placeholder;
3412
+ }
3413
+ function buildAssistantRecord(output) {
3414
+ let filePaths = [];
3415
+ if (output.filePaths !== void 0 && output.filePaths.length > 0) {
3416
+ filePaths = output.filePaths;
3417
+ } else if (output.filePath !== void 0) {
3418
+ filePaths = [output.filePath];
3419
+ }
3420
+ if (filePaths.length === 0) {
3421
+ return output.data;
3422
+ }
3423
+ const placeholders = filePaths.map((path) => `[file result: ${basename(path)}]`).join("\n");
3424
+ return output.data.length > 0 ? `${output.data}
3425
+ ${placeholders}` : placeholders;
3426
+ }
2116
3427
  function resolveJobPrice(tags, skills) {
2117
3428
  const skill = skills.route(tags);
2118
3429
  return skill?.priceSubunits ?? 0;
@@ -2229,7 +3540,7 @@ var PAID_GLOBAL_MAX_JOBS_PER_WINDOW = 2e3;
2229
3540
  var MAX_TRACKED_CUSTOMERS = 1e3;
2230
3541
  var GLOBAL_LIMITER_KEY = "__global__";
2231
3542
  var AgentRuntime = class {
2232
- constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor, irohTransport, identity, blossomTransport) {
3543
+ constructor(transport, skills, skillCtx, config, ledger, callbacks = {}, healthMonitor, irohTransport, identity2, blossomTransport, sessionStore) {
2233
3544
  this.transport = transport;
2234
3545
  this.skills = skills;
2235
3546
  this.skillCtx = skillCtx;
@@ -2238,8 +3549,9 @@ var AgentRuntime = class {
2238
3549
  this.callbacks = callbacks;
2239
3550
  this.healthMonitor = healthMonitor;
2240
3551
  this.irohTransport = irohTransport;
2241
- this.identity = identity;
3552
+ this.identity = identity2;
2242
3553
  this.blossomTransport = blossomTransport;
3554
+ this.sessionStore = sessionStore;
2243
3555
  this.limit = pLimit(config.maxConcurrentJobs);
2244
3556
  this.maxQueueSize = config.maxQueueSize ?? config.maxConcurrentJobs * 10;
2245
3557
  }
@@ -2287,6 +3599,102 @@ var AgentRuntime = class {
2287
3599
  * the operator's API key.
2288
3600
  */
2289
3601
  freeLlmLimiters = createFreeLlmLimiterSet();
3602
+ /**
3603
+ * Whether a job takes the session path: session ref present (transport only
3604
+ * populates it for NIP-44-encrypted requests) AND the matched skill opted in
3605
+ * (`context: true` - llm or dynamic-script mode) AND a session store is
3606
+ * wired. Everything else processes stateless - deterministically, never
3607
+ * implicitly per-pubkey.
3608
+ */
3609
+ resolveJobSession(job, skill) {
3610
+ if (job.session === void 0 || this.sessionStore === void 0 || skill === null || skill === void 0 || skill.mode !== "llm" && skill.mode !== "dynamic-script" || skill.context !== true) {
3611
+ return null;
3612
+ }
3613
+ return {
3614
+ store: this.sessionStore,
3615
+ customerId: job.customerId,
3616
+ sessionId: job.session.id,
3617
+ skill
3618
+ };
3619
+ }
3620
+ /**
3621
+ * Run a session-path execution: history load -> execute -> append, under the
3622
+ * session mutex. Lock-order invariant: callers already hold their `pLimit`
3623
+ * slot (live and recovery paths alike), so a mutex holder never waits for a
3624
+ * slot. The mutex is released after the append, BEFORE result seeding and
3625
+ * delivery - it never spans that network I/O (the execute callback's own LLM
3626
+ * call, and the compaction summarize below, are bounded by the LLM HTTP
3627
+ * timeout). Appends only happen on execution success; a failed job leaves
3628
+ * the transcript untouched.
3629
+ */
3630
+ async runSessionExchange(params) {
3631
+ const { store, customerId, sessionId, skill } = params.session;
3632
+ const release = await store.acquire(customerId, sessionId);
3633
+ try {
3634
+ const opened = store.open(
3635
+ customerId,
3636
+ sessionId,
3637
+ params.excludeOwnTurns === true ? params.jobId : void 0
3638
+ );
3639
+ let messages = opened.messages;
3640
+ if (!opened.stateless && opened.compactionNeeded && opened.summarizeText !== void 0) {
3641
+ messages = await this.compactSession({
3642
+ store,
3643
+ customerId,
3644
+ sessionId,
3645
+ skill,
3646
+ summarizeText: opened.summarizeText,
3647
+ fallbackMessages: messages,
3648
+ signal: params.signal,
3649
+ log: params.log,
3650
+ jobId: params.jobId
3651
+ });
3652
+ }
3653
+ const history = opened.stateless || messages.length === 0 ? void 0 : messages;
3654
+ const output = await params.execute(history);
3655
+ if (!opened.stateless) {
3656
+ store.appendExchange(customerId, sessionId, {
3657
+ jobId: params.jobId,
3658
+ capability: skill.name,
3659
+ userContent: params.userRecord,
3660
+ assistantContent: buildAssistantRecord(output),
3661
+ skipRoles: opened.recordedRoles
3662
+ });
3663
+ }
3664
+ return output;
3665
+ } finally {
3666
+ release();
3667
+ }
3668
+ }
3669
+ /**
3670
+ * Compaction: one summarize call on the invoked skill's own resolved LLM,
3671
+ * then an atomic rewrite to `[summary, ...kept tail]`. Any failure - thrown
3672
+ * error, missing client, or an empty-string summary (rewriting on it would
3673
+ * silently vaporize the summarized context, since empty messages are skipped
3674
+ * at replay) - counts a strike and processes this job with the uncompacted
3675
+ * history; after 2 consecutive strikes the store force-truncates without a
3676
+ * summary so a small-window model can never permanently brick a session.
3677
+ */
3678
+ async compactSession(params) {
3679
+ const { store, customerId, sessionId, skill } = params;
3680
+ try {
3681
+ const llm = this.skillCtx.getLlm?.(skill.llmOverride) ?? this.skillCtx.llm;
3682
+ if (llm === void 0) {
3683
+ throw new Error("no LLM client available for session compaction");
3684
+ }
3685
+ const summary = await llm.complete(
3686
+ SESSION_SUMMARIZE_SYSTEM,
3687
+ params.summarizeText,
3688
+ params.signal
3689
+ );
3690
+ return store.compact(customerId, sessionId, summary);
3691
+ } catch (err) {
3692
+ const detail = err instanceof Error ? err.message : String(err);
3693
+ params.log(`[${params.jobId.slice(0, 8)}] Session compaction failed: ${detail}`);
3694
+ const outcome = store.compactionFailed(customerId, sessionId);
3695
+ return outcome.forced && outcome.messages !== void 0 ? outcome.messages : params.fallbackMessages;
3696
+ }
3697
+ }
2290
3698
  /**
2291
3699
  * Inspect an error thrown by `skill.execute()` and, when it carries a
2292
3700
  * billing/invalid signal from the LLM provider, flip the matching
@@ -2431,6 +3839,7 @@ var AgentRuntime = class {
2431
3839
  async run() {
2432
3840
  const log = this.callbacks.onLog ?? console.log;
2433
3841
  this.ledger.pruneOldEntries(LEDGER_RETENTION_MS);
3842
+ this.sessionStore?.init();
2434
3843
  await this.recoverPendingJobs();
2435
3844
  this.recoveryInterval = setInterval(
2436
3845
  () => this.recoverPendingJobs().catch((e) => log(`Recovery error: ${e}`)),
@@ -2442,6 +3851,11 @@ var AgentRuntime = class {
2442
3851
  } catch (e) {
2443
3852
  log(`GC error: ${e.message}`);
2444
3853
  }
3854
+ try {
3855
+ this.sessionStore?.gc();
3856
+ } catch (e) {
3857
+ log(`Session GC error: ${e.message}`);
3858
+ }
2445
3859
  this.cleanupRateLimits();
2446
3860
  if (!this.transport.isHealthy()) {
2447
3861
  log("Warning: no events received in 30+ minutes. Check relay connectivity.");
@@ -2466,6 +3880,15 @@ var AgentRuntime = class {
2466
3880
  const isPaid = matched ? matched.priceSubunits > 0 : false;
2467
3881
  const customerLimiter = isPaid ? this.paidCustomerLimiter : this.freeCustomerLimiter;
2468
3882
  const globalLimiter = isPaid ? this.paidGlobalLimiter : this.freeGlobalLimiter;
3883
+ const jobSession = this.resolveJobSession(job, matched);
3884
+ if (jobSession && jobSession.store.admittedCount(jobSession.customerId, jobSession.sessionId) >= SESSION_MAX_CONCURRENT_JOBS) {
3885
+ this.transport.sendFeedback(job, {
3886
+ type: "error",
3887
+ message: "Session busy - wait for the previous message to finish, then retry."
3888
+ }).catch(() => {
3889
+ });
3890
+ return;
3891
+ }
2469
3892
  if (!customerLimiter.peek(job.customerId).allowed) {
2470
3893
  this.transport.sendFeedback(job, { type: "error", message: "Rate limited, try again later" }).catch(() => {
2471
3894
  });
@@ -2505,16 +3928,22 @@ var AgentRuntime = class {
2505
3928
  this.callbacks.onJobReceived?.(job);
2506
3929
  this.inFlight.add(job.jobId);
2507
3930
  this.pending++;
3931
+ if (jobSession) {
3932
+ jobSession.store.admitLive(jobSession.customerId, jobSession.sessionId);
3933
+ }
2508
3934
  this.limit(() => this.processJob(job)).catch((e) => {
2509
3935
  this.callbacks.onJobError?.(job.jobId, e.message);
2510
3936
  }).finally(() => {
2511
3937
  this.inFlight.delete(job.jobId);
2512
3938
  this.pending--;
3939
+ if (jobSession) {
3940
+ jobSession.store.releaseLive(jobSession.customerId, jobSession.sessionId);
3941
+ }
2513
3942
  });
2514
3943
  });
2515
3944
  log("Agent runtime started. Listening for jobs...");
2516
- await new Promise((resolve3) => {
2517
- this.abortController.signal.addEventListener("abort", () => resolve3(), { once: true });
3945
+ await new Promise((resolve4) => {
3946
+ this.abortController.signal.addEventListener("abort", () => resolve4(), { once: true });
2518
3947
  let shuttingDown = false;
2519
3948
  const onSignal = () => {
2520
3949
  if (shuttingDown) {
@@ -2704,68 +4133,82 @@ var AgentRuntime = class {
2704
4133
  throw new Error("No skill matched for tags: " + job.tags.join(", "));
2705
4134
  }
2706
4135
  log(`[${job.jobId.slice(0, 8)}] Executing skill: ${skill.name}`);
2707
- let output;
2708
4136
  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
- }
4137
+ const jobSession = this.resolveJobSession(job, skill);
4138
+ const runExecution = async (history) => {
4139
+ let budgetExceeded = false;
4140
+ const execAbort = new AbortController();
4141
+ const onOuterAbort = () => execAbort.abort();
2761
4142
  if (signal) {
2762
- signal.removeEventListener("abort", onOuterAbort);
4143
+ if (signal.aborted) {
4144
+ execAbort.abort();
4145
+ } else {
4146
+ signal.addEventListener("abort", onOuterAbort);
4147
+ }
2763
4148
  }
2764
- if (inputFile) {
2765
- await inputFile.cleanup().catch(() => {
4149
+ let budgetTimer;
4150
+ try {
4151
+ const execPromise = skill.execute(
4152
+ {
4153
+ data: inputFile?.inlineText ?? job.input,
4154
+ inputType: job.inputType,
4155
+ tags: job.tags,
4156
+ jobId: job.jobId,
4157
+ filePath: inputFile?.filePath,
4158
+ history,
4159
+ ...jobSession !== null ? { sessionId: jobSession.sessionId } : {}
4160
+ },
4161
+ { ...this.skillCtx, signal: execAbort.signal }
4162
+ );
4163
+ execPromise.catch(() => {
2766
4164
  });
4165
+ if (budgetMs > 0) {
4166
+ return await Promise.race([
4167
+ execPromise,
4168
+ new Promise((_resolve, reject) => {
4169
+ budgetTimer = setTimeout(() => {
4170
+ budgetExceeded = true;
4171
+ execAbort.abort();
4172
+ reject(new ExecutionBudgetExceededError(budgetMs));
4173
+ }, budgetMs);
4174
+ })
4175
+ ]);
4176
+ }
4177
+ return await execPromise;
4178
+ } catch (err) {
4179
+ if (budgetExceeded) {
4180
+ log(`[${job.jobId.slice(0, 8)}] Execution exceeded budget (${budgetMs / 1e3}s)`);
4181
+ throw new ExecutionBudgetExceededError(budgetMs);
4182
+ }
4183
+ const flippedToUnhealthy = this.markHealthFromExecuteError(skill, err, log, job.jobId);
4184
+ if (flippedToUnhealthy) {
4185
+ throw new AgentUnavailableError();
4186
+ }
4187
+ throw err;
4188
+ } finally {
4189
+ if (budgetTimer) {
4190
+ clearTimeout(budgetTimer);
4191
+ }
4192
+ if (signal) {
4193
+ signal.removeEventListener("abort", onOuterAbort);
4194
+ }
4195
+ if (inputFile) {
4196
+ await inputFile.cleanup().catch(() => {
4197
+ });
4198
+ }
2767
4199
  }
2768
- }
4200
+ };
4201
+ const output = jobSession === null ? await runExecution() : await this.runSessionExchange({
4202
+ session: jobSession,
4203
+ jobId: job.jobId,
4204
+ userRecord: buildUserRecord(
4205
+ inputFile?.inlineText ?? job.input,
4206
+ inputFile?.filePath !== void 0 ? job.attachment?.name ?? "attachment" : void 0
4207
+ ),
4208
+ signal,
4209
+ log,
4210
+ execute: runExecution
4211
+ });
2769
4212
  const { attachments, deliveredContent } = await this.buildResultAttachment(
2770
4213
  job.jobId,
2771
4214
  output,
@@ -3144,7 +4587,7 @@ var AgentRuntime = class {
3144
4587
  let refOutcome;
3145
4588
  let result;
3146
4589
  try {
3147
- result = await new Promise((resolve3, reject) => {
4590
+ result = await new Promise((resolve4, reject) => {
3148
4591
  let settled = false;
3149
4592
  let pending = 2;
3150
4593
  let lastResult = { verified: false };
@@ -3154,7 +4597,7 @@ var AgentRuntime = class {
3154
4597
  }
3155
4598
  settled = true;
3156
4599
  clearTimeout(deadline);
3157
- resolve3(verified);
4600
+ resolve4(verified);
3158
4601
  };
3159
4602
  const lose = (verified, reason) => {
3160
4603
  if (settled) {
@@ -3166,7 +4609,7 @@ var AgentRuntime = class {
3166
4609
  if (pending === 0) {
3167
4610
  settled = true;
3168
4611
  clearTimeout(deadline);
3169
- resolve3(lastResult);
4612
+ resolve4(lastResult);
3170
4613
  }
3171
4614
  };
3172
4615
  this.transport.waitForPaymentSignature(job.jobId, job.customerId, verifyAbort.signal, sigPathTimeoutMs).then(async (sig) => {
@@ -3219,7 +4662,7 @@ var AgentRuntime = class {
3219
4662
  }
3220
4663
  settled = true;
3221
4664
  log(`[${job.jobId.slice(0, 8)}] verify deadline (${deadlineMs}ms) hit`);
3222
- resolve3(lastResult);
4665
+ resolve4(lastResult);
3223
4666
  }, deadlineMs);
3224
4667
  if (signal?.aborted) {
3225
4668
  settled = true;
@@ -3272,8 +4715,52 @@ var AgentRuntime = class {
3272
4715
  }
3273
4716
  throw new PaymentTimeoutError();
3274
4717
  }
4718
+ /**
4719
+ * Session refs of pending `paid` entries, for the recovery pre-pass. `paid`
4720
+ * only: `executed` entries do no session work and the status-predicate
4721
+ * release would drop them at the first re-check anyway. Applies the same
4722
+ * session-path scope as live intake (encrypted-only decode + routed skill
4723
+ * with `context: true`, llm or dynamic-script mode) - a context-off or x402
4724
+ * entry parked for hours must not pin session slots for a job that never
4725
+ * touches the transcript.
4726
+ */
4727
+ collectRecoverySessionRefs(pending) {
4728
+ const refs = [];
4729
+ for (const entry of pending) {
4730
+ if (entry.status !== "paid" || entry.raw_event_json === void 0) {
4731
+ continue;
4732
+ }
4733
+ const skill = this.skills.route(entry.tags);
4734
+ if (!skill || skill.mode !== "llm" && skill.mode !== "dynamic-script" || skill.context !== true) {
4735
+ continue;
4736
+ }
4737
+ try {
4738
+ const rawEvent = JSON.parse(entry.raw_event_json);
4739
+ const encrypted = rawEvent.tags?.some((tag) => tag[0] === "encrypted" && tag[1] === "nip44") === true;
4740
+ if (!encrypted || typeof rawEvent.content !== "string") {
4741
+ continue;
4742
+ }
4743
+ const session = decodeJobPayload(rawEvent.content).session;
4744
+ if (session !== void 0) {
4745
+ refs.push({
4746
+ jobId: entry.job_id,
4747
+ customerId: entry.customer_id,
4748
+ sessionId: session.id
4749
+ });
4750
+ }
4751
+ } catch {
4752
+ }
4753
+ }
4754
+ return refs;
4755
+ }
3275
4756
  async recoverPendingJobs() {
3276
4757
  const pending = this.ledger.pendingJobs().filter((e) => !this.inFlight.has(e.job_id));
4758
+ if (this.sessionStore !== void 0) {
4759
+ this.sessionStore.syncRecoveryRegistrations(
4760
+ this.collectRecoverySessionRefs(pending),
4761
+ (jobId) => this.ledger.getStatus(jobId) === "paid"
4762
+ );
4763
+ }
3277
4764
  if (pending.length === 0) {
3278
4765
  return;
3279
4766
  }
@@ -3304,7 +4791,7 @@ var AgentRuntime = class {
3304
4791
  for (const entry of pending) {
3305
4792
  const ageMs = (Math.floor(Date.now() / 1e3) - entry.created_at) * 1e3;
3306
4793
  const expired = ageMs > MAX_PAID_AGE_MS;
3307
- const exhaustedRetries = entry.retry_count >= this.config.recoveryMaxRetries;
4794
+ const exhaustedRetries = entry.status === "paid" && entry.retry_count >= this.config.recoveryMaxRetries;
3308
4795
  if (expired || exhaustedRetries) {
3309
4796
  this.ledger.markFailed(entry.job_id);
3310
4797
  const reason = expired ? "Job permanently failed: agent did not recover within 24 hours" : "Job permanently failed after maximum retries";
@@ -3444,14 +4931,17 @@ var AgentRuntime = class {
3444
4931
  }
3445
4932
  }
3446
4933
  let recoveryInputFile;
4934
+ let recoverySession;
3447
4935
  try {
3448
4936
  const encrypted = rawEvent.tags?.some(
3449
4937
  (tag) => tag[0] === "encrypted" && tag[1] === "nip44"
3450
4938
  );
3451
4939
  const iTag = rawEvent.tags?.find((tag) => tag[0] === "i");
3452
4940
  const rawInput = encrypted ? rawEvent.content : iTag?.[1] ?? rawEvent.content;
4941
+ const decodedPayload = decodeJobPayload(rawInput);
4942
+ recoverySession = encrypted ? decodedPayload.session : void 0;
3453
4943
  recoveryInputFile = await this.resolveInputFile(
3454
- decodeJobPayload(rawInput).attachment,
4944
+ decodedPayload.attachment,
3455
4945
  entry.customer_id,
3456
4946
  recoveryAbort.signal
3457
4947
  );
@@ -3461,46 +4951,64 @@ var AgentRuntime = class {
3461
4951
  return;
3462
4952
  }
3463
4953
  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(() => {
4954
+ const recoveryJobSession = this.resolveJobSession(
4955
+ { session: recoverySession, customerId: entry.customer_id },
4956
+ skill
4957
+ );
4958
+ const runRecoveryExecution = async (history) => {
4959
+ let budgetTimer;
4960
+ try {
4961
+ const execPromise = skill.execute(
4962
+ {
4963
+ data: recoveryInputFile?.inlineText ?? entry.input,
4964
+ inputType: entry.input_type,
4965
+ tags: entry.tags,
4966
+ jobId: entry.job_id,
4967
+ filePath: recoveryInputFile?.filePath,
4968
+ history,
4969
+ ...recoveryJobSession !== null ? { sessionId: recoveryJobSession.sessionId } : {}
4970
+ },
4971
+ { ...this.skillCtx, signal: recoveryAbort.signal }
4972
+ );
4973
+ execPromise.catch(() => {
3501
4974
  });
4975
+ if (recoveryBudgetMs > 0) {
4976
+ return await Promise.race([
4977
+ execPromise,
4978
+ new Promise((_resolve, reject) => {
4979
+ budgetTimer = setTimeout(() => {
4980
+ recoveryAbort.abort();
4981
+ reject(new ExecutionBudgetExceededError(recoveryBudgetMs));
4982
+ }, recoveryBudgetMs);
4983
+ })
4984
+ ]);
4985
+ }
4986
+ return await execPromise;
4987
+ } catch (err) {
4988
+ this.markHealthFromExecuteError(skill, err, log, entry.job_id);
4989
+ throw err;
4990
+ } finally {
4991
+ if (budgetTimer) {
4992
+ clearTimeout(budgetTimer);
4993
+ }
4994
+ if (recoveryInputFile) {
4995
+ await recoveryInputFile.cleanup().catch(() => {
4996
+ });
4997
+ }
3502
4998
  }
3503
- }
4999
+ };
5000
+ const output = recoveryJobSession === null ? await runRecoveryExecution() : await this.runSessionExchange({
5001
+ session: recoveryJobSession,
5002
+ jobId: entry.job_id,
5003
+ userRecord: buildUserRecord(
5004
+ recoveryInputFile?.inlineText ?? entry.input,
5005
+ recoveryInputFile?.filePath !== void 0 ? "attachment" : void 0
5006
+ ),
5007
+ signal: recoveryAbort.signal,
5008
+ log,
5009
+ execute: runRecoveryExecution,
5010
+ excludeOwnTurns: true
5011
+ });
3504
5012
  const { attachments: resultAttachments, deliveredContent } = await this.buildResultAttachment(
3505
5013
  entry.job_id,
3506
5014
  output,
@@ -3615,6 +5123,9 @@ var SkillRegistry = class {
3615
5123
  return this.skills;
3616
5124
  }
3617
5125
  };
5126
+
5127
+ // src/skill/loader.ts
5128
+ init_llm();
3618
5129
  var StaticFileSkill = class {
3619
5130
  name;
3620
5131
  description;
@@ -3644,6 +5155,7 @@ var StaticFileSkill = class {
3644
5155
  priceSubunits: BigInt(Math.round(params.priceSubunits)),
3645
5156
  asset: params.asset,
3646
5157
  outputFilePath: params.outputFilePath,
5158
+ skillDir: params.dir,
3647
5159
  image: params.image,
3648
5160
  imageFile: params.imageFile
3649
5161
  });
@@ -3709,6 +5221,7 @@ var DynamicScriptSkill = class {
3709
5221
  inputMime;
3710
5222
  inputText;
3711
5223
  outputMime;
5224
+ context;
3712
5225
  inner;
3713
5226
  constructor(params) {
3714
5227
  this.name = params.name;
@@ -3720,6 +5233,7 @@ var DynamicScriptSkill = class {
3720
5233
  this.imageFile = params.imageFile;
3721
5234
  this.dir = params.dir;
3722
5235
  this.llmOverride = params.llmOverride;
5236
+ this.context = params.context;
3723
5237
  this.inputMime = params.inputMime;
3724
5238
  this.inputText = params.inputText;
3725
5239
  this.outputMime = params.outputMime;
@@ -3735,7 +5249,8 @@ var DynamicScriptSkill = class {
3735
5249
  scriptEnv: params.scriptEnv,
3736
5250
  image: params.image,
3737
5251
  imageFile: params.imageFile,
3738
- outputMime: params.outputMime
5252
+ outputMime: params.outputMime,
5253
+ context: params.context
3739
5254
  });
3740
5255
  }
3741
5256
  async execute(input, ctx) {
@@ -3750,11 +5265,12 @@ var ScriptSkill = class {
3750
5265
  asset;
3751
5266
  mode = "llm";
3752
5267
  llmOverride;
5268
+ context;
3753
5269
  image;
3754
5270
  imageFile;
3755
5271
  dir;
3756
5272
  inner;
3757
- constructor(name, description, capabilities, priceSubunits, assetOrImage, imageOrImageFile, imageFileOrDir, dirOrPrompt, promptOrTools, toolsOrRounds, rounds, llmOverride) {
5273
+ constructor(name, description, capabilities, priceSubunits, assetOrImage, imageOrImageFile, imageFileOrDir, dirOrPrompt, promptOrTools, toolsOrRounds, rounds, llmOverride, context) {
3758
5274
  let asset;
3759
5275
  let image;
3760
5276
  let imageFile;
@@ -3785,6 +5301,7 @@ var ScriptSkill = class {
3785
5301
  this.priceSubunits = priceSubunits;
3786
5302
  this.asset = asset;
3787
5303
  this.llmOverride = llmOverride;
5304
+ this.context = context;
3788
5305
  this.image = image;
3789
5306
  this.imageFile = imageFile;
3790
5307
  this.dir = skillDir;
@@ -3799,6 +5316,7 @@ var ScriptSkill = class {
3799
5316
  tools,
3800
5317
  maxToolRounds,
3801
5318
  llmOverride,
5319
+ context,
3802
5320
  image,
3803
5321
  imageFile
3804
5322
  });
@@ -3859,11 +5377,23 @@ var X402Skill = class {
3859
5377
  };
3860
5378
 
3861
5379
  // src/skill/loader.ts
5380
+ function scopeLlmKeys(scriptEnv, declaredProvider) {
5381
+ if (scriptEnv === void 0) {
5382
+ return void 0;
5383
+ }
5384
+ const env = { ...scriptEnv };
5385
+ for (const descriptor of listLlmProviders()) {
5386
+ if (descriptor.id !== declaredProvider) {
5387
+ delete env[descriptor.envVar];
5388
+ }
5389
+ }
5390
+ return env;
5391
+ }
3862
5392
  function buildCliSkill(parsed, entryPath, scriptEnv) {
3863
5393
  let safeImageFile = parsed.imageFile;
3864
- if (safeImageFile !== void 0 && resolveInsidePath(entryPath, safeImageFile) === null) {
5394
+ if (safeImageFile !== void 0 && resolveInsidePathReal(entryPath, safeImageFile) === null) {
3865
5395
  console.warn(
3866
- `SKILL.md "${parsed.name}": ignoring "image_file" that resolves outside the skill directory: ${safeImageFile}`
5396
+ `SKILL.md "${parsed.name}": ignoring "image_file" that resolves outside the skill directory (directly or via symlink): ${safeImageFile}`
3867
5397
  );
3868
5398
  safeImageFile = void 0;
3869
5399
  }
@@ -3882,7 +5412,8 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3882
5412
  parsed.systemPrompt,
3883
5413
  parsed.tools,
3884
5414
  parsed.maxToolRounds,
3885
- parsed.llmOverride
5415
+ parsed.llmOverride,
5416
+ parsed.context
3886
5417
  );
3887
5418
  break;
3888
5419
  case "static-file": {
@@ -3891,7 +5422,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3891
5422
  `SKILL.md "${parsed.name}": internal error - outputFile missing for mode 'static-file'`
3892
5423
  );
3893
5424
  }
3894
- const outputFilePath = resolveInsidePath(entryPath, parsed.outputFile);
5425
+ const outputFilePath = resolveInsidePathReal(entryPath, parsed.outputFile);
3895
5426
  if (!outputFilePath) {
3896
5427
  throw new Error(
3897
5428
  `SKILL.md "${parsed.name}": "output_file" must stay inside the skill directory`
@@ -3918,7 +5449,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3918
5449
  `SKILL.md "${parsed.name}": internal error - script missing for mode '${parsed.mode}'`
3919
5450
  );
3920
5451
  }
3921
- const scriptPath = resolveInsidePath(entryPath, parsed.script);
5452
+ const scriptPath = resolveInsidePathReal(entryPath, parsed.script);
3922
5453
  if (!scriptPath) {
3923
5454
  throw new Error(`SKILL.md "${parsed.name}": "script" must stay inside the skill directory`);
3924
5455
  }
@@ -3931,7 +5462,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3931
5462
  scriptPath,
3932
5463
  scriptArgs: parsed.scriptArgs,
3933
5464
  scriptTimeoutMs: parsed.scriptTimeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS,
3934
- scriptEnv,
5465
+ scriptEnv: scopeLlmKeys(scriptEnv, parsed.llmOverride?.provider),
3935
5466
  image: parsed.image,
3936
5467
  imageFile: safeImageFile,
3937
5468
  dir: entryPath,
@@ -3941,7 +5472,8 @@ function buildCliSkill(parsed, entryPath, scriptEnv) {
3941
5472
  ...scriptParams,
3942
5473
  outputMime: parsed.outputMime,
3943
5474
  inputMime: parsed.inputMime,
3944
- inputText: parsed.inputText
5475
+ inputText: parsed.inputText,
5476
+ context: parsed.context
3945
5477
  }) : new StaticScriptSkill(scriptParams);
3946
5478
  break;
3947
5479
  }
@@ -4021,9 +5553,9 @@ function parseBidTag(value) {
4021
5553
  }
4022
5554
  var HEALTH_CHECK_IDLE_MS = 30 * 60 * 1e3;
4023
5555
  var NostrTransport = class {
4024
- constructor(client, identity, kindOffsets) {
5556
+ constructor(client, identity2, kindOffsets) {
4025
5557
  this.client = client;
4026
- this.identity = identity;
5558
+ this.identity = identity2;
4027
5559
  this.kindOffsets = kindOffsets;
4028
5560
  }
4029
5561
  sub = null;
@@ -4059,10 +5591,12 @@ var NostrTransport = class {
4059
5591
  const rawInput = encrypted ? event.content : iTag?.[1] ?? event.content;
4060
5592
  let input;
4061
5593
  let attachment;
5594
+ let session;
4062
5595
  try {
4063
5596
  const decoded = decodeJobPayload(rawInput);
4064
5597
  input = decoded.text ?? "";
4065
5598
  attachment = decoded.attachment;
5599
+ session = encrypted ? decoded.session : void 0;
4066
5600
  } catch {
4067
5601
  return;
4068
5602
  }
@@ -4075,7 +5609,8 @@ var NostrTransport = class {
4075
5609
  bid: parseBidTag(bidTag?.[1]),
4076
5610
  encrypted,
4077
5611
  rawEvent: event,
4078
- attachment
5612
+ attachment,
5613
+ session
4079
5614
  });
4080
5615
  }
4081
5616
  );
@@ -4098,7 +5633,7 @@ var NostrTransport = class {
4098
5633
  * expiry window - without it this path waits forever on the relay.
4099
5634
  */
4100
5635
  waitForPaymentSignature(jobId, customerPubkey, signal, timeoutMs) {
4101
- return new Promise((resolve3) => {
5636
+ return new Promise((resolve4) => {
4102
5637
  let settled = false;
4103
5638
  const filter = {
4104
5639
  kinds: [KIND_JOB_FEEDBACK],
@@ -4117,7 +5652,7 @@ var NostrTransport = class {
4117
5652
  }
4118
5653
  signal.removeEventListener("abort", onAbort);
4119
5654
  sub.close();
4120
- resolve3(sig);
5655
+ resolve4(sig);
4121
5656
  };
4122
5657
  const sub = this.client.pool.subscribe(filter, (event) => {
4123
5658
  if (settled) {
@@ -4211,7 +5746,7 @@ var NostrTransport = class {
4211
5746
  function startWatchdog(deps) {
4212
5747
  const {
4213
5748
  client,
4214
- identity,
5749
+ identity: identity2,
4215
5750
  transport,
4216
5751
  onPing,
4217
5752
  log,
@@ -4222,7 +5757,7 @@ function startWatchdog(deps) {
4222
5757
  selfPingTimeoutMs = WATCHDOG_SELF_PING_TIMEOUT_MS,
4223
5758
  now = Date.now
4224
5759
  } = deps;
4225
- let pingSub = client.ping.subscribeToPings(identity, onPing);
5760
+ let pingSub = client.ping.subscribeToPings(identity2, onPing);
4226
5761
  let stopped = false;
4227
5762
  let probeBusy = false;
4228
5763
  let selfPingBusy = false;
@@ -4231,7 +5766,7 @@ function startWatchdog(deps) {
4231
5766
  const resetPoolAndResubscribe = () => {
4232
5767
  transport.stop();
4233
5768
  client.pool.reset();
4234
- pingSub = client.ping.subscribeToPings(identity, onPing);
5769
+ pingSub = client.ping.subscribeToPings(identity2, onPing);
4235
5770
  transport.restart();
4236
5771
  };
4237
5772
  const detectAndHandleSleep = () => {
@@ -4287,7 +5822,7 @@ function startWatchdog(deps) {
4287
5822
  }
4288
5823
  selfPingBusy = true;
4289
5824
  try {
4290
- const result = await client.ping.pingAgent(identity.publicKey, selfPingTimeoutMs);
5825
+ const result = await client.ping.pingAgent(identity2.publicKey, selfPingTimeoutMs);
4291
5826
  if (stopped || result.online) {
4292
5827
  return;
4293
5828
  }
@@ -4775,18 +6310,18 @@ async function capChallengeResponse(response) {
4775
6310
  });
4776
6311
  }
4777
6312
  function abortableDelay(ms, signal) {
4778
- return new Promise((resolve3) => {
6313
+ return new Promise((resolve4) => {
4779
6314
  if (signal?.aborted) {
4780
- resolve3(false);
6315
+ resolve4(false);
4781
6316
  return;
4782
6317
  }
4783
6318
  const onAbort = () => {
4784
6319
  clearTimeout(timer);
4785
- resolve3(false);
6320
+ resolve4(false);
4786
6321
  };
4787
6322
  const timer = setTimeout(() => {
4788
6323
  signal?.removeEventListener("abort", onAbort);
4789
- resolve3(true);
6324
+ resolve4(true);
4790
6325
  }, ms);
4791
6326
  signal?.addEventListener("abort", onAbort, { once: true });
4792
6327
  });
@@ -5158,6 +6693,9 @@ async function cmdStart(nameArg, options = {}) {
5158
6693
  }
5159
6694
  const scriptEnv = { ...process.env };
5160
6695
  delete scriptEnv.ELISYM_PASSPHRASE;
6696
+ if (scriptEnv.SOLANA_RPC_URL !== void 0 && !isPublicSolanaRpcUrl(scriptEnv.SOLANA_RPC_URL)) {
6697
+ delete scriptEnv.SOLANA_RPC_URL;
6698
+ }
5161
6699
  const llmKeys = loaded.secrets.llm_api_keys ?? {};
5162
6700
  for (const descriptor of listLlmProviders()) {
5163
6701
  const secretValue = llmKeys[descriptor.id];
@@ -5412,7 +6950,7 @@ async function cmdStart(nameArg, options = {}) {
5412
6950
  });
5413
6951
  }
5414
6952
  console.log(" Connecting to relays and publishing capabilities...");
5415
- const identity = ElisymIdentity.fromHex(loaded.secrets.nostr_secret_key);
6953
+ const identity2 = ElisymIdentity.fromHex(loaded.secrets.nostr_secret_key);
5416
6954
  const relays = loaded.yaml.relays.length > 0 ? loaded.yaml.relays : [...RELAYS];
5417
6955
  const client = new ElisymClient({ relays });
5418
6956
  if (process.env.ELISYM_NET_DIAG === "1") {
@@ -5434,7 +6972,7 @@ async function cmdStart(nameArg, options = {}) {
5434
6972
  loaded.dir,
5435
6973
  mediaCache,
5436
6974
  client.blossom,
5437
- identity,
6975
+ identity2,
5438
6976
  (updated) => mediaCacheDirty = mediaCacheDirty || updated
5439
6977
  );
5440
6978
  const bannerUrl = await resolveMediaField(
@@ -5442,7 +6980,7 @@ async function cmdStart(nameArg, options = {}) {
5442
6980
  loaded.dir,
5443
6981
  mediaCache,
5444
6982
  client.blossom,
5445
- identity,
6983
+ identity2,
5446
6984
  (updated) => mediaCacheDirty = mediaCacheDirty || updated
5447
6985
  );
5448
6986
  for (const skill of allSkills) {
@@ -5456,9 +6994,10 @@ async function cmdStart(nameArg, options = {}) {
5456
6994
  const url = await uploadOrReuse(
5457
6995
  cacheKey,
5458
6996
  absPath,
6997
+ skillRoot,
5459
6998
  mediaCache,
5460
6999
  client.blossom,
5461
- identity,
7000
+ identity2,
5462
7001
  () => mediaCacheDirty = true
5463
7002
  );
5464
7003
  if (url) {
@@ -5471,11 +7010,12 @@ async function cmdStart(nameArg, options = {}) {
5471
7010
  let profilePublished = false;
5472
7011
  try {
5473
7012
  await client.discovery.publishProfile(
5474
- identity,
7013
+ identity2,
5475
7014
  loaded.yaml.display_name ?? agentName,
5476
7015
  loaded.yaml.description ?? "",
5477
7016
  pictureUrl,
5478
- bannerUrl
7017
+ bannerUrl,
7018
+ loaded.yaml.identities?.website
5479
7019
  );
5480
7020
  profilePublished = true;
5481
7021
  logger.debug({ event: "publish_ack", kind: 0 }, "profile published");
@@ -5483,6 +7023,32 @@ async function cmdStart(nameArg, options = {}) {
5483
7023
  console.warn(` ! Failed to publish profile: ${e.message}`);
5484
7024
  logger.warn({ event: "publish_failed", kind: 0, error: e.message }, "profile publish failed");
5485
7025
  }
7026
+ if (loaded.yaml.identities !== void 0) {
7027
+ const identityClaims = buildIdentityClaimInputs(loaded.yaml.identities);
7028
+ try {
7029
+ await client.discovery.publishExternalIdentities(identity2, identityClaims);
7030
+ const summaryParts = identityClaims.map((claim) => `${claim.platform}:${claim.handle}`);
7031
+ if (loaded.yaml.identities.website !== void 0) {
7032
+ summaryParts.push(`website:${loaded.yaml.identities.website}`);
7033
+ }
7034
+ console.log(` * Identities: ${summaryParts.join(", ") || "none"}`);
7035
+ logger.debug(
7036
+ { event: "publish_ack", kind: KIND_EXTERNAL_IDENTITIES, claims: identityClaims.length },
7037
+ "identity claims published"
7038
+ );
7039
+ } catch (e) {
7040
+ console.warn(` ! Failed to publish identity claims: ${e.message}`);
7041
+ logger.warn(
7042
+ { event: "publish_failed", kind: KIND_EXTERNAL_IDENTITIES, error: e.message },
7043
+ "identity claims publish failed"
7044
+ );
7045
+ }
7046
+ } else {
7047
+ const sweptIdentities = await sweepStaleIdentityClaims(client.discovery, identity2);
7048
+ if (sweptIdentities) {
7049
+ console.log(" Removed stale identity claims (no identities in elisym.yaml).");
7050
+ }
7051
+ }
5486
7052
  const policies = existsSync(paths.policies) ? loadPoliciesFromDir(paths.policies) : [];
5487
7053
  const localPolicyDTags = new Set(
5488
7054
  policies.map((policy) => `${POLICY_D_TAG_PREFIX}${policy.type}`)
@@ -5491,7 +7057,7 @@ async function cmdStart(nameArg, options = {}) {
5491
7057
  try {
5492
7058
  return await client.pool.querySync({
5493
7059
  kinds: [KIND_LONG_FORM_ARTICLE],
5494
- authors: [identity.publicKey],
7060
+ authors: [identity2.publicKey],
5495
7061
  "#t": [POLICY_T_TAG]
5496
7062
  });
5497
7063
  } catch {
@@ -5518,7 +7084,7 @@ async function cmdStart(nameArg, options = {}) {
5518
7084
  continue;
5519
7085
  }
5520
7086
  try {
5521
- const { naddr } = await client.policies.publishPolicy(identity, policy);
7087
+ const { naddr } = await client.policies.publishPolicy(identity2, policy);
5522
7088
  console.log(` * Policy: ${policy.type}@${policy.version} -> ${naddr}`);
5523
7089
  logger.debug(
5524
7090
  { event: "publish_ack", kind: KIND_LONG_FORM_ARTICLE, policy: policy.type, naddr },
@@ -5546,12 +7112,12 @@ async function cmdStart(nameArg, options = {}) {
5546
7112
  continue;
5547
7113
  }
5548
7114
  const type = event.tags.find((tag) => tag[0] === "policy_type")?.[1];
5549
- if (!type) {
7115
+ if (!type || dTag !== `${POLICY_D_TAG_PREFIX}${type}`) {
5550
7116
  continue;
5551
7117
  }
5552
7118
  try {
5553
- await client.policies.deletePolicy(identity, type);
5554
- console.log(` Removed stale policy: ${type}`);
7119
+ await client.policies.deletePolicy(identity2, type);
7120
+ console.log(` Removed stale policy: ${sanitizeForTerminal(type)}`);
5555
7121
  } catch {
5556
7122
  }
5557
7123
  }
@@ -5569,6 +7135,9 @@ async function cmdStart(nameArg, options = {}) {
5569
7135
  ...skill.inputMime ? { inputMime: skill.inputMime } : {},
5570
7136
  ...skill.inputText ? { inputText: skill.inputText } : {},
5571
7137
  ...skill.outputMime ? { outputMime: skill.outputMime } : {},
7138
+ // Conversation-context flag: clients gate chat affordances (session-carrying
7139
+ // sends) on it. Strict true only - the read side coerces anything else away.
7140
+ ...skill.context === true ? { context: true } : {},
5572
7141
  payment: solanaAddress ? {
5573
7142
  chain: "solana",
5574
7143
  network: walletNetwork,
@@ -5610,7 +7179,7 @@ async function cmdStart(nameArg, options = {}) {
5610
7179
  continue;
5611
7180
  }
5612
7181
  try {
5613
- await client.discovery.publishCapability(identity, buildCard(skill), kinds);
7182
+ await client.discovery.publishCapability(identity2, buildCard(skill), kinds);
5614
7183
  cardsPublished += 1;
5615
7184
  logger.debug(
5616
7185
  { event: "publish_ack", kind: 31990, skill: skill.name },
@@ -5627,7 +7196,7 @@ async function cmdStart(nameArg, options = {}) {
5627
7196
  try {
5628
7197
  const existingEvents = await client.pool.querySync({
5629
7198
  kinds: [31990],
5630
- authors: [identity.publicKey],
7199
+ authors: [identity2.publicKey],
5631
7200
  "#t": ["elisym"]
5632
7201
  });
5633
7202
  const skillDTags = new Set(allSkills.map((s) => toDTag(s.name)));
@@ -5638,9 +7207,9 @@ async function cmdStart(nameArg, options = {}) {
5638
7207
  }
5639
7208
  try {
5640
7209
  const card = JSON.parse(ev.content);
5641
- if (card.name) {
5642
- await client.discovery.deleteCapability(identity, card.name);
5643
- console.log(` Removed stale capability: ${card.name}`);
7210
+ if (card.name && toDTag(card.name) === dTag) {
7211
+ await client.discovery.deleteCapability(identity2, card.name);
7212
+ console.log(` Removed stale capability: ${sanitizeForTerminal(card.name)}`);
5644
7213
  }
5645
7214
  } catch {
5646
7215
  }
@@ -5655,16 +7224,20 @@ async function cmdStart(nameArg, options = {}) {
5655
7224
  console.log(` [debug] Relays: ${relays.length} configured (${relays.join(", ")})`);
5656
7225
  }
5657
7226
  const onPing = (senderPubkey, nonce) => {
5658
- client.ping.sendPong(identity, senderPubkey, nonce).catch(() => {
7227
+ client.ping.sendPong(identity2, senderPubkey, nonce).catch(() => {
5659
7228
  });
5660
7229
  };
5661
- const transport = new NostrTransport(client, identity, [DEFAULT_KIND_OFFSET]);
7230
+ const transport = new NostrTransport(client, identity2, [DEFAULT_KIND_OFFSET]);
5662
7231
  const ledger = new JobLedger(paths.jobs);
5663
7232
  const irohTransport = createIrohTransport({ storePath: join(loaded.dir, ".iroh") });
5664
7233
  await ensureGitignoreHasIrohEntry(dirname(loaded.dir));
5665
7234
  if (x402Skills.length > 0) {
5666
7235
  await ensureGitignoreHasX402Entries(dirname(loaded.dir));
5667
7236
  }
7237
+ const hasContextSkills = registry.all().some((skill) => skill.context === true);
7238
+ if (hasContextSkills) {
7239
+ await ensureGitignoreHasSessionsEntry(dirname(loaded.dir));
7240
+ }
5668
7241
  const runtimeConfig = {
5669
7242
  paymentTimeoutSecs: DEFAULTS.PAYMENT_EXPIRY_SECS,
5670
7243
  maxConcurrentJobs: MAX_CONCURRENT_JOBS,
@@ -5695,7 +7268,7 @@ async function cmdStart(nameArg, options = {}) {
5695
7268
  };
5696
7269
  const watchdog = startWatchdog({
5697
7270
  client,
5698
- identity,
7271
+ identity: identity2,
5699
7272
  transport,
5700
7273
  onPing,
5701
7274
  log: diagLog,
@@ -5709,7 +7282,11 @@ async function cmdStart(nameArg, options = {}) {
5709
7282
  });
5710
7283
  diagLog("LLM health monitor armed (lazy recovery, 5min interval).");
5711
7284
  }
5712
- const blossomTransport = createBlossomTransport({ blossom: client.blossom, identity });
7285
+ const blossomTransport = createBlossomTransport({ blossom: client.blossom, identity: identity2 });
7286
+ const sessionStore = hasContextSkills ? new SessionStore(loaded.dir, diagLog) : void 0;
7287
+ if (sessionStore) {
7288
+ diagLog("Conversation sessions enabled (context: true skills present).");
7289
+ }
5713
7290
  const runtime = new AgentRuntime(
5714
7291
  transport,
5715
7292
  registry,
@@ -5742,8 +7319,9 @@ async function cmdStart(nameArg, options = {}) {
5742
7319
  },
5743
7320
  healthMonitor,
5744
7321
  irohTransport,
5745
- identity,
5746
- blossomTransport
7322
+ identity2,
7323
+ blossomTransport,
7324
+ sessionStore
5747
7325
  );
5748
7326
  console.log(" * Running. Press Ctrl+C to stop.\n");
5749
7327
  await runtime.run();
@@ -5768,10 +7346,18 @@ function stripRpcSecrets(raw) {
5768
7346
  return "[unparseable RPC URL]";
5769
7347
  }
5770
7348
  }
7349
+ function isPublicSolanaRpcUrl(raw) {
7350
+ try {
7351
+ const parsed = new URL(raw);
7352
+ return PUBLIC_SOLANA_RPC_HOSTS.has(parsed.hostname) && !parsed.username && !parsed.password && !parsed.search && (parsed.pathname === "" || parsed.pathname === "/");
7353
+ } catch {
7354
+ return false;
7355
+ }
7356
+ }
5771
7357
  function redactRpcUrlsInText(text) {
5772
7358
  return text.replace(/https?:\/\/[^\s)'"]+/g, (url) => stripRpcSecrets(url));
5773
7359
  }
5774
- async function resolveMediaField(value, agentDir, cache, blossom, identity, onCacheUpdate) {
7360
+ async function resolveMediaField(value, agentDir, cache, blossom, identity2, onCacheUpdate) {
5775
7361
  if (!value) {
5776
7362
  return void 0;
5777
7363
  }
@@ -5783,7 +7369,15 @@ async function resolveMediaField(value, agentDir, cache, blossom, identity, onCa
5783
7369
  console.warn(` ! Skipping media field "${value}": path must stay inside the agent directory.`);
5784
7370
  return void 0;
5785
7371
  }
5786
- return uploadOrReuse(value, absPath, cache, blossom, identity, () => onCacheUpdate(true));
7372
+ return uploadOrReuse(
7373
+ value,
7374
+ absPath,
7375
+ agentDir,
7376
+ cache,
7377
+ blossom,
7378
+ identity2,
7379
+ () => onCacheUpdate(true)
7380
+ );
5787
7381
  }
5788
7382
  function resolveInsideAgentDir(value, agentDir) {
5789
7383
  const agentRoot = resolve(agentDir);
@@ -5794,17 +7388,37 @@ function resolveInsideAgentDir(value, agentDir) {
5794
7388
  }
5795
7389
  return candidate;
5796
7390
  }
5797
- async function uploadOrReuse(cacheKey, absPath, cache, blossom, identity, onCacheUpdate) {
7391
+ function realpathInsideDir(absPath, rootDir) {
7392
+ try {
7393
+ const realRoot = realpathSync(resolve(rootDir));
7394
+ const realCandidate = realpathSync(absPath);
7395
+ const rel = relative(realRoot, realCandidate);
7396
+ if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
7397
+ return null;
7398
+ }
7399
+ return realCandidate;
7400
+ } catch {
7401
+ return null;
7402
+ }
7403
+ }
7404
+ async function uploadOrReuse(cacheKey, absPath, containRoot, cache, blossom, identity2, onCacheUpdate) {
7405
+ const realPath = realpathInsideDir(absPath, containRoot);
7406
+ if (realPath === null) {
7407
+ console.warn(
7408
+ ` ! Skipping upload of ${basename(absPath)}: unreadable or escapes its directory.`
7409
+ );
7410
+ return void 0;
7411
+ }
5798
7412
  try {
5799
- const cached = await lookupCachedUrl(cache, cacheKey, absPath);
7413
+ const cached = await lookupCachedUrl(cache, cacheKey, realPath);
5800
7414
  if (cached) {
5801
7415
  return cached;
5802
7416
  }
5803
- console.log(` Uploading ${basename(absPath)}...`);
5804
- const data = readFileSync(absPath);
7417
+ console.log(` Uploading ${basename(realPath)}...`);
7418
+ const data = readFileSync(realPath);
5805
7419
  const sha256 = createHash("sha256").update(data).digest("hex");
5806
7420
  const blob = new Blob([data], { type: mimeFromPath(absPath) });
5807
- const descriptor = await blossom.upload(identity, blob);
7421
+ const descriptor = await blossom.upload(identity2, blob);
5808
7422
  cache[cacheKey] = newCacheEntry(descriptor.url, sha256);
5809
7423
  onCacheUpdate();
5810
7424
  console.log(` Uploaded: ${descriptor.url}`);
@@ -6411,8 +8025,8 @@ program.command("list").description("List all agents (project-local and home-glo
6411
8025
  for (const agent of agents) {
6412
8026
  try {
6413
8027
  const loaded = await loadAgent(agent.name, cwd);
6414
- const identity = ElisymIdentity.fromHex(loaded.secrets.nostr_secret_key);
6415
- const npub = nip19.npubEncode(identity.publicKey);
8028
+ const identity2 = ElisymIdentity.fromHex(loaded.secrets.nostr_secret_key);
8029
+ const npub = nip19.npubEncode(identity2.publicKey);
6416
8030
  const solAddr = loaded.yaml.payments[0]?.address ? ` | Solana: ${loaded.yaml.payments[0].address}` : "";
6417
8031
  const shadow = agent.shadowsGlobal ? " [shadows global]" : "";
6418
8032
  console.log(` ${agent.name} (${agent.source})${shadow} | ${npub}${solAddr}`);
@@ -6425,6 +8039,29 @@ program.command("list").description("List all agents (project-local and home-glo
6425
8039
  })
6426
8040
  );
6427
8041
  program.command("wallet [name]").description("Show wallet balance").action(safe(cmdWallet));
8042
+ var identity = program.command("identity").description("Link, verify, and unlink external identities (GitHub, X, website)");
8043
+ identity.command("link <platform> [agent]").description(
8044
+ "Link a github|x|website identity: prints the NIP-39 proof template, verifies the proof live, writes elisym.yaml, and publishes the claim (kind 10011 / kind-0 nip05)"
8045
+ ).action(
8046
+ safe(async (platform, agent) => {
8047
+ await cmdIdentityLink(platform, agent);
8048
+ process.exit(0);
8049
+ })
8050
+ );
8051
+ identity.command("status [agent]").description(
8052
+ "List linked identities with live proof verification and published-claim drift check"
8053
+ ).action(
8054
+ safe(async (agent) => {
8055
+ await cmdIdentityStatus(agent);
8056
+ process.exit(0);
8057
+ })
8058
+ );
8059
+ identity.command("unlink <platform> [agent]").description("Remove a github|x|website identity and retract the published claim").action(
8060
+ safe(async (platform, agent) => {
8061
+ await cmdIdentityUnlink(platform, agent);
8062
+ process.exit(0);
8063
+ })
8064
+ );
6428
8065
  var x402 = program.command("x402").description("Bridge x402-paid HTTP services into elisym skills");
6429
8066
  x402.command("add <url> [agent]").description("Generate a bridge skill from a live x402 endpoint (probes its 402 challenge)").option("--method <method>", "HTTP method for the upstream call: GET or POST (default POST)").option(
6430
8067
  "--query-param <name>",