@bububuger/spanory 0.1.14 → 0.1.16
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 +89 -1
- package/dist/issue/state.d.ts +39 -0
- package/dist/issue/state.js +151 -0
- package/dist/runtime/claude/adapter.js +151 -2
- package/dist/runtime/codex/adapter.js +38 -2
- package/dist/runtime/openclaw/adapter.js +140 -2
- package/dist/runtime/shared/file-settle.d.ts +19 -0
- package/dist/runtime/shared/file-settle.js +44 -0
- package/dist/runtime/shared/normalize.js +81 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -12,9 +12,11 @@ import { createCodexProxyServer } from './runtime/codex/proxy.js';
|
|
|
12
12
|
import { openclawAdapter } from './runtime/openclaw/adapter.js';
|
|
13
13
|
import { compileOtlp, parseHeaders, sendOtlp } from './otlp.js';
|
|
14
14
|
import { loadUserEnv } from './env.js';
|
|
15
|
+
import { waitForFileMtimeToSettle } from './runtime/shared/file-settle.js';
|
|
15
16
|
import { langfuseBackendAdapter } from '../../backend-langfuse/dist/index.js';
|
|
16
17
|
import { evaluateRules, loadAlertRules, sendAlertWebhook } from './alert/evaluate.js';
|
|
17
18
|
import { summarizeCache, loadExportedEvents, summarizeAgents, summarizeCommands, summarizeMcp, summarizeSessions, summarizeTools, summarizeTurnDiff, } from './report/aggregate.js';
|
|
19
|
+
import { loadIssueState, parsePendingTodoItems, resolveIssueStatePath, resolveTodoPath, saveIssueState, setIssueStatus, syncIssueState, } from './issue/state.js';
|
|
18
20
|
const runtimeAdapters = {
|
|
19
21
|
'claude-code': claudeCodeAdapter,
|
|
20
22
|
codex: codexAdapter,
|
|
@@ -242,6 +244,23 @@ async function runHookMode(options) {
|
|
|
242
244
|
if (!resolvedContext) {
|
|
243
245
|
throw new Error('cannot resolve runtime context from hook payload; require session_id (or thread_id)');
|
|
244
246
|
}
|
|
247
|
+
if (runtimeName === 'codex') {
|
|
248
|
+
const runtimeHome = resolveRuntimeHome(runtimeName, options.runtimeHome);
|
|
249
|
+
const transcriptPath = resolvedContext.transcriptPath
|
|
250
|
+
?? (await listCodexSessions(runtimeHome)).find((session) => session.sessionId === resolvedContext.sessionId)?.transcriptPath;
|
|
251
|
+
if (transcriptPath) {
|
|
252
|
+
const settle = await waitForFileMtimeToSettle({
|
|
253
|
+
filePath: transcriptPath,
|
|
254
|
+
stableWindowMs: 350,
|
|
255
|
+
timeoutMs: 2500,
|
|
256
|
+
pollMs: 120,
|
|
257
|
+
});
|
|
258
|
+
resolvedContext.transcriptPath = transcriptPath;
|
|
259
|
+
if (!settle.settled) {
|
|
260
|
+
console.log(`hook=settle-timeout sessionId=${resolvedContext.sessionId} waitedMs=${settle.waitedMs}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
245
264
|
await runContextExportMode({
|
|
246
265
|
runtimeName,
|
|
247
266
|
context: resolvedContext,
|
|
@@ -821,11 +840,23 @@ function codexNotifyScriptContent({ spanoryBin, codexHome, exportDir, logFile })
|
|
|
821
840
|
+ ` echo "skip=empty-payload source=codex-notify args=$#" >> "${logFile}"\n`
|
|
822
841
|
+ ' exit 0\n'
|
|
823
842
|
+ 'fi\n'
|
|
843
|
+
+ 'payload_file="$(mktemp "${TMPDIR:-/tmp}/spanory-codex-notify.XXXXXX")"\n'
|
|
844
|
+
+ 'printf \'%s\' "$payload" > "$payload_file"\n'
|
|
824
845
|
+ `echo "$payload" | "${spanoryBin}" runtime codex hook \\\n`
|
|
825
846
|
+ ' --last-turn-only \\\n'
|
|
826
847
|
+ ` --runtime-home "${codexHome}" \\\n`
|
|
827
848
|
+ ` --export-json-dir "${exportDir}" \\\n`
|
|
828
|
-
+ ` >> "${logFile}" 2>&1 || true\n
|
|
849
|
+
+ ` >> "${logFile}" 2>&1 || true\n`
|
|
850
|
+
+ '(\n'
|
|
851
|
+
+ ' sleep 2\n'
|
|
852
|
+
+ ` cat "$payload_file" | "${spanoryBin}" runtime codex hook \\\n`
|
|
853
|
+
+ ' --last-turn-only \\\n'
|
|
854
|
+
+ ' --force \\\n'
|
|
855
|
+
+ ` --runtime-home "${codexHome}" \\\n`
|
|
856
|
+
+ ` --export-json-dir "${exportDir}" \\\n`
|
|
857
|
+
+ ` >> "${logFile}" 2>&1 || true\n`
|
|
858
|
+
+ ' rm -f "$payload_file"\n'
|
|
859
|
+
+ ') >/dev/null 2>&1 &\n';
|
|
829
860
|
}
|
|
830
861
|
async function applyCodexSetup({ homeRoot, spanoryBin, dryRun }) {
|
|
831
862
|
const codexHome = path.join(homeRoot, '.codex');
|
|
@@ -1613,6 +1644,63 @@ alert
|
|
|
1613
1644
|
process.exitCode = 2;
|
|
1614
1645
|
}
|
|
1615
1646
|
});
|
|
1647
|
+
const issue = program.command('issue').description('Manage local issue status for automation巡检');
|
|
1648
|
+
issue
|
|
1649
|
+
.command('sync')
|
|
1650
|
+
.description('Sync pending items from todo.md into issue state file')
|
|
1651
|
+
.option('--todo-file <path>', 'Path to todo markdown file (default: ./todo.md)')
|
|
1652
|
+
.option('--state-file <path>', 'Path to issue state json file (default: ./docs/issues/state.json)')
|
|
1653
|
+
.action(async (options) => {
|
|
1654
|
+
const todoFile = resolveTodoPath(options.todoFile);
|
|
1655
|
+
const stateFile = resolveIssueStatePath(options.stateFile);
|
|
1656
|
+
const todoRaw = await readFile(todoFile, 'utf-8');
|
|
1657
|
+
const pending = parsePendingTodoItems(todoRaw, 'todo.md');
|
|
1658
|
+
const prev = await loadIssueState(stateFile);
|
|
1659
|
+
const result = syncIssueState(prev, pending);
|
|
1660
|
+
await saveIssueState(stateFile, result.state);
|
|
1661
|
+
console.log(JSON.stringify({
|
|
1662
|
+
stateFile,
|
|
1663
|
+
todoFile,
|
|
1664
|
+
pending: pending.length,
|
|
1665
|
+
added: result.added,
|
|
1666
|
+
reopened: result.reopened,
|
|
1667
|
+
autoClosed: result.autoClosed,
|
|
1668
|
+
total: result.state.issues.length,
|
|
1669
|
+
}, null, 2));
|
|
1670
|
+
});
|
|
1671
|
+
issue
|
|
1672
|
+
.command('list')
|
|
1673
|
+
.description('List issues from state file')
|
|
1674
|
+
.option('--state-file <path>', 'Path to issue state json file (default: ./docs/issues/state.json)')
|
|
1675
|
+
.option('--status <status>', 'Filter by status: open,in_progress,blocked,done')
|
|
1676
|
+
.action(async (options) => {
|
|
1677
|
+
const stateFile = resolveIssueStatePath(options.stateFile);
|
|
1678
|
+
const state = await loadIssueState(stateFile);
|
|
1679
|
+
const statusFilter = options.status ? String(options.status).trim() : '';
|
|
1680
|
+
const rows = statusFilter
|
|
1681
|
+
? state.issues.filter((item) => item.status === statusFilter)
|
|
1682
|
+
: state.issues;
|
|
1683
|
+
console.log(JSON.stringify({ stateFile, total: rows.length, issues: rows }, null, 2));
|
|
1684
|
+
});
|
|
1685
|
+
issue
|
|
1686
|
+
.command('set-status')
|
|
1687
|
+
.description('Update one issue status in state file')
|
|
1688
|
+
.requiredOption('--id <id>', 'Issue id, e.g. T2')
|
|
1689
|
+
.requiredOption('--status <status>', 'Target status: open|in_progress|blocked|done')
|
|
1690
|
+
.option('--note <text>', 'Optional status note')
|
|
1691
|
+
.option('--state-file <path>', 'Path to issue state json file (default: ./docs/issues/state.json)')
|
|
1692
|
+
.action(async (options) => {
|
|
1693
|
+
const stateFile = resolveIssueStatePath(options.stateFile);
|
|
1694
|
+
const prev = await loadIssueState(stateFile);
|
|
1695
|
+
const next = setIssueStatus(prev, {
|
|
1696
|
+
id: options.id,
|
|
1697
|
+
status: options.status,
|
|
1698
|
+
note: options.note,
|
|
1699
|
+
});
|
|
1700
|
+
await saveIssueState(stateFile, next);
|
|
1701
|
+
const issueItem = next.issues.find((item) => item.id === options.id);
|
|
1702
|
+
console.log(JSON.stringify({ stateFile, issue: issueItem }, null, 2));
|
|
1703
|
+
});
|
|
1616
1704
|
program
|
|
1617
1705
|
.command('hook')
|
|
1618
1706
|
.description('Minimal hook entrypoint (defaults to runtime payload + ~/.env + default export dir)')
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare const ISSUE_STATUSES: readonly ["open", "in_progress", "blocked", "done"];
|
|
2
|
+
export type IssueStatus = (typeof ISSUE_STATUSES)[number];
|
|
3
|
+
export interface IssueItem {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
source: string;
|
|
7
|
+
status: IssueStatus;
|
|
8
|
+
notes: string[];
|
|
9
|
+
createdAt: string;
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
closedAt?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface IssueState {
|
|
14
|
+
version: 1;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
issues: IssueItem[];
|
|
17
|
+
}
|
|
18
|
+
export interface PendingTodoItem {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
source: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function parsePendingTodoItems(todoContent: string, source?: string): PendingTodoItem[];
|
|
24
|
+
export declare function syncIssueState(prev: IssueState, pending: PendingTodoItem[], now?: string): {
|
|
25
|
+
state: IssueState;
|
|
26
|
+
added: number;
|
|
27
|
+
reopened: number;
|
|
28
|
+
autoClosed: number;
|
|
29
|
+
};
|
|
30
|
+
export declare function setIssueStatus(prev: IssueState, input: {
|
|
31
|
+
id: string;
|
|
32
|
+
status: string;
|
|
33
|
+
note?: string;
|
|
34
|
+
}, now?: string): IssueState;
|
|
35
|
+
export declare function createEmptyIssueState(now?: string): IssueState;
|
|
36
|
+
export declare function loadIssueState(filePath: string): Promise<IssueState>;
|
|
37
|
+
export declare function saveIssueState(filePath: string, state: IssueState): Promise<void>;
|
|
38
|
+
export declare function resolveIssueStatePath(input?: string): string;
|
|
39
|
+
export declare function resolveTodoPath(input?: string): string;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const ISSUE_STATUSES = ['open', 'in_progress', 'blocked', 'done'];
|
|
4
|
+
function nowIso(now) {
|
|
5
|
+
return now ?? new Date().toISOString();
|
|
6
|
+
}
|
|
7
|
+
function normalizeId(id) {
|
|
8
|
+
return String(id ?? '').trim();
|
|
9
|
+
}
|
|
10
|
+
function assertStatus(status) {
|
|
11
|
+
if (!ISSUE_STATUSES.includes(status)) {
|
|
12
|
+
throw new Error(`unsupported issue status: ${status}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function assertAllowedTransition(from, to) {
|
|
16
|
+
if (from === to)
|
|
17
|
+
return;
|
|
18
|
+
if (from === 'done' && to !== 'done') {
|
|
19
|
+
throw new Error('cannot transition issue from done to non-done status');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function parsePendingTodoItems(todoContent, source = 'todo.md') {
|
|
23
|
+
const lines = String(todoContent ?? '').split(/\r?\n/);
|
|
24
|
+
const items = [];
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const m = line.match(/^\s*-\s*\[\s\]\s+([A-Za-z0-9_-]+)\s+(.*)$/);
|
|
27
|
+
if (!m)
|
|
28
|
+
continue;
|
|
29
|
+
items.push({
|
|
30
|
+
id: normalizeId(m[1]),
|
|
31
|
+
title: m[2].trim(),
|
|
32
|
+
source,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return items;
|
|
36
|
+
}
|
|
37
|
+
export function syncIssueState(prev, pending, now) {
|
|
38
|
+
const timestamp = nowIso(now);
|
|
39
|
+
const byId = new Map(prev.issues.map((issue) => [issue.id, issue]));
|
|
40
|
+
const activeIds = new Set(pending.map((item) => item.id));
|
|
41
|
+
let added = 0;
|
|
42
|
+
let reopened = 0;
|
|
43
|
+
let autoClosed = 0;
|
|
44
|
+
for (const item of pending) {
|
|
45
|
+
const existing = byId.get(item.id);
|
|
46
|
+
if (!existing) {
|
|
47
|
+
byId.set(item.id, {
|
|
48
|
+
id: item.id,
|
|
49
|
+
title: item.title,
|
|
50
|
+
source: item.source,
|
|
51
|
+
status: 'open',
|
|
52
|
+
notes: ['synced from todo pending item'],
|
|
53
|
+
createdAt: timestamp,
|
|
54
|
+
updatedAt: timestamp,
|
|
55
|
+
});
|
|
56
|
+
added += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
existing.title = item.title;
|
|
60
|
+
existing.source = item.source;
|
|
61
|
+
existing.updatedAt = timestamp;
|
|
62
|
+
if (existing.status === 'done') {
|
|
63
|
+
existing.status = 'open';
|
|
64
|
+
existing.closedAt = undefined;
|
|
65
|
+
existing.notes.push('reopened by todo pending item');
|
|
66
|
+
reopened += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const issue of byId.values()) {
|
|
70
|
+
if (issue.source !== 'todo.md')
|
|
71
|
+
continue;
|
|
72
|
+
if (activeIds.has(issue.id))
|
|
73
|
+
continue;
|
|
74
|
+
if (issue.status === 'done')
|
|
75
|
+
continue;
|
|
76
|
+
issue.status = 'done';
|
|
77
|
+
issue.updatedAt = timestamp;
|
|
78
|
+
issue.closedAt = timestamp;
|
|
79
|
+
issue.notes.push('auto-closed because todo item is no longer pending');
|
|
80
|
+
autoClosed += 1;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
state: {
|
|
84
|
+
version: 1,
|
|
85
|
+
updatedAt: timestamp,
|
|
86
|
+
issues: Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id)),
|
|
87
|
+
},
|
|
88
|
+
added,
|
|
89
|
+
reopened,
|
|
90
|
+
autoClosed,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function setIssueStatus(prev, input, now) {
|
|
94
|
+
const issueId = normalizeId(input.id);
|
|
95
|
+
const timestamp = nowIso(now);
|
|
96
|
+
assertStatus(input.status);
|
|
97
|
+
const issue = prev.issues.find((item) => item.id === issueId);
|
|
98
|
+
if (!issue)
|
|
99
|
+
throw new Error(`issue not found: ${issueId}`);
|
|
100
|
+
assertAllowedTransition(issue.status, input.status);
|
|
101
|
+
issue.status = input.status;
|
|
102
|
+
issue.updatedAt = timestamp;
|
|
103
|
+
if (input.status === 'done')
|
|
104
|
+
issue.closedAt = timestamp;
|
|
105
|
+
if (input.status !== 'done')
|
|
106
|
+
issue.closedAt = undefined;
|
|
107
|
+
if (input.note && input.note.trim())
|
|
108
|
+
issue.notes.push(input.note.trim());
|
|
109
|
+
return {
|
|
110
|
+
version: 1,
|
|
111
|
+
updatedAt: timestamp,
|
|
112
|
+
issues: prev.issues,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export function createEmptyIssueState(now) {
|
|
116
|
+
return {
|
|
117
|
+
version: 1,
|
|
118
|
+
updatedAt: nowIso(now),
|
|
119
|
+
issues: [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export async function loadIssueState(filePath) {
|
|
123
|
+
try {
|
|
124
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
125
|
+
const parsed = JSON.parse(raw);
|
|
126
|
+
if (!parsed || !Array.isArray(parsed.issues)) {
|
|
127
|
+
throw new Error('invalid issue state file');
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
version: 1,
|
|
131
|
+
updatedAt: String(parsed.updatedAt ?? new Date(0).toISOString()),
|
|
132
|
+
issues: parsed.issues,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error?.code === 'ENOENT') {
|
|
137
|
+
return createEmptyIssueState();
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export async function saveIssueState(filePath, state) {
|
|
143
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
144
|
+
await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
|
|
145
|
+
}
|
|
146
|
+
export function resolveIssueStatePath(input) {
|
|
147
|
+
return input ? path.resolve(process.cwd(), input) : path.resolve(process.cwd(), 'docs/issues/state.json');
|
|
148
|
+
}
|
|
149
|
+
export function resolveTodoPath(input) {
|
|
150
|
+
return input ? path.resolve(process.cwd(), input) : path.resolve(process.cwd(), 'todo.md');
|
|
151
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { RUNTIME_CAPABILITIES } from '../shared/capabilities.js';
|
|
5
5
|
import { normalizeTranscriptMessages, parseProjectIdFromTranscriptPath, pickUsage } from '../shared/normalize.js';
|
|
6
|
+
const INFER_WINDOW_EPSILON_MS = 1200;
|
|
6
7
|
function parseTimestamp(entry) {
|
|
7
8
|
const raw = entry?.timestamp;
|
|
8
9
|
const date = raw ? new Date(raw) : new Date();
|
|
@@ -17,6 +18,150 @@ function normalizeIsSidechain(entry) {
|
|
|
17
18
|
function normalizeAgentId(entry) {
|
|
18
19
|
return entry?.agentId ?? entry?.agent_id ?? entry?.message?.agentId ?? entry?.message?.agent_id;
|
|
19
20
|
}
|
|
21
|
+
function extractToolUses(content) {
|
|
22
|
+
if (!Array.isArray(content))
|
|
23
|
+
return [];
|
|
24
|
+
return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_use');
|
|
25
|
+
}
|
|
26
|
+
function extractToolResults(content) {
|
|
27
|
+
if (!Array.isArray(content))
|
|
28
|
+
return [];
|
|
29
|
+
return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_result');
|
|
30
|
+
}
|
|
31
|
+
function isToolResultOnlyContent(content) {
|
|
32
|
+
return Array.isArray(content)
|
|
33
|
+
&& content.length > 0
|
|
34
|
+
&& content.every((block) => block && typeof block === 'object' && block.type === 'tool_result');
|
|
35
|
+
}
|
|
36
|
+
function isPromptUserMessage(message) {
|
|
37
|
+
if (!message || message.role !== 'user' || message.isMeta)
|
|
38
|
+
return false;
|
|
39
|
+
const { content } = message;
|
|
40
|
+
if (typeof content === 'string')
|
|
41
|
+
return content.trim().length > 0;
|
|
42
|
+
if (!Array.isArray(content))
|
|
43
|
+
return false;
|
|
44
|
+
if (isToolResultOnlyContent(content))
|
|
45
|
+
return false;
|
|
46
|
+
return content.length > 0;
|
|
47
|
+
}
|
|
48
|
+
function firstNonEmpty(values) {
|
|
49
|
+
for (const value of values) {
|
|
50
|
+
const text = String(value ?? '').trim();
|
|
51
|
+
if (text)
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
function findChildSessionHints(messages) {
|
|
57
|
+
const hasSidechainSignal = messages.some((m) => m?.isSidechain === true || String(m?.agentId ?? '').trim().length > 0);
|
|
58
|
+
if (!hasSidechainSignal)
|
|
59
|
+
return null;
|
|
60
|
+
const hasParentLink = messages.some((m) => String(m?.parentSessionId ?? m?.parent_session_id ?? '').trim().length > 0
|
|
61
|
+
|| String(m?.parentTurnId ?? m?.parent_turn_id ?? '').trim().length > 0
|
|
62
|
+
|| String(m?.parentToolCallId ?? m?.parent_tool_call_id ?? '').trim().length > 0);
|
|
63
|
+
if (hasParentLink)
|
|
64
|
+
return null;
|
|
65
|
+
const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
66
|
+
const childStartedAt = sorted[0]?.timestamp;
|
|
67
|
+
if (!childStartedAt)
|
|
68
|
+
return null;
|
|
69
|
+
return {
|
|
70
|
+
childStartedAt,
|
|
71
|
+
agentId: firstNonEmpty(messages.map((m) => m?.agentId)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function extractTaskWindows(messages, sessionId) {
|
|
75
|
+
const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
76
|
+
const windows = [];
|
|
77
|
+
const byCallId = new Map();
|
|
78
|
+
let turnIndex = 0;
|
|
79
|
+
let currentTurnId = 'turn-1';
|
|
80
|
+
for (const msg of sorted) {
|
|
81
|
+
if (isPromptUserMessage(msg)) {
|
|
82
|
+
turnIndex += 1;
|
|
83
|
+
currentTurnId = `turn-${turnIndex}`;
|
|
84
|
+
}
|
|
85
|
+
if (msg.role === 'assistant') {
|
|
86
|
+
for (const tu of extractToolUses(msg.content)) {
|
|
87
|
+
const toolName = String(tu.name ?? '').trim();
|
|
88
|
+
if (toolName !== 'Task')
|
|
89
|
+
continue;
|
|
90
|
+
const callId = String(tu.id ?? '').trim();
|
|
91
|
+
if (!callId)
|
|
92
|
+
continue;
|
|
93
|
+
const window = {
|
|
94
|
+
parentSessionId: sessionId,
|
|
95
|
+
parentTurnId: currentTurnId,
|
|
96
|
+
parentToolCallId: callId,
|
|
97
|
+
startedAtMs: msg.timestamp.getTime(),
|
|
98
|
+
endedAtMs: msg.timestamp.getTime(),
|
|
99
|
+
};
|
|
100
|
+
windows.push(window);
|
|
101
|
+
byCallId.set(callId, window);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (msg.role === 'user') {
|
|
105
|
+
for (const tr of extractToolResults(msg.content)) {
|
|
106
|
+
const callId = String(tr.tool_use_id ?? tr.toolUseId ?? '').trim();
|
|
107
|
+
if (!callId)
|
|
108
|
+
continue;
|
|
109
|
+
const window = byCallId.get(callId);
|
|
110
|
+
if (window) {
|
|
111
|
+
window.endedAtMs = Math.max(window.endedAtMs, msg.timestamp.getTime());
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return windows;
|
|
117
|
+
}
|
|
118
|
+
async function inferParentLinkFromSiblingSessions({ transcriptPath, messages }) {
|
|
119
|
+
const hints = findChildSessionHints(messages);
|
|
120
|
+
if (!hints)
|
|
121
|
+
return messages;
|
|
122
|
+
const currentSessionId = path.basename(transcriptPath, '.jsonl');
|
|
123
|
+
const dir = path.dirname(transcriptPath);
|
|
124
|
+
let entries = [];
|
|
125
|
+
try {
|
|
126
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return messages;
|
|
130
|
+
}
|
|
131
|
+
const candidates = [];
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
134
|
+
continue;
|
|
135
|
+
const siblingSessionId = entry.name.slice(0, -'.jsonl'.length);
|
|
136
|
+
if (siblingSessionId === currentSessionId)
|
|
137
|
+
continue;
|
|
138
|
+
const siblingPath = path.join(dir, entry.name);
|
|
139
|
+
const siblingMessages = await readClaudeTranscript(siblingPath);
|
|
140
|
+
const windows = extractTaskWindows(siblingMessages, siblingSessionId);
|
|
141
|
+
for (const window of windows) {
|
|
142
|
+
const childAtMs = hints.childStartedAt.getTime();
|
|
143
|
+
const lower = window.startedAtMs - INFER_WINDOW_EPSILON_MS;
|
|
144
|
+
const upper = window.endedAtMs + INFER_WINDOW_EPSILON_MS;
|
|
145
|
+
if (childAtMs < lower || childAtMs > upper)
|
|
146
|
+
continue;
|
|
147
|
+
candidates.push({
|
|
148
|
+
...window,
|
|
149
|
+
score: Math.abs(childAtMs - window.startedAtMs),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (candidates.length === 0)
|
|
154
|
+
return messages;
|
|
155
|
+
candidates.sort((a, b) => a.score - b.score);
|
|
156
|
+
const best = candidates[0];
|
|
157
|
+
return messages.map((msg) => ({
|
|
158
|
+
...msg,
|
|
159
|
+
parentSessionId: best.parentSessionId,
|
|
160
|
+
parentTurnId: best.parentTurnId,
|
|
161
|
+
parentToolCallId: best.parentToolCallId,
|
|
162
|
+
parentLinkConfidence: 'inferred',
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
20
165
|
async function readClaudeTranscript(transcriptPath) {
|
|
21
166
|
const raw = await readFile(transcriptPath, 'utf-8');
|
|
22
167
|
const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
@@ -29,6 +174,9 @@ async function readClaudeTranscript(transcriptPath) {
|
|
|
29
174
|
isMeta: entry.isMeta ?? false,
|
|
30
175
|
isSidechain: normalizeIsSidechain(entry),
|
|
31
176
|
agentId: normalizeAgentId(entry),
|
|
177
|
+
parentSessionId: entry?.parentSessionId ?? entry?.parent_session_id,
|
|
178
|
+
parentTurnId: entry?.parentTurnId ?? entry?.parent_turn_id,
|
|
179
|
+
parentToolCallId: entry?.parentToolCallId ?? entry?.parent_tool_call_id,
|
|
32
180
|
content: entry?.message?.content ?? entry.content ?? '',
|
|
33
181
|
model: entry?.message?.model ?? entry.model,
|
|
34
182
|
usage: pickUsage(entry?.message?.usage ?? entry?.usage ?? entry?.message_usage),
|
|
@@ -62,7 +210,8 @@ export const claudeCodeAdapter = {
|
|
|
62
210
|
async collectEvents(context) {
|
|
63
211
|
const transcriptPath = context.transcriptPath ??
|
|
64
212
|
path.join(process.env.HOME || '', '.claude', 'projects', context.projectId, `${context.sessionId}.jsonl`);
|
|
65
|
-
const
|
|
213
|
+
const loaded = await readClaudeTranscript(transcriptPath);
|
|
214
|
+
const messages = await inferParentLinkFromSiblingSessions({ transcriptPath, messages: loaded });
|
|
66
215
|
return normalizeTranscriptMessages({
|
|
67
216
|
runtime: 'claude-code',
|
|
68
217
|
projectId: context.projectId,
|
|
@@ -77,6 +77,21 @@ function usageFromTotals(start, end) {
|
|
|
77
77
|
total_tokens: total || input + output,
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
+
function extractPtySessionId(output) {
|
|
81
|
+
const text = String(output ?? '');
|
|
82
|
+
const match = text.match(/session ID\s+(\d+)/i);
|
|
83
|
+
return match ? match[1] : undefined;
|
|
84
|
+
}
|
|
85
|
+
function extractWallTimeMs(output) {
|
|
86
|
+
const text = String(output ?? '');
|
|
87
|
+
const match = text.match(/Wall time:\s*([0-9]+(?:\.[0-9]+)?)\s*seconds?/i);
|
|
88
|
+
if (!match)
|
|
89
|
+
return undefined;
|
|
90
|
+
const seconds = Number(match[1]);
|
|
91
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
92
|
+
return undefined;
|
|
93
|
+
return Math.floor(seconds * 1000);
|
|
94
|
+
}
|
|
80
95
|
function createTurn(turnId, startedAt) {
|
|
81
96
|
return {
|
|
82
97
|
turnId,
|
|
@@ -194,6 +209,7 @@ async function readCodexSession(transcriptPath) {
|
|
|
194
209
|
let sessionMeta = null;
|
|
195
210
|
let callCounter = 0;
|
|
196
211
|
const callIndex = new Map();
|
|
212
|
+
const ptyCallBySession = new Map();
|
|
197
213
|
function finalizeCurrentTurn(at) {
|
|
198
214
|
if (!currentTurn)
|
|
199
215
|
return;
|
|
@@ -323,6 +339,11 @@ async function readCodexSession(transcriptPath) {
|
|
|
323
339
|
const rawName = payload.name ?? payload.tool_name ?? payload.toolName;
|
|
324
340
|
const rawInput = payload.type === 'custom_tool_call' ? payload.input : payload.arguments;
|
|
325
341
|
const args = safeJsonParse(rawInput, typeof rawInput === 'string' ? { raw: rawInput } : {});
|
|
342
|
+
const ptySessionId = args.session_id != null ? String(args.session_id) : '';
|
|
343
|
+
if (String(rawName ?? '') === 'write_stdin' && ptySessionId && ptyCallBySession.has(ptySessionId)) {
|
|
344
|
+
callIndex.set(String(payload.call_id ?? payload.callId ?? `call-${callCounter}`), ptyCallBySession.get(ptySessionId));
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
326
347
|
const normalized = normalizeToolCall(rawName, args, callCounter);
|
|
327
348
|
const callId = String(payload.call_id ?? payload.callId ?? normalized.callId ?? `call-${callCounter}`);
|
|
328
349
|
const call = {
|
|
@@ -341,8 +362,22 @@ async function readCodexSession(transcriptPath) {
|
|
|
341
362
|
const callId = String(payload.call_id ?? payload.callId ?? '');
|
|
342
363
|
const call = callIndex.get(callId);
|
|
343
364
|
if (call) {
|
|
344
|
-
|
|
365
|
+
const output = String(payload.output ?? '');
|
|
366
|
+
if (output)
|
|
367
|
+
call.output = output;
|
|
345
368
|
call.endedAt = isoAt;
|
|
369
|
+
const wallTimeMs = extractWallTimeMs(output);
|
|
370
|
+
if (wallTimeMs) {
|
|
371
|
+
const derivedEndedAt = new Date(parseTimestamp(call.startedAt).getTime() + wallTimeMs).toISOString();
|
|
372
|
+
if (parseTimestamp(derivedEndedAt).getTime() > parseTimestamp(call.endedAt).getTime()) {
|
|
373
|
+
call.endedAt = derivedEndedAt;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (call.toolName === 'Bash') {
|
|
377
|
+
const ptySessionId = extractPtySessionId(output);
|
|
378
|
+
if (ptySessionId)
|
|
379
|
+
ptyCallBySession.set(ptySessionId, call);
|
|
380
|
+
}
|
|
346
381
|
}
|
|
347
382
|
continue;
|
|
348
383
|
}
|
|
@@ -398,11 +433,12 @@ function remapTurnIds(events, turns) {
|
|
|
398
433
|
function attachCwdAttribute(events, cwd) {
|
|
399
434
|
if (!cwd)
|
|
400
435
|
return events;
|
|
436
|
+
const sanitizedCwd = deriveProjectIdFromCwd(cwd);
|
|
401
437
|
return events.map((event) => ({
|
|
402
438
|
...event,
|
|
403
439
|
attributes: {
|
|
404
440
|
...(event.attributes ?? {}),
|
|
405
|
-
'agentic.project.cwd':
|
|
441
|
+
'agentic.project.cwd': sanitizedCwd,
|
|
406
442
|
},
|
|
407
443
|
}));
|
|
408
444
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
|
-
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { RUNTIME_CAPABILITIES } from '../shared/capabilities.js';
|
|
5
5
|
import { normalizeTranscriptMessages, parseProjectIdFromTranscriptPath, pickUsage } from '../shared/normalize.js';
|
|
6
|
+
const INFER_WINDOW_EPSILON_MS = 1200;
|
|
6
7
|
function parseTimestamp(entry) {
|
|
7
8
|
const raw = entry?.timestamp ?? entry?.created_at ?? entry?.createdAt;
|
|
8
9
|
const date = raw ? new Date(raw) : new Date();
|
|
@@ -140,6 +141,139 @@ function normalizeAgentId(entry) {
|
|
|
140
141
|
?? entry?.payload?.agentId
|
|
141
142
|
?? entry?.payload?.agent_id);
|
|
142
143
|
}
|
|
144
|
+
function extractToolUses(content) {
|
|
145
|
+
if (!Array.isArray(content))
|
|
146
|
+
return [];
|
|
147
|
+
return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_use');
|
|
148
|
+
}
|
|
149
|
+
function extractToolResults(content) {
|
|
150
|
+
if (!Array.isArray(content))
|
|
151
|
+
return [];
|
|
152
|
+
return content.filter((block) => block && typeof block === 'object' && block.type === 'tool_result');
|
|
153
|
+
}
|
|
154
|
+
function isToolResultOnlyContent(content) {
|
|
155
|
+
return Array.isArray(content)
|
|
156
|
+
&& content.length > 0
|
|
157
|
+
&& content.every((block) => block && typeof block === 'object' && block.type === 'tool_result');
|
|
158
|
+
}
|
|
159
|
+
function isPromptUserMessage(message) {
|
|
160
|
+
if (!message || message.role !== 'user' || message.isMeta)
|
|
161
|
+
return false;
|
|
162
|
+
const { content } = message;
|
|
163
|
+
if (typeof content === 'string')
|
|
164
|
+
return content.trim().length > 0;
|
|
165
|
+
if (!Array.isArray(content))
|
|
166
|
+
return false;
|
|
167
|
+
if (isToolResultOnlyContent(content))
|
|
168
|
+
return false;
|
|
169
|
+
return content.length > 0;
|
|
170
|
+
}
|
|
171
|
+
function findChildSessionHints(messages) {
|
|
172
|
+
const hasSidechainSignal = messages.some((m) => m?.isSidechain === true || String(m?.agentId ?? '').trim().length > 0);
|
|
173
|
+
if (!hasSidechainSignal)
|
|
174
|
+
return null;
|
|
175
|
+
const hasParentLink = messages.some((m) => String(m?.parentSessionId ?? m?.parent_session_id ?? '').trim().length > 0
|
|
176
|
+
|| String(m?.parentTurnId ?? m?.parent_turn_id ?? '').trim().length > 0
|
|
177
|
+
|| String(m?.parentToolCallId ?? m?.parent_tool_call_id ?? '').trim().length > 0);
|
|
178
|
+
if (hasParentLink)
|
|
179
|
+
return null;
|
|
180
|
+
const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
181
|
+
const childStartedAt = sorted[0]?.timestamp;
|
|
182
|
+
if (!childStartedAt)
|
|
183
|
+
return null;
|
|
184
|
+
return { childStartedAt };
|
|
185
|
+
}
|
|
186
|
+
function extractTaskWindows(messages, sessionId) {
|
|
187
|
+
const sorted = [...messages].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
188
|
+
const windows = [];
|
|
189
|
+
const byCallId = new Map();
|
|
190
|
+
let turnIndex = 0;
|
|
191
|
+
let currentTurnId = 'turn-1';
|
|
192
|
+
for (const msg of sorted) {
|
|
193
|
+
if (isPromptUserMessage(msg)) {
|
|
194
|
+
turnIndex += 1;
|
|
195
|
+
currentTurnId = `turn-${turnIndex}`;
|
|
196
|
+
}
|
|
197
|
+
if (msg.role === 'assistant') {
|
|
198
|
+
for (const tu of extractToolUses(msg.content)) {
|
|
199
|
+
const toolName = String(tu.name ?? '').trim();
|
|
200
|
+
if (toolName !== 'Task')
|
|
201
|
+
continue;
|
|
202
|
+
const callId = String(tu.id ?? '').trim();
|
|
203
|
+
if (!callId)
|
|
204
|
+
continue;
|
|
205
|
+
const window = {
|
|
206
|
+
parentSessionId: sessionId,
|
|
207
|
+
parentTurnId: currentTurnId,
|
|
208
|
+
parentToolCallId: callId,
|
|
209
|
+
startedAtMs: msg.timestamp.getTime(),
|
|
210
|
+
endedAtMs: msg.timestamp.getTime(),
|
|
211
|
+
};
|
|
212
|
+
windows.push(window);
|
|
213
|
+
byCallId.set(callId, window);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (msg.role === 'user') {
|
|
217
|
+
for (const tr of extractToolResults(msg.content)) {
|
|
218
|
+
const callId = String(tr.tool_use_id ?? tr.toolUseId ?? '').trim();
|
|
219
|
+
if (!callId)
|
|
220
|
+
continue;
|
|
221
|
+
const window = byCallId.get(callId);
|
|
222
|
+
if (window) {
|
|
223
|
+
window.endedAtMs = Math.max(window.endedAtMs, msg.timestamp.getTime());
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return windows;
|
|
229
|
+
}
|
|
230
|
+
async function inferParentLinkFromSiblingSessions({ transcriptPath, messages }) {
|
|
231
|
+
const hints = findChildSessionHints(messages);
|
|
232
|
+
if (!hints)
|
|
233
|
+
return messages;
|
|
234
|
+
const currentSessionId = path.basename(transcriptPath, '.jsonl');
|
|
235
|
+
const dir = path.dirname(transcriptPath);
|
|
236
|
+
let entries = [];
|
|
237
|
+
try {
|
|
238
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return messages;
|
|
242
|
+
}
|
|
243
|
+
const candidates = [];
|
|
244
|
+
for (const entry of entries) {
|
|
245
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
246
|
+
continue;
|
|
247
|
+
const siblingSessionId = entry.name.slice(0, -'.jsonl'.length);
|
|
248
|
+
if (siblingSessionId === currentSessionId)
|
|
249
|
+
continue;
|
|
250
|
+
const siblingPath = path.join(dir, entry.name);
|
|
251
|
+
const siblingMessages = await readOpenclawTranscript(siblingPath);
|
|
252
|
+
const windows = extractTaskWindows(siblingMessages, siblingSessionId);
|
|
253
|
+
for (const window of windows) {
|
|
254
|
+
const childAtMs = hints.childStartedAt.getTime();
|
|
255
|
+
const lower = window.startedAtMs - INFER_WINDOW_EPSILON_MS;
|
|
256
|
+
const upper = window.endedAtMs + INFER_WINDOW_EPSILON_MS;
|
|
257
|
+
if (childAtMs < lower || childAtMs > upper)
|
|
258
|
+
continue;
|
|
259
|
+
candidates.push({
|
|
260
|
+
...window,
|
|
261
|
+
score: Math.abs(childAtMs - window.startedAtMs),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (candidates.length === 0)
|
|
266
|
+
return messages;
|
|
267
|
+
candidates.sort((a, b) => a.score - b.score);
|
|
268
|
+
const best = candidates[0];
|
|
269
|
+
return messages.map((msg) => ({
|
|
270
|
+
...msg,
|
|
271
|
+
parentSessionId: best.parentSessionId,
|
|
272
|
+
parentTurnId: best.parentTurnId,
|
|
273
|
+
parentToolCallId: best.parentToolCallId,
|
|
274
|
+
parentLinkConfidence: 'inferred',
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
143
277
|
async function readOpenclawTranscript(transcriptPath) {
|
|
144
278
|
const raw = await readFile(transcriptPath, 'utf-8');
|
|
145
279
|
const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
@@ -165,6 +299,9 @@ async function readOpenclawTranscript(transcriptPath) {
|
|
|
165
299
|
isMeta: entry?.isMeta ?? entry?.is_meta ?? false,
|
|
166
300
|
isSidechain: normalizeIsSidechain(entry),
|
|
167
301
|
agentId: normalizeAgentId(entry),
|
|
302
|
+
parentSessionId: entry?.parentSessionId ?? entry?.parent_session_id,
|
|
303
|
+
parentTurnId: entry?.parentTurnId ?? entry?.parent_turn_id,
|
|
304
|
+
parentToolCallId: entry?.parentToolCallId ?? entry?.parent_tool_call_id,
|
|
168
305
|
content: normalizeContent(entry),
|
|
169
306
|
model: normalizeModel(entry),
|
|
170
307
|
usage: normalizeUsage(entry),
|
|
@@ -231,7 +368,8 @@ export const openclawAdapter = {
|
|
|
231
368
|
},
|
|
232
369
|
async collectEvents(context) {
|
|
233
370
|
const transcriptPath = await resolveTranscriptPath(context);
|
|
234
|
-
const
|
|
371
|
+
const loaded = await readOpenclawTranscript(transcriptPath);
|
|
372
|
+
const messages = await inferParentLinkFromSiblingSessions({ transcriptPath, messages: loaded });
|
|
235
373
|
return normalizeTranscriptMessages({
|
|
236
374
|
runtime: 'openclaw',
|
|
237
375
|
projectId: context.projectId,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
type FileStat = {
|
|
2
|
+
mtimeMs: number;
|
|
3
|
+
};
|
|
4
|
+
type WaitForFileSettleOptions = {
|
|
5
|
+
filePath: string;
|
|
6
|
+
stableWindowMs?: number;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
pollMs?: number;
|
|
9
|
+
statFn?: (filePath: string) => Promise<FileStat>;
|
|
10
|
+
sleepFn?: (ms: number) => Promise<void>;
|
|
11
|
+
nowFn?: () => number;
|
|
12
|
+
};
|
|
13
|
+
type WaitForFileSettleResult = {
|
|
14
|
+
settled: boolean;
|
|
15
|
+
lastMtimeMs?: number;
|
|
16
|
+
waitedMs: number;
|
|
17
|
+
};
|
|
18
|
+
export declare function waitForFileMtimeToSettle(options: WaitForFileSettleOptions): Promise<WaitForFileSettleResult>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
function sleep(ms) {
|
|
3
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
}
|
|
5
|
+
export async function waitForFileMtimeToSettle(options) {
|
|
6
|
+
const stableWindowMs = options.stableWindowMs ?? 400;
|
|
7
|
+
const timeoutMs = options.timeoutMs ?? 2500;
|
|
8
|
+
const pollMs = options.pollMs ?? 100;
|
|
9
|
+
const statFn = options.statFn ?? (async (filePath) => stat(filePath));
|
|
10
|
+
const sleepFn = options.sleepFn ?? sleep;
|
|
11
|
+
const nowFn = options.nowFn ?? Date.now;
|
|
12
|
+
const startedAt = nowFn();
|
|
13
|
+
const deadline = startedAt + timeoutMs;
|
|
14
|
+
let lastMtimeMs;
|
|
15
|
+
let unchangedSinceMs;
|
|
16
|
+
while (nowFn() <= deadline) {
|
|
17
|
+
const current = await statFn(options.filePath);
|
|
18
|
+
const mtimeMs = Number(current.mtimeMs);
|
|
19
|
+
if (lastMtimeMs === mtimeMs) {
|
|
20
|
+
if (unchangedSinceMs === undefined)
|
|
21
|
+
unchangedSinceMs = nowFn();
|
|
22
|
+
if (nowFn() - unchangedSinceMs >= stableWindowMs) {
|
|
23
|
+
return {
|
|
24
|
+
settled: true,
|
|
25
|
+
lastMtimeMs: mtimeMs,
|
|
26
|
+
waitedMs: nowFn() - startedAt,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
lastMtimeMs = mtimeMs;
|
|
32
|
+
unchangedSinceMs = undefined;
|
|
33
|
+
}
|
|
34
|
+
const remainingMs = deadline - nowFn();
|
|
35
|
+
if (remainingMs <= 0)
|
|
36
|
+
break;
|
|
37
|
+
await sleepFn(Math.min(pollMs, remainingMs));
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
settled: false,
|
|
41
|
+
lastMtimeMs,
|
|
42
|
+
waitedMs: nowFn() - startedAt,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -210,6 +210,28 @@ function parseSlashCommand(text) {
|
|
|
210
210
|
args: plain[2] ? plain[2].trim() : '',
|
|
211
211
|
};
|
|
212
212
|
}
|
|
213
|
+
function parseBashCommandAttributes(commandLine) {
|
|
214
|
+
const raw = String(commandLine ?? '').trim();
|
|
215
|
+
if (!raw) {
|
|
216
|
+
return {
|
|
217
|
+
'agentic.command.name': '',
|
|
218
|
+
'agentic.command.args': '',
|
|
219
|
+
'agentic.command.pipe_count': 0,
|
|
220
|
+
'agentic.command.raw': '',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const segments = raw.split(/\|(?!\|)/);
|
|
224
|
+
const firstSegment = String(segments[0] ?? '').trim();
|
|
225
|
+
const tokens = firstSegment ? firstSegment.split(/\s+/) : [];
|
|
226
|
+
const name = String(tokens[0] ?? '').trim();
|
|
227
|
+
const args = tokens.length > 1 ? tokens.slice(1).join(' ') : '';
|
|
228
|
+
return {
|
|
229
|
+
'agentic.command.name': name,
|
|
230
|
+
'agentic.command.args': args,
|
|
231
|
+
'agentic.command.pipe_count': Math.max(segments.length - 1, 0),
|
|
232
|
+
'agentic.command.raw': raw,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
213
235
|
function isMcpToolName(name) {
|
|
214
236
|
const n = String(name || '').toLowerCase();
|
|
215
237
|
return n === 'mcp' || n.startsWith('mcp__') || n.startsWith('mcp-');
|
|
@@ -252,6 +274,55 @@ function actorHeuristic(messages) {
|
|
|
252
274
|
return { role: 'unknown', confidence: 0.6 };
|
|
253
275
|
return { role: 'main', confidence: 0.95 };
|
|
254
276
|
}
|
|
277
|
+
function firstNonEmptyString(values) {
|
|
278
|
+
for (const value of values) {
|
|
279
|
+
const text = String(value ?? '').trim();
|
|
280
|
+
if (text)
|
|
281
|
+
return text;
|
|
282
|
+
}
|
|
283
|
+
return '';
|
|
284
|
+
}
|
|
285
|
+
function inferParentLinkAttributes(messages) {
|
|
286
|
+
const agentId = firstNonEmptyString(messages.map((m) => m?.agentId ?? m?.agent_id ?? m?.message?.agentId ?? m?.message?.agent_id));
|
|
287
|
+
const parentSessionId = firstNonEmptyString(messages.map((m) => m?.parentSessionId
|
|
288
|
+
?? m?.parent_session_id
|
|
289
|
+
?? m?.parent?.sessionId
|
|
290
|
+
?? m?.parent?.session_id
|
|
291
|
+
?? m?.session_meta?.parent_session_id
|
|
292
|
+
?? m?.sessionMeta?.parentSessionId));
|
|
293
|
+
const parentTurnId = firstNonEmptyString(messages.map((m) => m?.parentTurnId
|
|
294
|
+
?? m?.parent_turn_id
|
|
295
|
+
?? m?.parent?.turnId
|
|
296
|
+
?? m?.parent?.turn_id
|
|
297
|
+
?? m?.session_meta?.parent_turn_id
|
|
298
|
+
?? m?.sessionMeta?.parentTurnId));
|
|
299
|
+
const parentToolCallId = firstNonEmptyString(messages.map((m) => m?.parentToolCallId
|
|
300
|
+
?? m?.parent_tool_call_id
|
|
301
|
+
?? m?.parent?.toolCallId
|
|
302
|
+
?? m?.parent?.tool_call_id
|
|
303
|
+
?? m?.session_meta?.parent_tool_call_id
|
|
304
|
+
?? m?.sessionMeta?.parentToolCallId));
|
|
305
|
+
const explicitConfidence = firstNonEmptyString(messages.map((m) => m?.parentLinkConfidence ?? m?.parent_link_confidence));
|
|
306
|
+
const attrs = {};
|
|
307
|
+
if (agentId)
|
|
308
|
+
attrs['agentic.agent_id'] = agentId;
|
|
309
|
+
if (parentSessionId)
|
|
310
|
+
attrs['agentic.parent.session_id'] = parentSessionId;
|
|
311
|
+
if (parentTurnId)
|
|
312
|
+
attrs['agentic.parent.turn_id'] = parentTurnId;
|
|
313
|
+
if (parentToolCallId)
|
|
314
|
+
attrs['agentic.parent.tool_call_id'] = parentToolCallId;
|
|
315
|
+
if (explicitConfidence) {
|
|
316
|
+
attrs['agentic.parent.link.confidence'] = explicitConfidence;
|
|
317
|
+
}
|
|
318
|
+
else if (parentSessionId || parentTurnId || parentToolCallId) {
|
|
319
|
+
attrs['agentic.parent.link.confidence'] = 'exact';
|
|
320
|
+
}
|
|
321
|
+
else if (agentId) {
|
|
322
|
+
attrs['agentic.parent.link.confidence'] = 'unknown';
|
|
323
|
+
}
|
|
324
|
+
return attrs;
|
|
325
|
+
}
|
|
255
326
|
function createTurn(messages, turnId, projectId, sessionId, runtime) {
|
|
256
327
|
const user = messages.find(isPromptUserMessage) ?? messages.find((m) => m.role === 'user' && !m.isMeta) ?? messages[0];
|
|
257
328
|
const assistantsRaw = messages.filter((m) => m.role === 'assistant');
|
|
@@ -283,6 +354,8 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
|
|
|
283
354
|
}
|
|
284
355
|
const usage = Object.keys(totalUsage).length ? totalUsage : undefined;
|
|
285
356
|
const actor = actorHeuristic(messages);
|
|
357
|
+
const parentLinkAttrs = inferParentLinkAttributes(messages);
|
|
358
|
+
const sharedAttrs = { ...runtimeAttrs, ...parentLinkAttrs };
|
|
286
359
|
const events = [
|
|
287
360
|
{
|
|
288
361
|
runtime,
|
|
@@ -299,7 +372,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
|
|
|
299
372
|
'agentic.event.category': 'turn',
|
|
300
373
|
'langfuse.observation.type': 'agent',
|
|
301
374
|
'gen_ai.operation.name': 'invoke_agent',
|
|
302
|
-
...
|
|
375
|
+
...sharedAttrs,
|
|
303
376
|
...modelAttributes(latestModel),
|
|
304
377
|
'agentic.actor.role': actor.role,
|
|
305
378
|
'agentic.actor.role_confidence': actor.confidence,
|
|
@@ -348,7 +421,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
|
|
|
348
421
|
attributes: {
|
|
349
422
|
'agentic.event.category': isMcp ? 'mcp' : 'agent_command',
|
|
350
423
|
'langfuse.observation.type': isMcp ? 'tool' : 'event',
|
|
351
|
-
...
|
|
424
|
+
...sharedAttrs,
|
|
352
425
|
'agentic.command.name': slash.name,
|
|
353
426
|
'agentic.command.args': slash.args,
|
|
354
427
|
'gen_ai.operation.name': isMcp ? 'execute_tool' : 'invoke_agent',
|
|
@@ -377,7 +450,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
|
|
|
377
450
|
attributes: {
|
|
378
451
|
'agentic.event.category': 'reasoning',
|
|
379
452
|
'langfuse.observation.type': 'span',
|
|
380
|
-
...
|
|
453
|
+
...sharedAttrs,
|
|
381
454
|
'gen_ai.operation.name': 'invoke_agent',
|
|
382
455
|
...modelAttributes(assistant.model),
|
|
383
456
|
},
|
|
@@ -408,8 +481,9 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
|
|
|
408
481
|
attributes: {
|
|
409
482
|
'agentic.event.category': 'shell_command',
|
|
410
483
|
'langfuse.observation.type': 'tool',
|
|
411
|
-
...
|
|
484
|
+
...sharedAttrs,
|
|
412
485
|
'process.command_line': commandLine,
|
|
486
|
+
...parseBashCommandAttributes(commandLine),
|
|
413
487
|
'gen_ai.tool.name': 'Bash',
|
|
414
488
|
'gen_ai.tool.call.id': toolId,
|
|
415
489
|
'gen_ai.operation.name': 'execute_tool',
|
|
@@ -435,7 +509,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
|
|
|
435
509
|
attributes: {
|
|
436
510
|
'agentic.event.category': 'mcp',
|
|
437
511
|
'langfuse.observation.type': 'tool',
|
|
438
|
-
...
|
|
512
|
+
...sharedAttrs,
|
|
439
513
|
'gen_ai.tool.name': toolName,
|
|
440
514
|
'mcp.request.id': toolId,
|
|
441
515
|
'gen_ai.operation.name': 'execute_tool',
|
|
@@ -461,7 +535,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
|
|
|
461
535
|
attributes: {
|
|
462
536
|
'agentic.event.category': 'agent_task',
|
|
463
537
|
'langfuse.observation.type': 'agent',
|
|
464
|
-
...
|
|
538
|
+
...sharedAttrs,
|
|
465
539
|
'gen_ai.tool.name': 'Task',
|
|
466
540
|
'gen_ai.tool.call.id': toolId,
|
|
467
541
|
'gen_ai.operation.name': 'invoke_agent',
|
|
@@ -486,7 +560,7 @@ function createTurn(messages, turnId, projectId, sessionId, runtime) {
|
|
|
486
560
|
attributes: {
|
|
487
561
|
'agentic.event.category': 'tool',
|
|
488
562
|
'langfuse.observation.type': 'tool',
|
|
489
|
-
...
|
|
563
|
+
...sharedAttrs,
|
|
490
564
|
'gen_ai.tool.name': toolName,
|
|
491
565
|
'gen_ai.tool.call.id': toolId,
|
|
492
566
|
'gen_ai.operation.name': 'execute_tool',
|