@intelli-1113/stratos-sdk 1.3.5 → 1.3.6

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.
@@ -0,0 +1,155 @@
1
+ // Shared Claude Code settings helpers: where Stratos keeps its own config,
2
+ // the hook block written into ~/.claude/settings.json, and a merge that never
3
+ // drops the user's own hooks.
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export const DEFAULT_URL = "http://localhost:4000";
11
+ export const HOOK_EVENTS = {
12
+ SessionStart: { arg: "session-start", timeout: 10 },
13
+ Stop: { arg: "stop", timeout: 30, async: true },
14
+ SubagentStop: { arg: "subagent-stop", timeout: 30, async: true },
15
+ SessionEnd: { arg: "session-end", timeout: 15 },
16
+ };
17
+
18
+ /** ~/.stratos — token, URL, cursors and the outbox live here, not in settings.json. */
19
+ export function stratosDir() {
20
+ return process.env.STRATOS_HOME || join(homedir(), ".stratos");
21
+ }
22
+
23
+ export function claudeSettingsPath() {
24
+ return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json");
25
+ }
26
+
27
+ const configPath = () => join(stratosDir(), "config.json");
28
+
29
+ function flag(v, fallback = false) {
30
+ if (v == null || v === "") return fallback;
31
+ return !["0", "false", "off", "no"].includes(String(v).toLowerCase());
32
+ }
33
+
34
+ /** Config file, overridden by STRATOS_* environment variables when set. */
35
+ export function loadConfig() {
36
+ let file = {};
37
+ try {
38
+ file = JSON.parse(readFileSync(configPath(), "utf8"));
39
+ } catch {
40
+ /* not set up yet */
41
+ }
42
+ const env = process.env;
43
+ return {
44
+ token: env.STRATOS_TOKEN || file.token || "",
45
+ url: (env.STRATOS_URL || file.url || DEFAULT_URL).replace(/\/+$/, ""),
46
+ appName: env.STRATOS_APP_NAME || file.appName || "claude-code",
47
+ recordInputs: flag(env.STRATOS_RECORD_INPUTS, !!file.recordInputs),
48
+ recordOutputs: flag(env.STRATOS_RECORD_OUTPUTS, !!file.recordOutputs),
49
+ };
50
+ }
51
+
52
+ export function saveConfig(cfg) {
53
+ const file = configPath();
54
+ mkdirSync(dirname(file), { recursive: true });
55
+ writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
56
+ try {
57
+ chmodSync(file, 0o600); // no-op on Windows, where the profile folder is already per-user
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ return file;
62
+ }
63
+
64
+ export function configExists() {
65
+ return existsSync(configPath());
66
+ }
67
+
68
+ export function configFile() {
69
+ return configPath();
70
+ }
71
+
72
+ /** Absolute path of this package's CLI entry. */
73
+ export function cliPath() {
74
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
75
+ }
76
+
77
+ const fwd = (p) => p.replace(/\\/g, "/");
78
+
79
+ /**
80
+ * The command Claude Code runs for one hook event. The script path is absolute
81
+ * (no global install needed); the program is bare `node` because a command that
82
+ * starts with a quoted path is a parse error in PowerShell, which Claude Code
83
+ * may use for hooks on Windows. `node "<path>" hook stop` works in bash, cmd and
84
+ * PowerShell alike.
85
+ */
86
+ export function hookCommand(arg, { node = "node", cli = cliPath() } = {}) {
87
+ return `${node} "${fwd(cli)}" hook ${arg}`;
88
+ }
89
+
90
+ const OUR_HOOK = /cli\.js"?\s+hook\s+(session-start|stop|subagent-stop|session-end)\b/;
91
+
92
+ export function isStratosHook(h) {
93
+ return !!(h && typeof h.command === "string" && OUR_HOOK.test(h.command) && /stratos/i.test(h.command));
94
+ }
95
+
96
+ /** Hook block in Claude Code's schema: { Event: [ { matcher, hooks: [ {type, command, ...} ] } ] } */
97
+ export function buildClaudeCodeHooks(opts = {}) {
98
+ const hooks = {};
99
+ for (const [event, spec] of Object.entries(HOOK_EVENTS)) {
100
+ const handler = { type: "command", command: hookCommand(spec.arg, opts), timeout: spec.timeout };
101
+ if (spec.async) handler.async = true;
102
+ hooks[event] = [{ matcher: "*", hooks: [handler] }];
103
+ }
104
+ return hooks;
105
+ }
106
+
107
+ /** Remove our handlers from a hooks object, keeping everything else the user has. */
108
+ export function removeStratosHooks(hooks = {}) {
109
+ const out = {};
110
+ for (const [event, groups] of Object.entries(hooks || {})) {
111
+ if (!Array.isArray(groups)) {
112
+ out[event] = groups;
113
+ continue;
114
+ }
115
+ const kept = groups
116
+ .map((g) => (g && Array.isArray(g.hooks) ? { ...g, hooks: g.hooks.filter((h) => !isStratosHook(h)) } : g))
117
+ .filter((g) => !(g && Array.isArray(g.hooks) && g.hooks.length === 0));
118
+ if (kept.length) out[event] = kept;
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** Add our hooks; re-running replaces our old entries instead of duplicating them. */
124
+ export function mergeStratosHooks(hooks = {}, opts = {}) {
125
+ const out = removeStratosHooks(hooks);
126
+ for (const [event, groups] of Object.entries(buildClaudeCodeHooks(opts))) {
127
+ out[event] = [...(Array.isArray(out[event]) ? out[event] : []), ...groups];
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** Earlier setup versions wrote these into settings.env; they must go. */
133
+ export const LEGACY_ENV_KEYS = ["STRATOS_TOKEN", "STRATOS_URL", "STRATOS_RECORD_INPUTS", "STRATOS_RECORD_OUTPUTS"];
134
+
135
+ export function hasLegacyNodeOptions(env = {}) {
136
+ return typeof env.NODE_OPTIONS === "string" && env.NODE_OPTIONS.includes("stratos-sdk/register");
137
+ }
138
+
139
+ export function cleanLegacyEnv(env = {}) {
140
+ const out = { ...env };
141
+ for (const k of LEGACY_ENV_KEYS) delete out[k];
142
+ if (hasLegacyNodeOptions(out)) {
143
+ const rest = out.NODE_OPTIONS.replace(/--import[= ]\S*stratos-sdk\/register\S*/g, "").trim();
144
+ if (rest) out.NODE_OPTIONS = rest;
145
+ else delete out.NODE_OPTIONS;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** Managed-settings.json locations for admins deploying to every developer. */
151
+ export const MANAGED_SETTINGS_PATHS = {
152
+ win32: "C:\\Program Files\\ClaudeCode\\managed-settings.json",
153
+ darwin: "/Library/Application Support/ClaudeCode/managed-settings.json",
154
+ linux: "/etc/claude-code/managed-settings.json",
155
+ };
@@ -0,0 +1,259 @@
1
+ // Claude Code transcript → Stratos spans (pure, no I/O).
2
+ //
3
+ // Claude Code writes every session to a JSONL transcript
4
+ // (~/.claude/projects/<project>/<session>.jsonl, subagents in
5
+ // <session>/subagents/agent-<id>.jsonl). Each assistant API call carries the
6
+ // model and token usage; tool calls appear as tool_use blocks, answered later
7
+ // by tool_result blocks. The format is internal to Claude Code and not
8
+ // documented, so everything here is defensive: unknown shapes are skipped and
9
+ // nothing throws.
10
+ //
11
+ // Spans use the same OpenLLMetry/Traceloop attribute names the rest of the SDK
12
+ // sends (see mcp-proxy.js reportTool), so /api/ingest needs no changes.
13
+
14
+ import { createHash } from "node:crypto";
15
+
16
+ const MAX_TEXT = 2048;
17
+
18
+ const hash = (s, n) => createHash("sha256").update(String(s)).digest("hex").slice(0, n);
19
+ const attr = (k, v) => ({ key: k, value: { stringValue: String(v) } });
20
+ const clip = (s) => {
21
+ const str = typeof s === "string" ? s : JSON.stringify(s ?? "");
22
+ return str.length > MAX_TEXT ? str.slice(0, MAX_TEXT) + "…" : str;
23
+ };
24
+
25
+ function toNano(iso) {
26
+ const ms = Date.parse(iso);
27
+ return Number.isFinite(ms) ? (BigInt(ms) * 1000000n).toString() : null;
28
+ }
29
+
30
+ export const traceIdFor = (sessionId) => hash(`session:${sessionId}`, 32);
31
+ export const llmSpanIdFor = (messageId) => hash(`msg:${messageId}`, 16);
32
+ export const toolSpanIdFor = (toolUseId) => hash(`tool:${toolUseId}`, 16);
33
+
34
+ /** Parse JSONL text into objects, skipping blank or malformed lines. */
35
+ export function parseLines(text) {
36
+ const out = [];
37
+ for (const line of String(text || "").split("\n")) {
38
+ if (!line.trim()) continue;
39
+ try {
40
+ const o = JSON.parse(line);
41
+ if (o && typeof o === "object") out.push(o);
42
+ } catch {
43
+ /* partial or foreign line — skip */
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function contentBlocks(message) {
50
+ return Array.isArray(message?.content) ? message.content : [];
51
+ }
52
+
53
+ function promptText(entry) {
54
+ const c = entry?.message?.content;
55
+ if (typeof c === "string") return c;
56
+ return contentBlocks(entry?.message)
57
+ .filter((b) => b?.type === "text")
58
+ .map((b) => b.text)
59
+ .join("\n");
60
+ }
61
+
62
+ function splitMcp(name) {
63
+ const m = /^mcp__(.+?)__(.+)$/.exec(name || "");
64
+ return m ? { server: m[1], tool: m[2] } : null;
65
+ }
66
+
67
+ /**
68
+ * Build spans from transcript entries.
69
+ *
70
+ * @param {object[]} entries parsed transcript lines (one batch, in order)
71
+ * @param {object} opts
72
+ * @param {string} opts.sessionId
73
+ * @param {{id?:string,type?:string,parentToolUseId?:string}} [opts.agent] set for subagent transcripts
74
+ * @param {Set<string>} [opts.sentMessages] message ids already reported (dedupe across runs)
75
+ * @param {Set<string>} [opts.sentTools] tool_use ids already reported
76
+ * @param {object} [opts.pending] tool_use ids seen earlier without a result: { id: {name, ts, input} }
77
+ * @param {boolean} [opts.recordInputs]
78
+ * @param {boolean} [opts.recordOutputs]
79
+ * @returns {{ spans: object[], messageIds: string[], toolIds: string[], pending: object }}
80
+ */
81
+ export function buildSpans(entries, opts = {}) {
82
+ const sessionId = opts.sessionId || entries.find((e) => e?.sessionId)?.sessionId || "unknown";
83
+ const traceId = traceIdFor(sessionId);
84
+ const sentMessages = opts.sentMessages || new Set();
85
+ const sentTools = opts.sentTools || new Set();
86
+ const pending = { ...(opts.pending || {}) };
87
+ const agent = opts.agent || null;
88
+ const parentSpanId = agent?.parentToolUseId ? toolSpanIdFor(agent.parentToolUseId) : undefined;
89
+
90
+ const common = [attr("stratos.surface", "claude-code"), attr("session.id", sessionId)];
91
+ if (agent) {
92
+ common.push(attr("stratos.subagent", "true"));
93
+ if (agent.id) common.push(attr("stratos.agent.id", agent.id));
94
+ if (agent.type) common.push(attr("stratos.agent.type", agent.type));
95
+ }
96
+
97
+ // Streaming writes one line per content block, each repeating the same
98
+ // usage — group lines by message id and report each API call once.
99
+ const calls = new Map();
100
+ let lastInputTs = null;
101
+ let lastPrompt = null;
102
+
103
+ for (const e of entries) {
104
+ if (e?.type === "user") {
105
+ const blocks = contentBlocks(e.message);
106
+ const results = blocks.filter((b) => b?.type === "tool_result");
107
+ if (!results.length) {
108
+ lastPrompt = promptText(e);
109
+ }
110
+ for (const r of results) {
111
+ const id = r.tool_use_id;
112
+ if (id && pending[id]) pending[id].result = { ts: e.timestamp, isError: !!r.is_error, content: r.content };
113
+ }
114
+ if (e.timestamp) lastInputTs = e.timestamp;
115
+ continue;
116
+ }
117
+ if (e?.type !== "assistant" || !e.message) continue;
118
+
119
+ const msg = e.message;
120
+ const key = msg.id || e.requestId || e.uuid;
121
+ if (!key) continue;
122
+ let call = calls.get(key);
123
+ if (!call) {
124
+ call = {
125
+ id: key,
126
+ requestId: e.requestId,
127
+ model: msg.model,
128
+ usage: msg.usage || {},
129
+ stopReason: msg.stop_reason,
130
+ startTs: lastInputTs || e.timestamp,
131
+ endTs: e.timestamp,
132
+ text: [],
133
+ prompt: lastPrompt,
134
+ version: e.version,
135
+ };
136
+ calls.set(key, call);
137
+ lastPrompt = null;
138
+ }
139
+ call.endTs = e.timestamp || call.endTs;
140
+ if ((msg.usage?.output_tokens || 0) >= (call.usage.output_tokens || 0)) call.usage = msg.usage || call.usage;
141
+ if (msg.stop_reason) call.stopReason = msg.stop_reason;
142
+
143
+ for (const b of contentBlocks(msg)) {
144
+ if (b?.type === "text" && b.text) call.text.push(b.text);
145
+ if (b?.type === "tool_use" && b.id && !sentTools.has(b.id) && !pending[b.id]) {
146
+ pending[b.id] = { name: b.name || "tool", ts: e.timestamp, input: b.input };
147
+ }
148
+ }
149
+ }
150
+
151
+ const spans = [];
152
+ const messageIds = [];
153
+
154
+ for (const call of calls.values()) {
155
+ if (sentMessages.has(call.id)) continue;
156
+ if (call.model === "<synthetic>") continue; // local error placeholders, not API calls
157
+ const u = call.usage || {};
158
+ const input = u.input_tokens || 0;
159
+ const output = u.output_tokens || 0;
160
+ const cacheRead = u.cache_read_input_tokens || 0;
161
+ const cacheWrite = u.cache_creation_input_tokens || 0;
162
+ const start = toNano(call.startTs) || toNano(call.endTs);
163
+ const end = toNano(call.endTs) || start;
164
+ if (!start) continue;
165
+
166
+ const attributes = [
167
+ attr("traceloop.span.kind", "llm"),
168
+ attr("traceloop.entity.name", "claude-code"),
169
+ attr("gen_ai.system", "anthropic"),
170
+ attr("gen_ai.request.model", call.model || "unknown"),
171
+ attr("gen_ai.response.model", call.model || "unknown"),
172
+ attr("gen_ai.usage.input_tokens", input),
173
+ attr("gen_ai.usage.output_tokens", output),
174
+ attr("gen_ai.usage.prompt_tokens", input),
175
+ attr("gen_ai.usage.completion_tokens", output),
176
+ attr("gen_ai.usage.cache_read_input_tokens", cacheRead),
177
+ attr("gen_ai.usage.cache_creation_input_tokens", cacheWrite),
178
+ attr("llm.usage.total_tokens", input + output + cacheRead + cacheWrite),
179
+ ...common,
180
+ ];
181
+ if (u.output_tokens_details?.thinking_tokens != null) {
182
+ attributes.push(attr("gen_ai.usage.thinking_tokens", u.output_tokens_details.thinking_tokens));
183
+ }
184
+ if (call.stopReason) attributes.push(attr("gen_ai.response.finish_reason", call.stopReason));
185
+ if (call.requestId) attributes.push(attr("stratos.request_id", call.requestId));
186
+ if (call.version) attributes.push(attr("claude_code.version", call.version));
187
+ if (opts.recordInputs && call.prompt) attributes.push(attr("traceloop.entity.input", clip(call.prompt)));
188
+ if (opts.recordOutputs && call.text.length) attributes.push(attr("traceloop.entity.output", clip(call.text.join("\n"))));
189
+
190
+ spans.push({
191
+ traceId,
192
+ spanId: llmSpanIdFor(call.id),
193
+ ...(parentSpanId ? { parentSpanId } : {}),
194
+ name: "anthropic.chat",
195
+ kind: 3,
196
+ startTimeUnixNano: start,
197
+ endTimeUnixNano: end,
198
+ attributes,
199
+ });
200
+ messageIds.push(call.id);
201
+ }
202
+
203
+ const toolIds = [];
204
+ for (const [id, t] of Object.entries(pending)) {
205
+ if (!t.result || sentTools.has(id)) continue;
206
+ const start = toNano(t.ts) || toNano(t.result.ts);
207
+ const end = toNano(t.result.ts) || start;
208
+ if (!start) continue;
209
+ const attributes = [
210
+ attr("traceloop.span.kind", "tool"),
211
+ attr("traceloop.entity.name", t.name),
212
+ attr("stratos.tool.is_error", t.result.isError),
213
+ ...common,
214
+ ];
215
+ const mcp = splitMcp(t.name);
216
+ if (mcp) attributes.push(attr("mcp.server", mcp.server), attr("mcp.tool", mcp.tool));
217
+ if (opts.recordInputs) {
218
+ attributes.push(attr("traceloop.entity.input", clip({ tool_name: t.name, arguments: t.input ?? {} })));
219
+ }
220
+ if (opts.recordOutputs) {
221
+ attributes.push(attr("traceloop.entity.output", clip({ content: t.result.content, is_error: t.result.isError })));
222
+ }
223
+ spans.push({
224
+ traceId,
225
+ spanId: toolSpanIdFor(id),
226
+ ...(parentSpanId ? { parentSpanId } : {}),
227
+ name: `${t.name}.tool`,
228
+ kind: 1,
229
+ startTimeUnixNano: start,
230
+ endTimeUnixNano: end,
231
+ attributes,
232
+ ...(t.result.isError ? { status: { code: 2 } } : {}),
233
+ });
234
+ toolIds.push(id);
235
+ delete pending[id];
236
+ }
237
+
238
+ // Strip bulky inputs from what is carried over to the next run.
239
+ for (const t of Object.values(pending)) {
240
+ if (!opts.recordInputs) delete t.input;
241
+ if (t.result && !opts.recordOutputs) delete t.result.content;
242
+ }
243
+
244
+ return { spans, messageIds, toolIds, pending };
245
+ }
246
+
247
+ /** Wrap spans in an OTLP/JSON body for POST /api/ingest. */
248
+ export function toOtlpPayload(spans, { serviceName = "claude-code", version } = {}) {
249
+ const resource = [attr("service.name", serviceName), attr("stratos.surface", "claude-code")];
250
+ if (version) resource.push(attr("claude_code.version", version));
251
+ return {
252
+ resourceSpans: [
253
+ {
254
+ resource: { attributes: resource },
255
+ scopeSpans: [{ scope: { name: "stratos-sdk.claude-code" }, spans }],
256
+ },
257
+ ],
258
+ };
259
+ }