@bububuger/spanory 0.1.16 → 0.1.18

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,21 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-nocheck
3
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
4
  import { chmod, copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
5
  import path from 'node:path';
5
6
  import { createHash } from 'node:crypto';
6
7
  import { spawnSync } from 'node:child_process';
7
- import { pathToFileURL } from 'node:url';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { createRequire } from 'node:module';
8
10
  import { Command } from 'commander';
9
11
  import { claudeCodeAdapter } from './runtime/claude/adapter.js';
10
12
  import { codexAdapter } from './runtime/codex/adapter.js';
11
13
  import { createCodexProxyServer } from './runtime/codex/proxy.js';
12
14
  import { openclawAdapter } from './runtime/openclaw/adapter.js';
13
15
  import { compileOtlp, parseHeaders, sendOtlp } from './otlp.js';
14
- import { loadUserEnv } from './env.js';
16
+ import { loadUserEnv, resolveSpanoryEnvPath, resolveSpanoryHome } from './env.js';
15
17
  import { waitForFileMtimeToSettle } from './runtime/shared/file-settle.js';
16
18
  import { langfuseBackendAdapter } from '../../backend-langfuse/dist/index.js';
17
19
  import { evaluateRules, loadAlertRules, sendAlertWebhook } from './alert/evaluate.js';
18
- import { summarizeCache, loadExportedEvents, summarizeAgents, summarizeCommands, summarizeMcp, summarizeSessions, summarizeTools, summarizeTurnDiff, } from './report/aggregate.js';
20
+ import { summarizeCache, loadExportedEvents, summarizeAgents, summarizeCommands, summarizeMcp, summarizeContext, summarizeSessions, summarizeTools, summarizeTurnDiff, } from './report/aggregate.js';
19
21
  import { loadIssueState, parsePendingTodoItems, resolveIssueStatePath, resolveTodoPath, saveIssueState, setIssueStatus, syncIssueState, } from './issue/state.js';
20
22
  const runtimeAdapters = {
21
23
  'claude-code': claudeCodeAdapter,
@@ -30,12 +32,61 @@ const OPENCODE_SPANORY_PLUGIN_ID = 'spanory-opencode-plugin';
30
32
  const DEFAULT_SETUP_RUNTIMES = ['claude-code', 'codex', 'openclaw', 'opencode'];
31
33
  const EMPTY_OUTPUT_RETRY_WINDOW_MS = 1000;
32
34
  const EMPTY_OUTPUT_RETRY_INTERVAL_MS = 120;
35
+ const SPANORY_NPM_PACKAGE = '@bububuger/spanory';
36
+ const HOOK_STDIN_IDLE_MS = 200;
37
+ const HOOK_STDIN_TIMEOUT_MS = 1500;
33
38
  const CODEX_WATCH_DEFAULT_POLL_MS = 1200;
34
39
  const CODEX_WATCH_DEFAULT_SETTLE_MS = 250;
40
+ const EXECUTION_ENTRY = (() => {
41
+ if (!('pkg' in process)) {
42
+ return fileURLToPath(import.meta.url);
43
+ }
44
+ const candidate = path.resolve(process.argv[1] ?? process.cwd());
45
+ try {
46
+ return realpathSync(candidate);
47
+ }
48
+ catch {
49
+ return candidate;
50
+ }
51
+ })();
52
+ const requireFromHere = createRequire(EXECUTION_ENTRY);
53
+ const CLI_FILE_DIR = path.dirname(EXECUTION_ENTRY);
54
+ const CLI_PACKAGE_DIR = path.resolve(CLI_FILE_DIR, '..');
55
+ const DEFAULT_VERSION = '0.1.1';
56
+ function readVersionFromPackageJson() {
57
+ const packageNameCandidates = ['@bububuger/spanory', '@spanory/cli'];
58
+ for (const packageName of packageNameCandidates) {
59
+ try {
60
+ const pkgJsonPath = requireFromHere.resolve(`${packageName}/package.json`);
61
+ const parsed = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
62
+ const version = String(parsed?.version ?? '').trim();
63
+ if (version)
64
+ return version;
65
+ }
66
+ catch { }
67
+ }
68
+ const candidates = [
69
+ path.join(CLI_PACKAGE_DIR, 'package.json'),
70
+ path.resolve(process.cwd(), 'packages', 'cli', 'package.json'),
71
+ ];
72
+ for (const file of candidates) {
73
+ if (!existsSync(file))
74
+ continue;
75
+ try {
76
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
77
+ const version = String(parsed?.version ?? '').trim();
78
+ if (version)
79
+ return version;
80
+ }
81
+ catch { }
82
+ }
83
+ return null;
84
+ }
85
+ const CLI_VERSION = process.env.SPANORY_VERSION ?? readVersionFromPackageJson() ?? DEFAULT_VERSION;
35
86
  function getResource() {
36
87
  return {
37
88
  serviceName: 'spanory',
38
- serviceVersion: process.env.SPANORY_VERSION ?? '0.1.1',
89
+ serviceVersion: CLI_VERSION,
39
90
  environment: process.env.SPANORY_ENV ?? 'development',
40
91
  };
41
92
  }
@@ -66,11 +117,73 @@ function parseHookPayload(raw) {
66
117
  throw new Error('hook payload is not valid JSON');
67
118
  }
68
119
  }
120
+ function redactSecretText(value) {
121
+ return String(value ?? '')
122
+ .replace(/(authorization\s*[:=]\s*)(basic|bearer)\s+[^\s"']+/ig, '$1[REDACTED]')
123
+ .replace(/\b(sk|pk)_[a-z0-9_-]{8,}\b/ig, '[REDACTED]');
124
+ }
125
+ function maskEndpoint(endpoint) {
126
+ const raw = String(endpoint ?? '').trim();
127
+ if (!raw)
128
+ return '';
129
+ try {
130
+ const url = new URL(raw);
131
+ return `${url.protocol}//${url.host}${url.pathname}`;
132
+ }
133
+ catch {
134
+ return redactSecretText(raw);
135
+ }
136
+ }
69
137
  async function readStdinText() {
70
- const chunks = [];
71
- for await (const chunk of process.stdin)
72
- chunks.push(chunk);
73
- return Buffer.concat(chunks).toString('utf-8');
138
+ if (process.stdin.isTTY)
139
+ return '';
140
+ return new Promise((resolve, reject) => {
141
+ const chunks = [];
142
+ let lastDataAt = Date.now();
143
+ let settled = false;
144
+ function done(error, value = '') {
145
+ if (settled)
146
+ return;
147
+ settled = true;
148
+ clearInterval(idleTimer);
149
+ clearTimeout(hardTimeout);
150
+ process.stdin.off('data', onData);
151
+ process.stdin.off('end', onEnd);
152
+ process.stdin.off('error', onError);
153
+ process.stdin.pause();
154
+ if (typeof process.stdin.unref === 'function') {
155
+ process.stdin.unref();
156
+ }
157
+ if (error)
158
+ reject(error);
159
+ else
160
+ resolve(value);
161
+ }
162
+ function onData(chunk) {
163
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
164
+ lastDataAt = Date.now();
165
+ }
166
+ function onEnd() {
167
+ done(null, Buffer.concat(chunks).toString('utf-8'));
168
+ }
169
+ function onError(error) {
170
+ done(error);
171
+ }
172
+ const idleTimer = setInterval(() => {
173
+ if (chunks.length === 0)
174
+ return;
175
+ if (Date.now() - lastDataAt >= HOOK_STDIN_IDLE_MS) {
176
+ done(null, Buffer.concat(chunks).toString('utf-8'));
177
+ }
178
+ }, 50);
179
+ const hardTimeout = setTimeout(() => {
180
+ done(new Error(`hook stdin read timeout after ${HOOK_STDIN_TIMEOUT_MS}ms`));
181
+ }, HOOK_STDIN_TIMEOUT_MS);
182
+ process.stdin.on('data', onData);
183
+ process.stdin.on('end', onEnd);
184
+ process.stdin.on('error', onError);
185
+ process.stdin.resume();
186
+ });
74
187
  }
75
188
  function fingerprintSession(context, events) {
76
189
  const hash = createHash('sha256');
@@ -179,6 +292,14 @@ function resolveRuntimeProjectRoot(runtimeName, explicitRuntimeHome) {
179
292
  function resolveRuntimeStateRoot(runtimeName, explicitRuntimeHome) {
180
293
  return path.join(resolveRuntimeHome(runtimeName, explicitRuntimeHome), 'state');
181
294
  }
295
+ function resolveOpencodePluginStateRoot(runtimeHome) {
296
+ if (process.env.SPANORY_OPENCODE_STATE_DIR)
297
+ return process.env.SPANORY_OPENCODE_STATE_DIR;
298
+ if (runtimeHome || process.env.SPANORY_OPENCODE_HOME) {
299
+ return path.join(runtimeHome ?? resolveRuntimeHome('opencode'), 'state', 'spanory');
300
+ }
301
+ return path.join(resolveSpanoryHome(), 'opencode');
302
+ }
182
303
  function resolveRuntimeExportDir(runtimeName, explicitRuntimeHome) {
183
304
  return path.join(resolveRuntimeStateRoot(runtimeName, explicitRuntimeHome), 'spanory-json');
184
305
  }
@@ -218,7 +339,7 @@ async function emitSession({ runtimeName, context, events, endpoint, headers, ex
218
339
  console.log(`runtime=${runtimeName} projectId=${context.projectId} sessionId=${context.sessionId} events=${events.length}`);
219
340
  if (endpoint) {
220
341
  await sendOtlp(endpoint, payload, headers);
221
- console.log(`otlp=sent endpoint=${endpoint}`);
342
+ console.log(`otlp=sent endpoint=${maskEndpoint(endpoint)}`);
222
343
  }
223
344
  else {
224
345
  console.log('otlp=skipped endpoint=unset');
@@ -584,13 +705,48 @@ function resolveOpenclawPluginDir() {
584
705
  if (process.env.SPANORY_OPENCLAW_PLUGIN_DIR) {
585
706
  return process.env.SPANORY_OPENCLAW_PLUGIN_DIR;
586
707
  }
587
- return path.resolve(process.cwd(), 'packages/openclaw-plugin');
708
+ const candidates = [
709
+ resolveInstalledPackageDir('@bububuger/spanory-openclaw-plugin'),
710
+ path.resolve(CLI_PACKAGE_DIR, '..', 'openclaw-plugin'),
711
+ path.resolve(process.cwd(), 'packages/openclaw-plugin'),
712
+ ].filter(Boolean);
713
+ const hit = candidates.find((dir) => existsSync(path.join(dir, 'package.json')));
714
+ return hit ?? candidates[0] ?? path.resolve(process.cwd(), 'packages/openclaw-plugin');
588
715
  }
589
716
  function resolveOpencodePluginDir() {
590
717
  if (process.env.SPANORY_OPENCODE_PLUGIN_DIR) {
591
718
  return process.env.SPANORY_OPENCODE_PLUGIN_DIR;
592
719
  }
593
- return path.resolve(process.cwd(), 'packages/opencode-plugin');
720
+ const pkgCandidate = process.pkg
721
+ ? path.resolve(path.dirname(process.execPath), '..', 'opencode-plugin')
722
+ : undefined;
723
+ const candidates = [
724
+ resolveInstalledPackageDir('@bububuger/spanory-opencode-plugin'),
725
+ pkgCandidate,
726
+ path.resolve(CLI_PACKAGE_DIR, '..', 'opencode-plugin'),
727
+ path.resolve(process.cwd(), 'packages/opencode-plugin'),
728
+ ].filter(Boolean);
729
+ const hit = candidates.find((dir) => existsSync(path.join(dir, 'package.json')));
730
+ return hit ?? candidates[0] ?? path.resolve(process.cwd(), 'packages/opencode-plugin');
731
+ }
732
+ function resolveInstalledPackageDir(packageName) {
733
+ try {
734
+ const pkgJsonPath = requireFromHere.resolve(`${packageName}/package.json`);
735
+ return path.dirname(pkgJsonPath);
736
+ }
737
+ catch {
738
+ return undefined;
739
+ }
740
+ }
741
+ async function resolveOpencodePluginEntry(pluginDir) {
742
+ const explicitEntry = process.env.SPANORY_OPENCODE_PLUGIN_ENTRY;
743
+ if (explicitEntry) {
744
+ await stat(explicitEntry);
745
+ return explicitEntry;
746
+ }
747
+ const pluginEntry = path.join(pluginDir, 'dist', 'index.js');
748
+ await stat(pluginEntry);
749
+ return pluginEntry;
594
750
  }
595
751
  function resolveOpencodePluginInstallDir(runtimeHome) {
596
752
  return path.join(resolveRuntimeHome('opencode', runtimeHome), 'plugin');
@@ -632,7 +788,7 @@ async function runOpenclawPluginDoctor(runtimeHome) {
632
788
  id: 'otlp_endpoint',
633
789
  ok: Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
634
790
  detail: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
635
- ? process.env.OTEL_EXPORTER_OTLP_ENDPOINT
791
+ ? maskEndpoint(process.env.OTEL_EXPORTER_OTLP_ENDPOINT)
636
792
  : 'OTEL_EXPORTER_OTLP_ENDPOINT is unset',
637
793
  });
638
794
  const spoolDir = process.env.SPANORY_OPENCLAW_SPOOL_DIR
@@ -669,15 +825,32 @@ async function runOpencodePluginDoctor(runtimeHome) {
669
825
  catch {
670
826
  checks.push({ id: 'plugin_installed', ok: false, detail: `plugin loader missing: ${loaderFile}` });
671
827
  }
828
+ try {
829
+ const loaderUrl = pathToFileURL(loaderFile).href;
830
+ const mod = await import(loaderUrl);
831
+ const hasDefault = typeof mod.default === 'function' || typeof mod.SpanoryOpencodePlugin === 'function';
832
+ checks.push({
833
+ id: 'plugin_loadable',
834
+ ok: hasDefault,
835
+ detail: hasDefault ? 'plugin module loaded and exports a register function' : 'plugin module loaded but missing default export function',
836
+ });
837
+ }
838
+ catch (err) {
839
+ checks.push({
840
+ id: 'plugin_loadable',
841
+ ok: false,
842
+ detail: `plugin import failed: ${err instanceof Error ? err.message : String(err)}`,
843
+ });
844
+ }
672
845
  checks.push({
673
846
  id: 'otlp_endpoint',
674
847
  ok: Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
675
848
  detail: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
676
- ? process.env.OTEL_EXPORTER_OTLP_ENDPOINT
849
+ ? maskEndpoint(process.env.OTEL_EXPORTER_OTLP_ENDPOINT)
677
850
  : 'OTEL_EXPORTER_OTLP_ENDPOINT is unset',
678
851
  });
679
852
  const spoolDir = process.env.SPANORY_OPENCODE_SPOOL_DIR
680
- ?? path.join(resolveRuntimeStateRoot('opencode', runtimeHome), 'spanory', 'spool');
853
+ ?? path.join(resolveOpencodePluginStateRoot(runtimeHome), 'spool');
681
854
  try {
682
855
  await mkdir(spoolDir, { recursive: true });
683
856
  checks.push({ id: 'spool_writable', ok: true, detail: spoolDir });
@@ -685,8 +858,8 @@ async function runOpencodePluginDoctor(runtimeHome) {
685
858
  catch (err) {
686
859
  checks.push({ id: 'spool_writable', ok: false, detail: String(err) });
687
860
  }
688
- const statusFile = path.join(resolveRuntimeStateRoot('opencode', runtimeHome), 'spanory', 'plugin-status.json');
689
- const logFile = path.join(resolveRuntimeStateRoot('opencode', runtimeHome), 'spanory', 'plugin.log');
861
+ const statusFile = path.join(resolveOpencodePluginStateRoot(runtimeHome), 'plugin-status.json');
862
+ const logFile = path.join(resolveOpencodePluginStateRoot(runtimeHome), 'plugin.log');
690
863
  try {
691
864
  await mkdir(path.dirname(logFile), { recursive: true });
692
865
  checks.push({ id: 'opencode_plugin_log', ok: true, detail: logFile });
@@ -704,7 +877,7 @@ async function runOpencodePluginDoctor(runtimeHome) {
704
877
  checks.push({
705
878
  id: 'last_send_endpoint_configured',
706
879
  ok: false,
707
- detail: 'last send skipped or failed to resolve OTLP endpoint in opencode process; check ~/.env and restart opencode',
880
+ detail: `last send skipped or failed to resolve OTLP endpoint in opencode process; check ${resolveSpanoryEnvPath()} and restart opencode`,
708
881
  });
709
882
  }
710
883
  else if (endpointConfigured === true) {
@@ -834,7 +1007,7 @@ function codexNotifyScriptContent({ spanoryBin, codexHome, exportDir, logFile })
834
1007
  + 'set -euo pipefail\n'
835
1008
  + 'payload="${1:-}"\n'
836
1009
  + 'if [[ -z "$payload" ]] && [[ ! -t 0 ]]; then\n'
837
- + ' payload="$(cat || true)"\n'
1010
+ + ' IFS= read -r -t 0 payload || true\n'
838
1011
  + 'fi\n'
839
1012
  + 'if [[ -z "${payload//[$\'\\t\\r\\n \']/}" ]]; then\n'
840
1013
  + ` echo "skip=empty-payload source=codex-notify args=$#" >> "${logFile}"\n`
@@ -860,10 +1033,11 @@ function codexNotifyScriptContent({ spanoryBin, codexHome, exportDir, logFile })
860
1033
  }
861
1034
  async function applyCodexSetup({ homeRoot, spanoryBin, dryRun }) {
862
1035
  const codexHome = path.join(homeRoot, '.codex');
1036
+ const spanoryHome = process.env.SPANORY_HOME ?? path.join(homeRoot, '.spanory');
863
1037
  const binDir = path.join(codexHome, 'bin');
864
1038
  const stateDir = path.join(codexHome, 'state', 'spanory');
865
1039
  const scriptPath = path.join(binDir, 'spanory-codex-notify.sh');
866
- const logFile = path.join(codexHome, 'state', 'spanory-codex-hook.log');
1040
+ const logFile = path.join(spanoryHome, 'logs', 'codex-notify.log');
867
1041
  const configPath = path.join(codexHome, 'config.toml');
868
1042
  const notifyScriptRef = scriptPath;
869
1043
  const scriptContent = codexNotifyScriptContent({
@@ -896,6 +1070,7 @@ async function applyCodexSetup({ homeRoot, spanoryBin, dryRun }) {
896
1070
  if (changed && !dryRun) {
897
1071
  await mkdir(binDir, { recursive: true });
898
1072
  await mkdir(stateDir, { recursive: true });
1073
+ await mkdir(path.dirname(logFile), { recursive: true });
899
1074
  if (configChanged) {
900
1075
  configBackup = await backupIfExists(configPath);
901
1076
  await writeFile(configPath, nextConfig, 'utf-8');
@@ -919,6 +1094,38 @@ function commandExists(command) {
919
1094
  const result = runSystemCommand('which', [command], { env: process.env });
920
1095
  return result.code === 0;
921
1096
  }
1097
+ function detectUpgradePackageManager(userAgent = process.env.npm_config_user_agent) {
1098
+ const token = String(userAgent ?? '').trim().split(/\s+/)[0] ?? '';
1099
+ if (token.startsWith('tnpm/'))
1100
+ return 'tnpm';
1101
+ return 'npm';
1102
+ }
1103
+ function detectUpgradeScope() {
1104
+ if (String(process.env.npm_config_global ?? '').trim() === 'true') {
1105
+ return 'global';
1106
+ }
1107
+ const normalizedEntry = String(EXECUTION_ENTRY ?? '').replaceAll('\\', '/');
1108
+ if (normalizedEntry.includes('/lib/node_modules/') || normalizedEntry.includes('/npm-global/')) {
1109
+ return 'global';
1110
+ }
1111
+ if (normalizedEntry.includes('/node_modules/')) {
1112
+ return 'local';
1113
+ }
1114
+ return 'global';
1115
+ }
1116
+ function resolveUpgradeInvocation(scope, manager) {
1117
+ const selectedManager = manager === 'tnpm' ? 'tnpm' : 'npm';
1118
+ const args = ['install'];
1119
+ if (scope === 'global')
1120
+ args.push('-g');
1121
+ args.push(`${SPANORY_NPM_PACKAGE}@latest`);
1122
+ return {
1123
+ manager: selectedManager,
1124
+ command: selectedManager,
1125
+ args,
1126
+ scope,
1127
+ };
1128
+ }
922
1129
  function openclawRuntimeHomeForSetup(homeRoot, explicitRuntimeHome) {
923
1130
  return explicitRuntimeHome || path.join(homeRoot, '.openclaw');
924
1131
  }
@@ -950,10 +1157,16 @@ function installOpenclawPlugin(runtimeHome) {
950
1157
  enableStdout: enableResult.stdout.trim(),
951
1158
  };
952
1159
  }
953
- async function installOpencodePlugin(runtimeHome) {
954
- const pluginDir = path.resolve(resolveOpencodePluginDir());
955
- const pluginEntry = path.join(pluginDir, 'src', 'index.js');
956
- await stat(pluginEntry);
1160
+ async function installOpencodePlugin(runtimeHome, pluginDirOverride) {
1161
+ const pluginDir = path.resolve(pluginDirOverride ?? resolveOpencodePluginDir());
1162
+ let pluginEntry;
1163
+ try {
1164
+ pluginEntry = await resolveOpencodePluginEntry(pluginDir);
1165
+ }
1166
+ catch {
1167
+ throw new Error(`opencode plugin entry not found at ${path.join(pluginDir, 'dist', 'index.js')}. `
1168
+ + 'build plugin first: npm run --workspace @bububuger/spanory-opencode-plugin build');
1169
+ }
957
1170
  const installDir = resolveOpencodePluginInstallDir(runtimeHome);
958
1171
  const loaderFile = opencodePluginLoaderPath(runtimeHome);
959
1172
  await mkdir(installDir, { recursive: true });
@@ -1508,18 +1721,8 @@ function registerRuntimeCommands(runtimeRoot, runtimeName) {
1508
1721
  .option('--plugin-dir <path>', 'Plugin directory path (default: packages/opencode-plugin)')
1509
1722
  .option('--runtime-home <path>', 'Override OpenCode runtime home (default: ~/.config/opencode)')
1510
1723
  .action(async (options) => {
1511
- const pluginDir = path.resolve(options.pluginDir ?? resolveOpencodePluginDir());
1512
- const pluginEntry = path.join(pluginDir, 'src', 'index.js');
1513
- await stat(pluginEntry);
1514
- const installDir = resolveOpencodePluginInstallDir(options.runtimeHome);
1515
- const loaderFile = opencodePluginLoaderPath(options.runtimeHome);
1516
- await mkdir(installDir, { recursive: true });
1517
- const importUrl = pathToFileURL(pluginEntry).href;
1518
- const loader = `import plugin from ${JSON.stringify(importUrl)};\n`
1519
- + 'export const SpanoryOpencodePlugin = plugin;\n'
1520
- + 'export default SpanoryOpencodePlugin;\n';
1521
- await writeFile(loaderFile, loader, 'utf-8');
1522
- console.log(`installed=${loaderFile}`);
1724
+ const result = await installOpencodePlugin(options.runtimeHome, options.pluginDir);
1725
+ console.log(`installed=${result.loaderFile}`);
1523
1726
  });
1524
1727
  plugin
1525
1728
  .command('uninstall')
@@ -1548,7 +1751,7 @@ program
1548
1751
  .description('Cross-runtime observability CLI for agent sessions')
1549
1752
  .showHelpAfterError()
1550
1753
  .showSuggestionAfterError(true)
1551
- .version('0.1.1');
1754
+ .version(CLI_VERSION, '-v, --version');
1552
1755
  const runtime = program.command('runtime').description('Runtime-specific parsers and exporters');
1553
1756
  for (const runtimeName of ['claude-code', 'codex', 'openclaw', 'opencode']) {
1554
1757
  registerRuntimeCommands(runtime, runtimeName);
@@ -1602,6 +1805,14 @@ report
1602
1805
  const sessions = await loadExportedEvents(options.inputJson);
1603
1806
  console.log(JSON.stringify({ view: 'tool-summary', rows: summarizeTools(sessions) }, null, 2));
1604
1807
  });
1808
+ report
1809
+ .command('context')
1810
+ .description('Context observability summary per session')
1811
+ .requiredOption('--input-json <path>', 'Path to exported JSON file or directory of JSON files')
1812
+ .action(async (options) => {
1813
+ const sessions = await loadExportedEvents(options.inputJson);
1814
+ console.log(JSON.stringify({ view: 'context-summary', rows: summarizeContext(sessions) }, null, 2));
1815
+ });
1605
1816
  report
1606
1817
  .command('turn-diff')
1607
1818
  .description('Turn input diff summary view')
@@ -1703,7 +1914,7 @@ issue
1703
1914
  });
1704
1915
  program
1705
1916
  .command('hook')
1706
- .description('Minimal hook entrypoint (defaults to runtime payload + ~/.env + default export dir)')
1917
+ .description('Minimal hook entrypoint (defaults to runtime payload + ~/.spanory/.env + default export dir)')
1707
1918
  .option('--runtime <name>', 'Runtime name (default: SPANORY_HOOK_RUNTIME or claude-code)')
1708
1919
  .option('--runtime-home <path>', 'Override runtime home directory')
1709
1920
  .option('--endpoint <url>', 'OTLP HTTP endpoint (fallback: OTEL_EXPORTER_OTLP_ENDPOINT)')
@@ -1768,6 +1979,33 @@ setup
1768
1979
  if (!report.ok)
1769
1980
  process.exitCode = 2;
1770
1981
  });
1982
+ program
1983
+ .command('upgrade')
1984
+ .description('Upgrade spanory CLI from npm registry')
1985
+ .option('--dry-run', 'Print upgrade command without executing', false)
1986
+ .option('--manager <name>', 'Package manager override: npm|tnpm')
1987
+ .option('--scope <scope>', 'Install scope override: global|local')
1988
+ .action(async (options) => {
1989
+ const manager = options.manager === 'tnpm' ? 'tnpm' : detectUpgradePackageManager();
1990
+ const scope = options.scope === 'local' ? 'local' : options.scope === 'global' ? 'global' : detectUpgradeScope();
1991
+ const invocation = resolveUpgradeInvocation(scope, manager);
1992
+ if (options.dryRun) {
1993
+ console.log(JSON.stringify({ dryRun: true, ...invocation }, null, 2));
1994
+ return;
1995
+ }
1996
+ const result = runSystemCommand(invocation.command, invocation.args, { env: process.env });
1997
+ const output = (result.stdout || result.stderr || '').trim();
1998
+ if (result.code !== 0) {
1999
+ console.error(output || result.error || 'upgrade failed');
2000
+ process.exitCode = 2;
2001
+ return;
2002
+ }
2003
+ console.log(JSON.stringify({
2004
+ ok: true,
2005
+ ...invocation,
2006
+ output,
2007
+ }, null, 2));
2008
+ });
1771
2009
  loadUserEnv()
1772
2010
  .then(() => program.parseAsync(process.argv))
1773
2011
  .catch((error) => {
package/dist/otlp.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { buildResource } from '../../otlp-core/dist/index.js';
2
2
  export { buildResource };
3
- export declare function parseHeaders(input: any): any;
4
- export declare function compileOtlp(events: any, resource: any): any;
3
+ export declare function parseHeaders(input: any): Record<string, string>;
4
+ export declare function compileOtlp(events: any, resource: any): import("../../otlp-core/dist/index.js").OtlpPayload;
5
5
  export declare function sendOtlp(endpoint: any, payload: any, headers?: {}): Promise<void>;
@@ -19,3 +19,4 @@ export declare function summarizeTools(sessions: any): {
19
19
  sessions: any;
20
20
  }[];
21
21
  export declare function summarizeTurnDiff(sessions: any): any[];
22
+ export declare function summarizeContext(sessions: any): any;
@@ -26,6 +26,32 @@ function usageFromEvent(event) {
26
26
  const total = toNumber(attrs['gen_ai.usage.total_tokens']) || input + output;
27
27
  return { input, output, total };
28
28
  }
29
+ function parseJsonObject(value) {
30
+ if (typeof value !== 'string' || !value.trim())
31
+ return null;
32
+ try {
33
+ const parsed = JSON.parse(value);
34
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
35
+ return parsed;
36
+ }
37
+ catch {
38
+ // ignore parse errors
39
+ }
40
+ return null;
41
+ }
42
+ function parseJsonArray(value) {
43
+ if (typeof value !== 'string' || !value.trim())
44
+ return [];
45
+ try {
46
+ const parsed = JSON.parse(value);
47
+ if (Array.isArray(parsed))
48
+ return parsed;
49
+ }
50
+ catch {
51
+ // ignore parse errors
52
+ }
53
+ return [];
54
+ }
29
55
  export async function loadExportedEvents(inputPath) {
30
56
  const inputStat = await stat(inputPath);
31
57
  const files = [];
@@ -141,8 +167,8 @@ export function summarizeCache(sessions) {
141
167
  for (const turn of turns) {
142
168
  const attrs = turn.attributes ?? {};
143
169
  inputTokens += toNumber(attrs['gen_ai.usage.input_tokens']);
144
- cacheReadInputTokens += toNumber(attrs['gen_ai.usage.details.cache_read_input_tokens']);
145
- cacheCreationInputTokens += toNumber(attrs['gen_ai.usage.details.cache_creation_input_tokens']);
170
+ cacheReadInputTokens += toNumber(attrs['gen_ai.usage.cache_read.input_tokens']);
171
+ cacheCreationInputTokens += toNumber(attrs['gen_ai.usage.cache_creation.input_tokens']);
146
172
  const hitRate = toOptionalNumber(attrs['gen_ai.usage.details.cache_hit_rate']);
147
173
  if (hitRate !== undefined)
148
174
  explicitHitRates.push(hitRate);
@@ -243,3 +269,103 @@ export function summarizeTurnDiff(sessions) {
243
269
  }
244
270
  return rows;
245
271
  }
272
+ export function summarizeContext(sessions) {
273
+ return sessions.map((s) => {
274
+ const events = s.events ?? [];
275
+ const snapshots = events.filter((e) => e?.attributes?.['agentic.context.event_type'] === 'context_snapshot');
276
+ const boundaries = events.filter((e) => e?.attributes?.['agentic.context.event_type'] === 'context_boundary');
277
+ const attributions = events.filter((e) => e?.attributes?.['agentic.context.event_type'] === 'context_source_attribution');
278
+ let maxFillRatio = 0;
279
+ let maxDeltaTokens = 0;
280
+ for (const snapshot of snapshots) {
281
+ const attrs = snapshot.attributes ?? {};
282
+ const fillRatio = toOptionalNumber(attrs['agentic.context.fill_ratio']) ?? 0;
283
+ const deltaTokens = toOptionalNumber(attrs['agentic.context.delta_tokens']) ?? 0;
284
+ maxFillRatio = Math.max(maxFillRatio, fillRatio);
285
+ maxDeltaTokens = Math.max(maxDeltaTokens, deltaTokens);
286
+ }
287
+ const compactCount = boundaries.filter((e) => String(e?.attributes?.['agentic.context.boundary_kind'] ?? '') === 'compact_after').length;
288
+ const last5 = snapshots.slice(-5);
289
+ let unknownTokens = 0;
290
+ let totalTokens = 0;
291
+ for (const snapshot of last5) {
292
+ const composition = parseJsonObject(snapshot?.attributes?.['agentic.context.composition']);
293
+ if (!composition)
294
+ continue;
295
+ for (const [kind, raw] of Object.entries(composition)) {
296
+ const value = Number(raw);
297
+ if (!Number.isFinite(value) || value <= 0)
298
+ continue;
299
+ totalTokens += value;
300
+ if (kind === 'unknown')
301
+ unknownTokens += value;
302
+ }
303
+ }
304
+ const unknownDeltaShareWindow5 = totalTokens > 0 ? round6(unknownTokens / totalTokens) : 0;
305
+ let unknownTopStreak = 0;
306
+ let runningUnknown = 0;
307
+ for (const snapshot of snapshots) {
308
+ const topSources = parseJsonArray(snapshot?.attributes?.['agentic.context.top_sources']);
309
+ const top = String(topSources[0] ?? '').trim();
310
+ if (top === 'unknown') {
311
+ runningUnknown += 1;
312
+ unknownTopStreak = Math.max(unknownTopStreak, runningUnknown);
313
+ }
314
+ else {
315
+ runningUnknown = 0;
316
+ }
317
+ }
318
+ let highPollutionSourceStreak = 0;
319
+ const turnOrder = [];
320
+ const highByTurn = new Map();
321
+ for (const event of attributions) {
322
+ const attrs = event?.attributes ?? {};
323
+ const turnId = String(event?.turnId ?? '');
324
+ if (!turnId)
325
+ continue;
326
+ if (!highByTurn.has(turnId)) {
327
+ highByTurn.set(turnId, []);
328
+ turnOrder.push(turnId);
329
+ }
330
+ const sourceKind = String(attrs['agentic.context.source_kind'] ?? '').trim();
331
+ const score = Number(attrs['agentic.context.pollution_score']);
332
+ if (!sourceKind || !Number.isFinite(score) || score < 80)
333
+ continue;
334
+ highByTurn.get(turnId).push({ sourceKind, score });
335
+ }
336
+ let runningSource = '';
337
+ let runningCount = 0;
338
+ for (const turnId of turnOrder) {
339
+ const items = highByTurn.get(turnId) ?? [];
340
+ if (!items.length) {
341
+ runningSource = '';
342
+ runningCount = 0;
343
+ continue;
344
+ }
345
+ items.sort((a, b) => b.score - a.score);
346
+ const topSource = items[0].sourceKind;
347
+ if (topSource === runningSource) {
348
+ runningCount += 1;
349
+ }
350
+ else {
351
+ runningSource = topSource;
352
+ runningCount = 1;
353
+ }
354
+ highPollutionSourceStreak = Math.max(highPollutionSourceStreak, runningCount);
355
+ }
356
+ return {
357
+ projectId: s.context.projectId ?? events[0]?.projectId,
358
+ sessionId: s.context.sessionId ?? events[0]?.sessionId,
359
+ runtime: events[0]?.runtime,
360
+ snapshots: snapshots.length,
361
+ boundaries: boundaries.length,
362
+ compactCount,
363
+ attributions: attributions.length,
364
+ maxFillRatio: round6(maxFillRatio),
365
+ maxDeltaTokens,
366
+ unknownDeltaShareWindow5,
367
+ unknownTopStreak,
368
+ highPollutionSourceStreak,
369
+ };
370
+ });
371
+ }
@@ -60,7 +60,7 @@ function sanitizeProjectBase(name) {
60
60
  }
61
61
  function deriveProjectIdFromCwd(cwd) {
62
62
  const base = sanitizeProjectBase(path.basename(String(cwd ?? '').trim()) || 'codex');
63
- const hash = createHash('sha1').update(String(cwd ?? '')).digest('hex').slice(0, 6);
63
+ const hash = createHash('sha1').update(String(cwd ?? '')).digest('hex').slice(0, 10);
64
64
  return `${base}-${hash}`;
65
65
  }
66
66
  function usageFromTotals(start, end) {
@@ -117,7 +117,10 @@ function correlationKeyFromRequest(req, seq) {
117
117
  }
118
118
  export function createCodexProxyServer(options) {
119
119
  const upstreamBaseUrl = options?.upstreamBaseUrl ?? process.env.OPENAI_BASE_URL ?? 'https://api.openai.com';
120
- const spoolDir = options?.spoolDir ?? process.env.SPANORY_CODEX_PROXY_SPOOL_DIR ?? path.join(process.cwd(), '.spanory', 'codex-proxy-spool');
120
+ const spanoryHome = process.env.SPANORY_HOME ?? path.join(process.env.HOME || '', '.spanory');
121
+ const spoolDir = options?.spoolDir
122
+ ?? process.env.SPANORY_CODEX_PROXY_SPOOL_DIR
123
+ ?? path.join(spanoryHome, 'spool', 'codex-proxy');
121
124
  const maxBodyBytes = Number(options?.maxBodyBytes ?? process.env.SPANORY_CODEX_CAPTURE_MAX_BYTES ?? 131072);
122
125
  const logger = options?.logger ?? console;
123
126
  const upstream = new URL(upstreamBaseUrl);