@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/alert/evaluate.js +151 -0
- package/dist/env.d.ts +4 -0
- package/dist/env.js +40 -12
- package/dist/index.js +275 -37
- package/dist/otlp.d.ts +2 -2
- package/dist/report/aggregate.d.ts +1 -0
- package/dist/report/aggregate.js +128 -2
- package/dist/runtime/codex/adapter.js +1 -1
- package/dist/runtime/codex/proxy.js +4 -1
- package/dist/runtime/shared/normalize.js +387 -15
- package/package.json +8 -6
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:
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
689
|
-
const logFile = path.join(
|
|
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:
|
|
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
|
-
+ '
|
|
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(
|
|
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
|
-
|
|
956
|
-
|
|
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
|
|
1512
|
-
|
|
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('
|
|
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):
|
|
4
|
-
export declare function compileOtlp(events: any, resource: 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>;
|
package/dist/report/aggregate.js
CHANGED
|
@@ -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.
|
|
145
|
-
cacheCreationInputTokens += toNumber(attrs['gen_ai.usage.
|
|
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,
|
|
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
|
|
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);
|