fuli-context 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -43
- package/README.zh-CN.md +14 -40
- package/dist/web/assets/{ProjectAgentsPage-CQemHFxy.js → ProjectAgentsPage-CPgKVLgg.js} +1 -1
- package/dist/web/assets/{ProjectAgentsPage-BgQLIpxg.css → ProjectAgentsPage-DeuRkMp1.css} +1 -1
- package/dist/web/assets/RoundtablesPage-C9dXfWCl.js +2 -0
- package/dist/web/assets/{index-UygXwZDZ.js → index-ulISYH02.js} +3 -3
- package/dist/web/index.html +1 -1
- package/docs/roundtable.md +46 -0
- package/graph-provider/pyproject.toml +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -4
- package/skills/capturing-session-knowledge/SKILL.md +1 -1
- package/src/agents/claude-code/lifecycle-hook.js +6 -19
- package/src/agents/codex/lifecycle-hook.js +51 -27
- package/src/agents/codex/token-usage.js +235 -0
- package/src/agents/cursor/lifecycle-hook.js +3 -9
- package/src/agents/token-usage-resolver.js +147 -0
- package/src/graphiti/federated-application.js +5 -1
- package/src/graphiti/project-agent-mapping.js +1 -1
- package/src/graphiti/project-agent-workflows.js +6 -1
- package/src/mcp/instructions.js +1 -1
- package/src/mcp/session-id.js +1 -1
- package/src/mcp/tool-annotations.js +3 -1
- package/src/roundtables/domain.js +3 -3
- package/src/roundtables/http-router.js +5 -3
- package/src/roundtables/mcp-server.js +15 -8
- package/src/roundtables/profiles.js +84 -0
- package/src/roundtables/service.js +86 -2
- package/src/roundtables/store.js +4 -0
- package/src/roundtables/tool-contract.js +22 -5
- package/dist/web/assets/RoundtablesPage-ByVeZlgC.js +0 -2
- package/docs/roundtable-beta-testing.md +0 -102
- package/docs/roundtable-beta-validation.md +0 -133
- package/docs/roundtable-research.md +0 -142
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fuli-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Local-first collaboration relationship graph for AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -14,9 +14,7 @@
|
|
|
14
14
|
"docs/agent-conversations-and-collaboration.md",
|
|
15
15
|
"docs/employee-agents.md",
|
|
16
16
|
"docs/agent-interface-architecture.md",
|
|
17
|
-
"docs/roundtable
|
|
18
|
-
"docs/roundtable-beta-validation.md",
|
|
19
|
-
"docs/roundtable-research.md",
|
|
17
|
+
"docs/roundtable.md",
|
|
20
18
|
"examples/external-knowledge/markdown-folder.mjs",
|
|
21
19
|
"graph-provider/Dockerfile",
|
|
22
20
|
"graph-provider/pyproject.toml",
|
|
@@ -288,7 +288,7 @@ worker session (`workerRuntime.sessionId`, or a worker session link only when su
|
|
|
288
288
|
Label `sourceApplication` and `sourceSessionId` separately as the reporting host. They do not
|
|
289
289
|
identify an external worker; do not substitute them for missing worker evidence.
|
|
290
290
|
Use only the source-labelled cumulative `tokenUsage` reported for that worker. If it is absent,
|
|
291
|
-
show
|
|
291
|
+
show `-`; never estimate it or copy a conversation-wide total into a worker row. If no
|
|
292
292
|
worker session link exists, show `workerRuntime.sessionId` or `not reported`; never invent a link. This applies only
|
|
293
293
|
to Provider-reported actual workers. When empty `executionSummary` is returned, omit rows; an absent
|
|
294
294
|
`executionSummary` also omits rows. Configured, allowed, or available clients/executors are not evidence
|
|
@@ -37,19 +37,7 @@ export async function claudeLifecycleOutput(event, input, invoke) {
|
|
|
37
37
|
const check = await invoke('verify_task_checkpoint', { sessionId, ...source });
|
|
38
38
|
const reason = boundedHookMessage(check?.reason);
|
|
39
39
|
if (check?.decision !== 'block' || !reason.trim()) return {};
|
|
40
|
-
|
|
41
|
-
if (typeof check.task_context_token === 'string' && check.task_context_token.trim()) {
|
|
42
|
-
await invoke('checkpoint_task_knowledge', {
|
|
43
|
-
taskContextToken: check.task_context_token,
|
|
44
|
-
disposition: 'retain_nothing',
|
|
45
|
-
reason: 'Claude Code Stop hook fallback after one checkpoint continuation.',
|
|
46
|
-
workLog: { status: 'incomplete', summary: 'The host stopped before the employee submitted a work summary. Review the prior task before resuming.' },
|
|
47
|
-
...source
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
return {};
|
|
51
|
-
}
|
|
52
|
-
return { decision: 'block', reason };
|
|
40
|
+
return { systemMessage: reason };
|
|
53
41
|
}
|
|
54
42
|
|
|
55
43
|
export function claudeLifecycleUnavailable(event) {
|
|
@@ -80,7 +68,7 @@ export async function runClaudeLifecycleHook(args, dependencies = {}) {
|
|
|
80
68
|
let sync = await syncConversationTranscript(app, input, 'claude_code', {
|
|
81
69
|
normalize: normalizeClaudeRecord, verify: verifyClaudeTranscript
|
|
82
70
|
}, guard);
|
|
83
|
-
if (event === 'UserPromptSubmit' && sync.entryBlocked) return {
|
|
71
|
+
if (event === 'UserPromptSubmit' && sync.entryBlocked) return { systemMessage: boundedHookMessage(sync.reason) };
|
|
84
72
|
const entryGuard = sync.status === 'capture_disabled' ? null : guard;
|
|
85
73
|
if (event === 'UserPromptSubmit' && entryGuard?.value) await entryGuard.write({ ...entryGuard.value, phase: 'beginning' });
|
|
86
74
|
const output = await claudeLifecycleOutput(event, input, async (name, parameters) => {
|
|
@@ -91,12 +79,12 @@ export async function runClaudeLifecycleHook(args, dependencies = {}) {
|
|
|
91
79
|
if (event === 'UserPromptSubmit') sync = await claimTranscriptBoundary(app, input, 'claude_code', sync, {
|
|
92
80
|
normalize: normalizeClaudeRecord, verify: verifyClaudeTranscript
|
|
93
81
|
}, entryGuard);
|
|
94
|
-
if (event === 'UserPromptSubmit' && sync.entryBlocked) return {
|
|
82
|
+
if (event === 'UserPromptSubmit' && sync.entryBlocked) return { systemMessage: boundedHookMessage(sync.reason) };
|
|
95
83
|
return withTranscriptNotice(output, sync);
|
|
96
84
|
});
|
|
97
85
|
} catch (error) {
|
|
98
86
|
if (event !== 'UserPromptSubmit' || !app.getCapturePolicy?.().enabled || !input.transcript_path) throw error;
|
|
99
|
-
return {
|
|
87
|
+
return { systemMessage: error.code === 'EADDRINUSE'
|
|
100
88
|
? 'The session handoff lock is occupied. Retry after the current hook finishes; if it persists, start a new host session and resume the intended Agent.'
|
|
101
89
|
: 'Fuli transcript handoff could not be completed safely. Retry task entry; no previous Agent context was supplied.' };
|
|
102
90
|
}
|
|
@@ -127,7 +115,6 @@ async function main() {
|
|
|
127
115
|
? configured : 28000;
|
|
128
116
|
const controller = new AbortController();
|
|
129
117
|
let emitted = false;
|
|
130
|
-
let guarded = false;
|
|
131
118
|
const emit = (output, callback) => {
|
|
132
119
|
if (emitted) return;
|
|
133
120
|
emitted = true;
|
|
@@ -137,11 +124,11 @@ async function main() {
|
|
|
137
124
|
// No prompt, transcript, path or raw exception is written to diagnostics.
|
|
138
125
|
const deadline = setTimeout(() => {
|
|
139
126
|
controller.abort();
|
|
140
|
-
emit(
|
|
127
|
+
emit(claudeLifecycleUnavailable(event), () => process.exit(0));
|
|
141
128
|
}, timeoutMs);
|
|
142
129
|
const abort = setTimeout(() => controller.abort(), Math.max(1, timeoutMs - 1500));
|
|
143
130
|
try {
|
|
144
|
-
const output = await runClaudeLifecycleHook(args, { signal: controller.signal
|
|
131
|
+
const output = await runClaudeLifecycleHook(args, { signal: controller.signal });
|
|
145
132
|
emit(output);
|
|
146
133
|
} catch {
|
|
147
134
|
emit(claudeLifecycleUnavailable(event));
|
|
@@ -26,20 +26,9 @@ export async function codexStopLifecycleOutput(input, invoke) {
|
|
|
26
26
|
if (check?.decision !== 'block' || typeof check.reason !== 'string' || !check.reason.trim()) {
|
|
27
27
|
return {};
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
taskContextToken: check.task_context_token,
|
|
33
|
-
disposition: 'retain_nothing',
|
|
34
|
-
reason: 'Codex Stop hook fallback after one checkpoint continuation.',
|
|
35
|
-
workLog: { status: 'incomplete', summary: 'The host stopped before the employee submitted a work summary. Review the prior task before resuming.' },
|
|
36
|
-
sourceApplication: 'codex',
|
|
37
|
-
sourceSessionId: sessionId
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
return {};
|
|
41
|
-
}
|
|
42
|
-
return { decision: 'block', reason: boundedHookMessage(check.reason) };
|
|
29
|
+
// Checkpoints are advisory to the host. Never force another model turn or
|
|
30
|
+
// fabricate a work log merely because the user is ending the conversation.
|
|
31
|
+
return { systemMessage: boundedHookMessage(check.reason) };
|
|
43
32
|
}
|
|
44
33
|
|
|
45
34
|
export async function codexLifecycleOutput(event, input, invoke) {
|
|
@@ -73,6 +62,9 @@ export async function runCodexLifecycleHook(args, dependencies = {}) {
|
|
|
73
62
|
);
|
|
74
63
|
let app;
|
|
75
64
|
let leases;
|
|
65
|
+
let closing;
|
|
66
|
+
const cleanup = () => closing ??= Promise.allSettled([closeQuietly(leases), closeQuietly(app)]);
|
|
67
|
+
dependencies.onCleanup?.(cleanup);
|
|
76
68
|
try {
|
|
77
69
|
app = (dependencies.openApplication ?? openFederatedGraphApplication)({
|
|
78
70
|
runtimeConfigPath
|
|
@@ -94,32 +86,31 @@ export async function runCodexLifecycleHook(args, dependencies = {}) {
|
|
|
94
86
|
// Existing guards and all later failures remain fail-closed.
|
|
95
87
|
return { systemMessage: `Fuli: ${sync.reason}. Message submission is allowed; no saved Agent context was loaded or changed.` };
|
|
96
88
|
}
|
|
97
|
-
if (event === 'UserPromptSubmit' && sync.entryBlocked) return {
|
|
89
|
+
if (event === 'UserPromptSubmit' && sync.entryBlocked) return { systemMessage: boundedHookMessage(sync.reason) };
|
|
98
90
|
const entryGuard = sync.status === 'capture_disabled' ? null : guard;
|
|
99
91
|
if (event === 'UserPromptSubmit' && entryGuard?.value) await entryGuard.write({ ...entryGuard.value, phase: 'beginning' });
|
|
100
92
|
const result = await codexLifecycleOutput(event, input, async (name, parameters) => {
|
|
101
|
-
const context = await invokeTool(app, name, parameters);
|
|
93
|
+
const context = await invokeTool(app, name, parameters, { signal: dependencies.signal });
|
|
102
94
|
if (name === 'begin_task_context') await recordTranscriptTaskEntry(entryGuard, context);
|
|
103
95
|
return context;
|
|
104
96
|
});
|
|
105
97
|
if (event === 'UserPromptSubmit') sync = await claimTranscriptBoundary(app, input, 'codex', sync, {
|
|
106
98
|
normalize: normalizeCodexRecord, verify: verifyCodexTranscript
|
|
107
99
|
}, entryGuard);
|
|
108
|
-
if (event === 'UserPromptSubmit' && sync.entryBlocked) return {
|
|
100
|
+
if (event === 'UserPromptSubmit' && sync.entryBlocked) return { systemMessage: boundedHookMessage(sync.reason) };
|
|
109
101
|
return withTranscriptNotice(result, sync);
|
|
110
102
|
});
|
|
111
103
|
} catch (error) {
|
|
112
104
|
if (event !== 'UserPromptSubmit' || !app.getCapturePolicy?.().enabled || !input.transcript_path) throw error;
|
|
113
|
-
return {
|
|
105
|
+
return { systemMessage: error.code === 'EADDRINUSE'
|
|
114
106
|
? 'The session handoff lock is occupied. Retry after the current hook finishes; if it persists, start a new host session and resume the intended Agent.'
|
|
115
107
|
: 'Fuli transcript handoff could not be completed safely. Retry task entry; no previous Agent context was supplied.' };
|
|
116
108
|
}
|
|
117
|
-
});
|
|
109
|
+
}, { signal: dependencies.signal });
|
|
118
110
|
write(`${JSON.stringify(output)}\n`);
|
|
119
111
|
return output;
|
|
120
112
|
} finally {
|
|
121
|
-
await
|
|
122
|
-
await closeQuietly(app);
|
|
113
|
+
await cleanup();
|
|
123
114
|
}
|
|
124
115
|
}
|
|
125
116
|
|
|
@@ -142,13 +133,46 @@ async function readHookInput() {
|
|
|
142
133
|
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
143
134
|
}
|
|
144
135
|
|
|
145
|
-
async function
|
|
136
|
+
export async function runCodexLifecycleCommand(args, dependencies = {}) {
|
|
137
|
+
const configured = Number(args[args.indexOf('--timeout-ms') + 1]);
|
|
138
|
+
const timeoutMs = args.includes('--timeout-ms') && Number.isSafeInteger(configured) && configured > 0
|
|
139
|
+
? Math.min(configured, 5000) : 5000;
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
let emitted = false;
|
|
142
|
+
const write = value => {
|
|
143
|
+
if (emitted) return;
|
|
144
|
+
emitted = true;
|
|
145
|
+
(dependencies.write ?? (value => process.stdout.write(value)))(value);
|
|
146
|
+
};
|
|
147
|
+
const unavailable = JSON.stringify({ systemMessage: 'Fuli context is unavailable. Continue with the user request; saved context and checkpoint were not verified.' }) + '\n';
|
|
148
|
+
let cleanup = async () => {};
|
|
149
|
+
let deadline;
|
|
150
|
+
const expired = new Promise(resolve => {
|
|
151
|
+
deadline = setTimeout(async () => {
|
|
152
|
+
controller.abort();
|
|
153
|
+
write(unavailable);
|
|
154
|
+
// A broken dependency must not hold the host open. Give opened leases
|
|
155
|
+
// and connections a bounded chance to close before the process exits.
|
|
156
|
+
let grace;
|
|
157
|
+
await Promise.race([cleanup(), new Promise(done => { grace = setTimeout(done, 250); })]);
|
|
158
|
+
clearTimeout(grace);
|
|
159
|
+
resolve({ timedOut: true });
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
});
|
|
146
162
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
163
|
+
const running = runCodexLifecycleHook(args, { ...dependencies, signal: controller.signal, write,
|
|
164
|
+
onCleanup: close => { cleanup = close; } }).then(() => ({ timedOut: false }), () => {
|
|
165
|
+
// Never print prompts, transcripts, paths or credentials.
|
|
166
|
+
write(unavailable);
|
|
167
|
+
return { timedOut: false };
|
|
168
|
+
});
|
|
169
|
+
return await Promise.race([running, expired]);
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(deadline);
|
|
151
172
|
}
|
|
152
173
|
}
|
|
153
174
|
|
|
154
|
-
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url))
|
|
175
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
176
|
+
const result = await runCodexLifecycleCommand(process.argv.slice(2));
|
|
177
|
+
if (result.timedOut) process.exit(0);
|
|
178
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, opendir, open } from 'node:fs/promises';
|
|
3
|
+
import { basename, extname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MAX_HEADER_BYTES = 64 * 1024;
|
|
6
|
+
const MAX_USAGE_TAIL_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
const MAX_CANDIDATE_FILES = 128;
|
|
8
|
+
const MAX_DIRECTORY_ENTRIES = 2048;
|
|
9
|
+
const MAX_DIRECTORY_DEPTH = 8;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Codex's native JSONL session adapter. It returns only the latest cumulative
|
|
13
|
+
* token_count snapshot and never returns transcript records or text.
|
|
14
|
+
*/
|
|
15
|
+
export function createCodexTokenUsageResolver({
|
|
16
|
+
sessionsDirectory = undefined,
|
|
17
|
+
archivedSessionsDirectory = undefined,
|
|
18
|
+
codexSessionsDirectory = undefined,
|
|
19
|
+
codexArchivedSessionsDirectory = undefined,
|
|
20
|
+
codexHomeDirectory = undefined,
|
|
21
|
+
homeDirectory = undefined
|
|
22
|
+
} = {}) {
|
|
23
|
+
const explicitSessionsDirectory = sessionsDirectory ?? codexSessionsDirectory;
|
|
24
|
+
const explicitArchivedSessionsDirectory =
|
|
25
|
+
archivedSessionsDirectory ?? codexArchivedSessionsDirectory;
|
|
26
|
+
const home = typeof homeDirectory === 'string' && homeDirectory
|
|
27
|
+
? homeDirectory : defaultHomeDirectory();
|
|
28
|
+
const codexHome = typeof codexHomeDirectory === 'string' && codexHomeDirectory
|
|
29
|
+
? codexHomeDirectory
|
|
30
|
+
: homeDirectory === undefined ? (defaultCodexHomeDirectory() ?? (home ? join(home, '.codex') : null))
|
|
31
|
+
: (home ? join(home, '.codex') : null);
|
|
32
|
+
const useDefaultRoots = explicitSessionsDirectory === undefined &&
|
|
33
|
+
explicitArchivedSessionsDirectory === undefined;
|
|
34
|
+
const roots = [
|
|
35
|
+
explicitSessionsDirectory ?? (useDefaultRoots && codexHome ? join(codexHome, 'sessions') : null),
|
|
36
|
+
explicitArchivedSessionsDirectory ??
|
|
37
|
+
(useDefaultRoots && codexHome ? join(codexHome, 'archived_sessions') : null)
|
|
38
|
+
]
|
|
39
|
+
.filter((root, index, values) => typeof root === 'string' && root && values.indexOf(root) === index);
|
|
40
|
+
return {
|
|
41
|
+
resolve: (request) => resolveCodexTokenUsage(request, { roots })
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function resolveCodexTokenUsage(request = {}, { roots } = {}) {
|
|
46
|
+
const sessionId = safeSessionId(request.sessionId ?? request.session_id);
|
|
47
|
+
if (!sessionId) return null;
|
|
48
|
+
const configuredRoots = Array.isArray(roots) ? roots : roots ? [roots] : null;
|
|
49
|
+
const directories = (configuredRoots ?? [
|
|
50
|
+
defaultCodexSessionsDirectory(),
|
|
51
|
+
defaultCodexArchivedSessionsDirectory()
|
|
52
|
+
]).filter((root, index, values) => typeof root === 'string' && root && values.indexOf(root) === index);
|
|
53
|
+
|
|
54
|
+
let inspectedEntries = 0;
|
|
55
|
+
let candidates = 0;
|
|
56
|
+
for (const root of directories) {
|
|
57
|
+
const files = await matchingSessionFiles(root, sessionId, {
|
|
58
|
+
onEntry: () => { inspectedEntries += 1; },
|
|
59
|
+
get entriesInspected() { return inspectedEntries; },
|
|
60
|
+
get candidatesFound() { return candidates; },
|
|
61
|
+
addCandidate: () => { candidates += 1; }
|
|
62
|
+
});
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
const usage = await readSessionUsage(file, sessionId);
|
|
65
|
+
if (usage) return usage;
|
|
66
|
+
}
|
|
67
|
+
if (inspectedEntries >= MAX_DIRECTORY_ENTRIES || candidates >= MAX_CANDIDATE_FILES) break;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function matchingSessionFiles(root, sessionId, counters, depth = 0) {
|
|
73
|
+
if (depth > MAX_DIRECTORY_DEPTH || counters.entriesInspected >= MAX_DIRECTORY_ENTRIES ||
|
|
74
|
+
counters.candidatesFound >= MAX_CANDIDATE_FILES) return [];
|
|
75
|
+
let directory;
|
|
76
|
+
try {
|
|
77
|
+
const rootStat = await lstat(root);
|
|
78
|
+
if (!rootStat.isDirectory()) return [];
|
|
79
|
+
directory = await opendir(root);
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const files = [];
|
|
85
|
+
try {
|
|
86
|
+
for await (const entry of directory) {
|
|
87
|
+
counters.onEntry();
|
|
88
|
+
if (counters.entriesInspected > MAX_DIRECTORY_ENTRIES ||
|
|
89
|
+
counters.candidatesFound >= MAX_CANDIDATE_FILES) break;
|
|
90
|
+
const path = join(root, entry.name);
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
files.push(...await matchingSessionFiles(path, sessionId, counters, depth + 1));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!entry.isFile() || extname(entry.name) !== '.jsonl' ||
|
|
96
|
+
!basename(entry.name).includes(sessionId)) continue;
|
|
97
|
+
counters.addCandidate();
|
|
98
|
+
files.push(path);
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// A session directory can rotate while the host is running. An incomplete
|
|
102
|
+
// scan is an unavailable snapshot, never a reason to guess another file.
|
|
103
|
+
} finally {
|
|
104
|
+
await directory.close().catch(() => {});
|
|
105
|
+
}
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function readSessionUsage(filePath, sessionId) {
|
|
110
|
+
let file;
|
|
111
|
+
try {
|
|
112
|
+
file = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
113
|
+
const stat = await file.stat();
|
|
114
|
+
if (!stat.isFile() || stat.size <= 0) return null;
|
|
115
|
+
|
|
116
|
+
const header = await readBytes(file, Math.min(MAX_HEADER_BYTES, stat.size), 0);
|
|
117
|
+
const firstLineEnd = header.indexOf(10);
|
|
118
|
+
if (firstLineEnd < 0 || !isSessionHeader(header.subarray(0, firstLineEnd), sessionId)) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const start = Math.max(0, stat.size - MAX_USAGE_TAIL_BYTES);
|
|
123
|
+
const tail = await readBytes(file, stat.size - start, start);
|
|
124
|
+
let firstLine = 0;
|
|
125
|
+
if (start > 0) {
|
|
126
|
+
const previous = await readBytes(file, 1, start - 1);
|
|
127
|
+
if (previous[0] !== 10) {
|
|
128
|
+
firstLine = tail.indexOf(10) + 1;
|
|
129
|
+
if (firstLine <= 0) return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return latestTokenUsage(tail.subarray(firstLine));
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
} finally {
|
|
136
|
+
await file?.close().catch(() => {});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readBytes(file, length, position) {
|
|
141
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
142
|
+
let offset = 0;
|
|
143
|
+
while (offset < length) {
|
|
144
|
+
const result = await file.read(buffer, offset, length - offset, position + offset);
|
|
145
|
+
if (!result.bytesRead) break;
|
|
146
|
+
offset += result.bytesRead;
|
|
147
|
+
}
|
|
148
|
+
return offset === length ? buffer : buffer.subarray(0, offset);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isSessionHeader(bytes, sessionId) {
|
|
152
|
+
try {
|
|
153
|
+
const record = JSON.parse(bytes.toString('utf8'));
|
|
154
|
+
return record?.type === 'session_meta' && record.payload?.id === sessionId;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function latestTokenUsage(bytes) {
|
|
161
|
+
let latest = null;
|
|
162
|
+
const text = bytes.toString('utf8');
|
|
163
|
+
let offset = 0;
|
|
164
|
+
while (offset < text.length) {
|
|
165
|
+
const end = text.indexOf('\n', offset);
|
|
166
|
+
const line = text.slice(offset, end < 0 ? text.length : end).trim();
|
|
167
|
+
if (line.includes('"token_count"') && line.includes('"total_token_usage"')) {
|
|
168
|
+
try {
|
|
169
|
+
const record = JSON.parse(line);
|
|
170
|
+
const total = record?.payload?.info?.total_token_usage;
|
|
171
|
+
if (record?.type === 'event_msg' && record.payload?.type === 'token_count') {
|
|
172
|
+
const totalTokens = nonNegativeInteger(total?.total_tokens);
|
|
173
|
+
if (totalTokens !== null) {
|
|
174
|
+
latest = {
|
|
175
|
+
source: 'host',
|
|
176
|
+
totalTokens,
|
|
177
|
+
inputTokens: optionalNonNegativeInteger(total?.input_tokens),
|
|
178
|
+
outputTokens: optionalNonNegativeInteger(total?.output_tokens),
|
|
179
|
+
cachedInputTokens: optionalNonNegativeInteger(total?.cached_input_tokens),
|
|
180
|
+
cacheWriteInputTokens: optionalNonNegativeInteger(total?.cache_write_input_tokens),
|
|
181
|
+
reasoningOutputTokens: optionalNonNegativeInteger(total?.reasoning_output_tokens)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// Ignore malformed native records and continue to later snapshots.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (end < 0) break;
|
|
190
|
+
offset = end + 1;
|
|
191
|
+
}
|
|
192
|
+
return latest;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function optionalNonNegativeInteger(value) {
|
|
196
|
+
return value === null || value === undefined ? null : nonNegativeInteger(value);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function nonNegativeInteger(value) {
|
|
200
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function safeSessionId(value) {
|
|
204
|
+
if (typeof value !== 'string') return null;
|
|
205
|
+
const normalized = value.trim();
|
|
206
|
+
if (!normalized || normalized.length > 256 || normalized.includes('..') ||
|
|
207
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) return null;
|
|
208
|
+
return normalized;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function defaultCodexSessionsDirectory() {
|
|
212
|
+
const codexHome = defaultCodexRootDirectory();
|
|
213
|
+
return codexHome ? join(codexHome, 'sessions') : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function defaultCodexArchivedSessionsDirectory() {
|
|
217
|
+
const codexHome = defaultCodexRootDirectory();
|
|
218
|
+
return codexHome ? join(codexHome, 'archived_sessions') : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function defaultCodexRootDirectory() {
|
|
222
|
+
const configured = defaultCodexHomeDirectory();
|
|
223
|
+
if (configured) return configured;
|
|
224
|
+
const home = defaultHomeDirectory();
|
|
225
|
+
return home ? join(home, '.codex') : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function defaultCodexHomeDirectory() {
|
|
229
|
+
const value = process.env.CODEX_HOME;
|
|
230
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function defaultHomeDirectory() {
|
|
234
|
+
return process.env.HOME ?? process.env.USERPROFILE ?? null;
|
|
235
|
+
}
|
|
@@ -6,7 +6,6 @@ import { callAgentTool } from '../../agent-tools.js';
|
|
|
6
6
|
import { createRuntimeLeaseClient } from '../../adaptive-runtime/lease-client.js';
|
|
7
7
|
import { openFederatedGraphApplication } from '../../graphiti/federated-application.js';
|
|
8
8
|
import { resolveGraphRuntimeOptions } from '../../graphiti/runtime-config.js';
|
|
9
|
-
import { boundedHookMessage } from '../lifecycle-message.js';
|
|
10
9
|
|
|
11
10
|
const EVENTS = new Set(['sessionStart', 'beforeSubmitPrompt', 'stop']);
|
|
12
11
|
|
|
@@ -18,14 +17,9 @@ export async function cursorLifecycleOutput(event, input, invoke) {
|
|
|
18
17
|
}
|
|
19
18
|
const source = { sourceApplication: 'cursor', sourceSessionId: sessionId };
|
|
20
19
|
if (event === 'stop') {
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const notice = '\nIf this mode is read-only or checkpoint writes are denied, state that the checkpoint was not saved and do not retry. This is the only checkpoint continuation.';
|
|
25
|
-
const reason = boundedHookMessage(check.reason, { maxBytes: 8_000 - Buffer.byteLength(notice, 'utf8') });
|
|
26
|
-
return check.status === 'checkpoint_required' && reason.trim()
|
|
27
|
-
? { followup_message: reason + notice }
|
|
28
|
-
: {};
|
|
20
|
+
// Cursor has no advisory Stop output. A followup_message would start
|
|
21
|
+
// another model turn, so pending checkpoints remain available through MCP.
|
|
22
|
+
return {};
|
|
29
23
|
}
|
|
30
24
|
const roots = input.workspace_roots;
|
|
31
25
|
if (!Array.isArray(roots) || roots.length !== 1 || typeof roots[0] !== 'string') {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createCodexTokenUsageResolver } from './codex/token-usage.js';
|
|
2
|
+
|
|
3
|
+
const TOKEN_USAGE_SOURCES = new Set(['executor', 'host', 'dingdong']);
|
|
4
|
+
const DEFAULT_RESOLVER = createAgentTokenUsageResolver();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve one exact worker/session identity through the runtime adapter port.
|
|
8
|
+
*
|
|
9
|
+
* A worker runtime is authoritative when present. Its session is never
|
|
10
|
+
* replaced with the reporting host session. A reporting host session is used
|
|
11
|
+
* only when no worker runtime was supplied, which covers direct host activity.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveAgentSession(input = {}) {
|
|
14
|
+
const workerRuntime = input.workerRuntime ?? input.worker_runtime;
|
|
15
|
+
if (workerRuntime !== undefined && workerRuntime !== null) {
|
|
16
|
+
if (!workerRuntime || typeof workerRuntime !== 'object' || Array.isArray(workerRuntime)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const application = workerRuntime.application;
|
|
20
|
+
const sessionId = safeSessionId(workerRuntime.sessionId ?? workerRuntime.session_id);
|
|
21
|
+
if (!validApplication(application) || !sessionId) return null;
|
|
22
|
+
return {
|
|
23
|
+
application,
|
|
24
|
+
sessionId,
|
|
25
|
+
sourceApplication: input.sourceApplication ?? input.source_application ?? null,
|
|
26
|
+
sourceSessionId: input.sourceSessionId ?? input.source_session_id ?? null,
|
|
27
|
+
scope: 'worker'
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (hasWorkerMetadata(input)) return null;
|
|
32
|
+
const application = input.sourceApplication ?? input.source_application;
|
|
33
|
+
const sessionId = safeSessionId(input.sourceSessionId ?? input.source_session_id);
|
|
34
|
+
if (!validApplication(application) || !sessionId) return null;
|
|
35
|
+
return {
|
|
36
|
+
application,
|
|
37
|
+
sessionId,
|
|
38
|
+
sourceApplication: application,
|
|
39
|
+
sourceSessionId: sessionId,
|
|
40
|
+
scope: 'host'
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shared adapter port for exact session token usage.
|
|
46
|
+
*
|
|
47
|
+
* Adapters receive only the selected session identity and return a cumulative
|
|
48
|
+
* snapshot or null. They must not return transcript content.
|
|
49
|
+
*/
|
|
50
|
+
export function createAgentTokenUsageResolver({ adapters } = {}) {
|
|
51
|
+
const registry = adapters instanceof Map
|
|
52
|
+
? adapters
|
|
53
|
+
: new Map(Object.entries(adapters ?? {
|
|
54
|
+
codex: createCodexTokenUsageResolver()
|
|
55
|
+
}));
|
|
56
|
+
return {
|
|
57
|
+
async resolve(input = {}) {
|
|
58
|
+
const session = resolveAgentSession(input);
|
|
59
|
+
if (!session) return null;
|
|
60
|
+
const adapter = registry.get(session.application);
|
|
61
|
+
const resolve = typeof adapter === 'function' ? adapter : adapter?.resolve;
|
|
62
|
+
if (typeof resolve !== 'function') return null;
|
|
63
|
+
try {
|
|
64
|
+
return normalizeTokenUsage(await resolve.call(adapter, session));
|
|
65
|
+
} catch {
|
|
66
|
+
// Usage collection is best-effort. It must not prevent the host from
|
|
67
|
+
// recording otherwise valid worker activity.
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function enrichProjectAgentTaskActivity(input = {}, resolver = DEFAULT_RESOLVER) {
|
|
75
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return input;
|
|
76
|
+
const actorKind = input.actorKind ?? input.actor_kind;
|
|
77
|
+
if (actorKind === 'human') return input;
|
|
78
|
+
// Null is an explicit report (including an explicit zero inside the object),
|
|
79
|
+
// so only an omitted value is eligible for automatic collection.
|
|
80
|
+
const explicitTokenUsage = (Object.hasOwn(input, 'tokenUsage') && input.tokenUsage !== undefined) ||
|
|
81
|
+
(Object.hasOwn(input, 'token_usage') && input.token_usage !== undefined);
|
|
82
|
+
if (explicitTokenUsage) return input;
|
|
83
|
+
|
|
84
|
+
const resolve = typeof resolver === 'function' ? resolver : resolver?.resolve;
|
|
85
|
+
if (typeof resolve !== 'function') return input;
|
|
86
|
+
try {
|
|
87
|
+
const tokenUsage = normalizeTokenUsage(await resolve.call(resolver, input));
|
|
88
|
+
return tokenUsage ? { ...input, tokenUsage } : input;
|
|
89
|
+
} catch {
|
|
90
|
+
return input;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function resolveProjectAgentTaskActivityTokenUsage(input, resolver = DEFAULT_RESOLVER) {
|
|
95
|
+
const enriched = await enrichProjectAgentTaskActivity(input, resolver);
|
|
96
|
+
if (!enriched || typeof enriched !== 'object') return null;
|
|
97
|
+
return enriched.tokenUsage !== undefined ? enriched.tokenUsage : enriched.token_usage ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeTokenUsage(value) {
|
|
101
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
102
|
+
const source = value.source;
|
|
103
|
+
const totalTokens = nonNegativeInteger(value.totalTokens ?? value.total_tokens);
|
|
104
|
+
if (!TOKEN_USAGE_SOURCES.has(source) || totalTokens === null) return null;
|
|
105
|
+
return {
|
|
106
|
+
source,
|
|
107
|
+
totalTokens,
|
|
108
|
+
inputTokens: optionalNonNegativeInteger(value.inputTokens ?? value.input_tokens),
|
|
109
|
+
outputTokens: optionalNonNegativeInteger(value.outputTokens ?? value.output_tokens),
|
|
110
|
+
cachedInputTokens: optionalNonNegativeInteger(value.cachedInputTokens ?? value.cached_input_tokens),
|
|
111
|
+
cacheWriteInputTokens: optionalNonNegativeInteger(
|
|
112
|
+
value.cacheWriteInputTokens ?? value.cache_write_input_tokens
|
|
113
|
+
),
|
|
114
|
+
reasoningOutputTokens: optionalNonNegativeInteger(
|
|
115
|
+
value.reasoningOutputTokens ?? value.reasoning_output_tokens
|
|
116
|
+
)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function optionalNonNegativeInteger(value) {
|
|
121
|
+
return value === null || value === undefined ? null : nonNegativeInteger(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function nonNegativeInteger(value) {
|
|
125
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validApplication(value) {
|
|
129
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 64;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function hasWorkerMetadata(input) {
|
|
133
|
+
return [
|
|
134
|
+
input.workerId ?? input.worker_id,
|
|
135
|
+
input.workerLabel ?? input.worker_label,
|
|
136
|
+
input.workerStatus ?? input.worker_status,
|
|
137
|
+
input.workerOccupationEmoji ?? input.worker_occupation_emoji
|
|
138
|
+
].some((value) => value !== undefined && value !== null && value !== '');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function safeSessionId(value) {
|
|
142
|
+
if (typeof value !== 'string') return null;
|
|
143
|
+
const normalized = value.trim();
|
|
144
|
+
if (!normalized || normalized.length > 256 || normalized.includes('..') ||
|
|
145
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) return null;
|
|
146
|
+
return normalized;
|
|
147
|
+
}
|
|
@@ -92,6 +92,7 @@ export function openFederatedGraphApplication({
|
|
|
92
92
|
config,
|
|
93
93
|
capturePolicyStore,
|
|
94
94
|
agentAccessPolicyStore,
|
|
95
|
+
tokenUsageResolver,
|
|
95
96
|
fetchImpl = globalThis.fetch,
|
|
96
97
|
env = process.env
|
|
97
98
|
}) {
|
|
@@ -105,6 +106,7 @@ export function openFederatedGraphApplication({
|
|
|
105
106
|
agentAccessPolicyStore: agentAccessPolicyStore ?? new AgentAccessPolicyStore(
|
|
106
107
|
agentAccessPolicyPathForRuntime(runtimeConfigPath)
|
|
107
108
|
),
|
|
109
|
+
tokenUsageResolver,
|
|
108
110
|
consoleUrl: sourceConsoleUrl(runtimeConfigPath),
|
|
109
111
|
providerRequestTimeoutMs: providerRequestTimeoutFromEnv(env)
|
|
110
112
|
});
|
|
@@ -140,7 +142,8 @@ export class FederatedGraphApplication extends ProjectAgentControlPlaneApplicati
|
|
|
140
142
|
consoleUrl = sourceConsoleUrl(null),
|
|
141
143
|
projectPathResolver = resolvePersonalProjectPath,
|
|
142
144
|
taskContextRegistry = null,
|
|
143
|
-
providerRequestTimeoutMs = undefined
|
|
145
|
+
providerRequestTimeoutMs = undefined,
|
|
146
|
+
tokenUsageResolver = undefined
|
|
144
147
|
} = {}) {
|
|
145
148
|
super();
|
|
146
149
|
this.graphiti = true;
|
|
@@ -149,6 +152,7 @@ export class FederatedGraphApplication extends ProjectAgentControlPlaneApplicati
|
|
|
149
152
|
this.agentAccessPolicyStore = agentAccessPolicyStore;
|
|
150
153
|
this.consoleUrl = consoleUrl;
|
|
151
154
|
this.projectPathResolver = projectPathResolver;
|
|
155
|
+
this.tokenUsageResolver = tokenUsageResolver;
|
|
152
156
|
this.personal = new GraphitiProviderClient({
|
|
153
157
|
baseUrl: config.personal.providerUrl,
|
|
154
158
|
accessToken: config.personal.accessToken,
|
|
@@ -380,7 +380,7 @@ export function providerProjectAgentTaskActivity(input) {
|
|
|
380
380
|
}
|
|
381
381
|
if (hasAny(input, ['tokenUsage', 'token_usage'])) {
|
|
382
382
|
result.token_usage = providerProjectAgentTokenUsage(
|
|
383
|
-
input.tokenUsage
|
|
383
|
+
input.tokenUsage !== undefined ? input.tokenUsage : input.token_usage
|
|
384
384
|
);
|
|
385
385
|
}
|
|
386
386
|
if (hasAny(input, ['workerRuntime', 'worker_runtime'])) {
|