fuli-context 0.8.0 → 0.8.1
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 +4 -0
- package/README.zh-CN.md +4 -1
- package/dist/web/assets/{AboutPage-BjMmpwJB.js → AboutPage-fBq5VFoQ.js} +1 -1
- package/dist/web/assets/AgentHand-CMZk5GjY.js +1 -0
- package/dist/web/assets/AgentName-Cydd0UI4.css +1 -0
- package/dist/web/assets/AgentName-DRZHAIB2.js +1 -0
- package/dist/web/assets/AgentProfilePage-BPQEfMhB.js +1 -0
- package/dist/web/assets/AgentsDirectoryPage-CKZfrcuD.js +1 -0
- package/dist/web/assets/{ConnectionsPage-BKGxFDos.js → ConnectionsPage-COb56qnk.js} +1 -1
- package/dist/web/assets/EmployeeRecruitDialog-BLTpjCcK.js +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-CQwZnTA0.css +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-HUd-gne2.js +3 -0
- package/dist/web/assets/{GrowthLoading-WXCj8b0r.js → GrowthLoading-Bu_kH-I8.js} +1 -1
- package/dist/web/assets/{KnowledgeEditDialog-Bkie08ZA.js → KnowledgeEditDialog-BTwXXRCD.js} +1 -1
- package/dist/web/assets/{KnowledgeInspector-Cez-Z_95.js → KnowledgeInspector-Ct5hl2--.js} +1 -1
- package/dist/web/assets/{KnowledgeOrganizerPage-BYmTPA_8.js → KnowledgeOrganizerPage-DMC8Hh43.js} +1 -1
- package/dist/web/assets/KnowledgePage-uXW1Eovz.js +1 -0
- package/dist/web/assets/{KnowledgeWorkspace-DKij2_B4.js → KnowledgeWorkspace-DmHIc0jb.js} +1 -1
- package/dist/web/assets/{NotFoundPage-HGV-q-bh.js → NotFoundPage-C0yOR9T9.js} +1 -1
- package/dist/web/assets/{OverviewPage-DE29_F_Y.js → OverviewPage-BreX_kb4.js} +1 -1
- package/dist/web/assets/{PersonalProfilePage-B95DFc7x.js → PersonalProfilePage-CX1a6_E3.js} +1 -1
- package/dist/web/assets/PersonalProjectsPage-Br1S8ETM.js +1 -0
- package/dist/web/assets/ProjectAgentsPage-CA04h1Uf.js +13 -0
- package/dist/web/assets/ProjectAgentsPage-CZWWV4Kw.css +1 -0
- package/dist/web/assets/{ProjectScopePicker-Cu5PdxF6.js → ProjectScopePicker-DVHfXX1f.js} +1 -1
- package/dist/web/assets/{PublicProjectsPage-BY4B3_t3.js → PublicProjectsPage-CtctKml1.js} +1 -1
- package/dist/web/assets/{ReviewPage-C9vCesNu.js → ReviewPage-DUqaK-do.js} +1 -1
- package/dist/web/assets/{SearchableSelect-214TvfYg.js → SearchableSelect-BZtyYmZN.js} +1 -1
- package/dist/web/assets/{SettingsPage-D9TnzcFc.js → SettingsPage-CB4ymLr8.js} +1 -1
- package/dist/web/assets/{SharedJefaReportPage-CdSGI_OE.js → SharedJefaReportPage-CbFtrDCY.js} +1 -1
- package/dist/web/assets/{WritingTastePage-CUE1dU8f.js → WritingTastePage-B7lSzs2z.js} +1 -1
- package/dist/web/assets/agent-profile-CdKOh5xw.css +1 -0
- package/dist/web/assets/agent-profile.css_vue_type_style_index_0_src_true_lang-C22UdfU0.js +1 -0
- package/dist/web/assets/catalog-DU4Kfd-N.js +1 -0
- package/dist/web/assets/{client-Bl4oiW3E.js → client-CEHkJJ-5.js} +1 -1
- package/dist/web/assets/{console-BAIVNC-N.js → console--CZxc3PW.js} +1 -1
- package/dist/web/assets/{conversation-launcher-settings-DpRI_Jp2.js → conversation-launcher-settings-Co1RlTFH.js} +1 -1
- package/dist/web/assets/{i18n-CVFADb_M.js → i18n-BtOOoyJd.js} +3 -3
- package/dist/web/assets/{identity-Dt0WZgqo.js → identity-CsowvtIm.js} +1 -1
- package/dist/web/assets/index-CWMqDq8g.css +1 -0
- package/dist/web/assets/index-MKmHNBtc.js +2 -0
- package/dist/web/assets/{model-B8sOxih0.js → model-CGpllrds.js} +1 -1
- package/dist/web/assets/{personal-profile-graph-aAqex7ty.js → personal-profile-graph-BNTG4PSY.js} +1 -1
- package/dist/web/assets/profile-model-BQO4zNs9.js +1 -0
- package/dist/web/assets/{shared-report-BQD6l_cS.js → shared-report-BFUJqqQe.js} +1 -1
- package/dist/web/assets/task-evidence-BUu7yYIn.js +1 -0
- package/dist/web/assets/{useApi-CROJJdhE-CuJ16v2S.js → useApi-CROJJdhE-raNlHRDl.js} +1 -1
- package/dist/web/assets/{useModalDialog-Ddv2XJQi.js → useModalDialog-CroGk2Cs.js} +1 -1
- package/dist/web/assets/{vue-router-C8KuEZ6a.js → vue-router-JFYBLXB1.js} +1 -1
- package/dist/web/index.html +12 -10
- package/docs/agent-conversations-and-collaboration.md +115 -0
- package/docs/project-agent-memory.md +28 -0
- package/graph-provider/fuli_graph/agent_conversation_models.py +72 -0
- package/graph-provider/fuli_graph/agent_conversation_routes.py +47 -0
- package/graph-provider/fuli_graph/agent_loan_models.py +28 -0
- package/graph-provider/fuli_graph/agent_quality_policy.py +151 -0
- package/graph-provider/fuli_graph/agent_verification_models.py +22 -0
- package/graph-provider/fuli_graph/conversation_context.py +30 -0
- package/graph-provider/fuli_graph/project_agent_executor_models.py +3 -0
- package/graph-provider/fuli_graph/project_agent_memory_routes.py +2 -0
- package/graph-provider/fuli_graph/project_agent_models.py +10 -0
- package/graph-provider/fuli_graph/project_agent_task_models.py +4 -0
- package/graph-provider/fuli_graph/runtime.py +14 -0
- package/graph-provider/fuli_graph/store.py +6 -0
- package/graph-provider/fuli_graph/store_agent_conversations.py +206 -0
- package/graph-provider/fuli_graph/store_agent_loans.py +146 -0
- package/graph-provider/fuli_graph/store_agent_verification.py +111 -0
- package/graph-provider/fuli_graph/store_project_agent_executors.py +2 -0
- package/graph-provider/fuli_graph/store_project_agent_task_activity.py +6 -0
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +20 -8
- package/graph-provider/fuli_graph/store_task_contexts.py +9 -0
- package/graph-provider/fuli_graph/task_context_routes.py +7 -0
- package/graph-provider/pyproject.toml +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/skills/operating-fuli/SKILL.md +4 -0
- package/src/agent-tools/agent-collaboration-definitions.js +30 -0
- package/src/agent-tools/agent-conversation-definitions.js +21 -0
- package/src/agent-tools/graph-definitions.js +17 -33
- package/src/agent-tools/graph-handlers.js +17 -1
- package/src/agent-tools/interface-catalog.js +2 -0
- package/src/agent-tools/project-agent-definitions.js +21 -0
- package/src/agent-tools/project-relation-definitions.js +36 -0
- package/src/agents/claude-code/conversation-transcript.js +20 -0
- package/src/agents/claude-code/lifecycle-hook.js +154 -0
- package/src/agents/claude-code/lifecycle-hooks.js +20 -0
- package/src/agents/codex/conversation-transcript.js +22 -0
- package/src/agents/codex/lifecycle-hook.js +47 -8
- package/src/agents/codex/lifecycle-hooks.js +6 -6
- package/src/agents/cursor/lifecycle-hook.js +5 -4
- package/src/app/agent-request-context.js +6 -1
- package/src/app/test-tools.js +26 -0
- package/src/conversations/sync-transcript.js +144 -0
- package/src/conversations/task-context-view.js +28 -0
- package/src/conversations/transcript-guard.js +54 -0
- package/src/conversations/transcript-reader.js +113 -0
- package/src/graphiti/agent-collaboration.js +62 -0
- package/src/graphiti/agent-conversations.js +75 -0
- package/src/graphiti/agent-knowledge-workflows.js +7 -1
- package/src/graphiti/project-agent-control-plane.js +22 -2
- package/src/graphiti/project-agent-mapping.js +19 -1
- package/src/graphiti/project-agent-task-entry.js +11 -1
- package/src/graphiti/project-agent-workflows.js +1 -0
- package/src/graphiti/provider-client.js +17 -0
- package/src/graphiti/verification-preference.js +24 -0
- package/src/http/graph-api-router.js +17 -0
- package/src/mcp/create-mcp-server.js +13 -4
- package/src/mcp/session-id.js +4 -0
- package/src/mcp/tool-annotations.js +8 -1
- package/src/setup/agent-installation-status.js +1 -0
- package/src/setup/claude-code-config.js +13 -19
- package/dist/web/assets/AgentHand-BfLYPGjg.js +0 -1
- package/dist/web/assets/EmployeeRecruitDialog-D-W043v4.js +0 -1
- package/dist/web/assets/EmployeeWorkbenchPage-Cz7y-IFD.css +0 -1
- package/dist/web/assets/EmployeeWorkbenchPage-HHQPxdqI.js +0 -3
- package/dist/web/assets/KnowledgePage-Ck7tIBDn.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-Bt7tFF5Z.js +0 -1
- package/dist/web/assets/ProjectAgentsPage-BioDR9OW.css +0 -1
- package/dist/web/assets/ProjectAgentsPage-CiiPNe_G.js +0 -12
- package/dist/web/assets/index-BUMq5zNh.js +0 -2
- package/dist/web/assets/index-C0ci8dnU.css +0 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { withTranscriptGuard } from '../../conversations/transcript-guard.js';
|
|
3
|
+
import { compactTaskContext } from '../../conversations/task-context-view.js';
|
|
4
|
+
import { syncConversationTranscript, withTranscriptNotice, claimTranscriptBoundary, recordTranscriptTaskEntry } from '../../conversations/sync-transcript.js';
|
|
5
|
+
import { normalizeClaudeRecord, verifyClaudeTranscript } from './conversation-transcript.js';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { callAgentTool } from '../../agent-tools.js';
|
|
10
|
+
import { createRuntimeLeaseClient } from '../../adaptive-runtime/lease-client.js';
|
|
11
|
+
import { openFederatedGraphApplication } from '../../graphiti/federated-application.js';
|
|
12
|
+
import { resolveGraphRuntimeOptions } from '../../graphiti/runtime-config.js';
|
|
13
|
+
import { hookAdditionalContextToolResult } from '../../mcp/tool-result.js';
|
|
14
|
+
import { boundedHookMessage } from '../lifecycle-message.js';
|
|
15
|
+
|
|
16
|
+
export async function claudeLifecycleOutput(event, input, invoke) {
|
|
17
|
+
if (!['UserPromptSubmit', 'Stop'].includes(event)) throw new TypeError('Unsupported Claude lifecycle event');
|
|
18
|
+
const sessionId = input?.session_id;
|
|
19
|
+
if (typeof sessionId !== 'string' || !sessionId.trim() || sessionId.length > 256) {
|
|
20
|
+
throw new TypeError('Claude lifecycle requires a host session identifier');
|
|
21
|
+
}
|
|
22
|
+
const source = { sourceApplication: 'claude_code', sourceSessionId: sessionId };
|
|
23
|
+
if (event === 'UserPromptSubmit') {
|
|
24
|
+
if (typeof input.cwd !== 'string' || !input.cwd.trim() || typeof input.prompt !== 'string') {
|
|
25
|
+
throw new TypeError('Claude task entry requires cwd and prompt');
|
|
26
|
+
}
|
|
27
|
+
const context = await invoke('begin_task_context', {
|
|
28
|
+
sessionId, projectPath: input.cwd, taskPrompt: input.prompt.slice(0, 8192), ...source
|
|
29
|
+
});
|
|
30
|
+
return JSON.parse(hookAdditionalContextToolResult(compactTaskContext(context), {
|
|
31
|
+
hookEventName: event,
|
|
32
|
+
label: 'Fuli task context. Apply effective_preferences and use taskContextToken for the final checkpoint.',
|
|
33
|
+
limitBytes: 128 * 1024,
|
|
34
|
+
itemLimit: 1000
|
|
35
|
+
}).content[0].text);
|
|
36
|
+
}
|
|
37
|
+
const check = await invoke('verify_task_checkpoint', { sessionId, ...source });
|
|
38
|
+
const reason = boundedHookMessage(check?.reason);
|
|
39
|
+
if (check?.decision !== 'block' || !reason.trim()) return {};
|
|
40
|
+
if (input.stop_hook_active === true) {
|
|
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 };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function claudeLifecycleUnavailable(event) {
|
|
56
|
+
const warning = 'Fuli context is unavailable; memory recovery and task checkpoint are not verified. '
|
|
57
|
+
+ 'Continue with the user request and use Fuli MCP when it becomes available.';
|
|
58
|
+
return event === 'UserPromptSubmit'
|
|
59
|
+
? { systemMessage: warning, hookSpecificOutput: { hookEventName: event, additionalContext: warning } }
|
|
60
|
+
: { systemMessage: warning };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function runClaudeLifecycleHook(args, dependencies = {}) {
|
|
64
|
+
const event = args[args.indexOf('--event') + 1];
|
|
65
|
+
const input = await (dependencies.readInput ?? readHookInput)();
|
|
66
|
+
if (dependencies.invoke) return claudeLifecycleOutput(event, input, dependencies.invoke);
|
|
67
|
+
const { runtimeConfigPath } = (dependencies.resolveRuntimeOptions ?? resolveGraphRuntimeOptions)(
|
|
68
|
+
args, dependencies.env ?? process.env
|
|
69
|
+
);
|
|
70
|
+
let app;
|
|
71
|
+
let leases;
|
|
72
|
+
try {
|
|
73
|
+
app = (dependencies.openApplication ?? openFederatedGraphApplication)({ runtimeConfigPath });
|
|
74
|
+
leases = (dependencies.createLeases ?? createRuntimeLeaseClient)({ runtimeConfigPath });
|
|
75
|
+
const request = { signal: dependencies.signal };
|
|
76
|
+
return await leases.withGraphLease('claude-code-lifecycle', async () => {
|
|
77
|
+
try {
|
|
78
|
+
if (app.getCapturePolicy?.().enabled && input.transcript_path) dependencies.onTranscriptGuard?.();
|
|
79
|
+
return await withTranscriptGuard(runtimeConfigPath, input, 'claude_code', app.getCapturePolicy?.().enabled, async guard => {
|
|
80
|
+
let sync = await syncConversationTranscript(app, input, 'claude_code', {
|
|
81
|
+
normalize: normalizeClaudeRecord, verify: verifyClaudeTranscript
|
|
82
|
+
}, guard);
|
|
83
|
+
if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
|
|
84
|
+
const entryGuard = sync.status === 'capture_disabled' ? null : guard;
|
|
85
|
+
if (event === 'UserPromptSubmit' && entryGuard?.value) await entryGuard.write({ ...entryGuard.value, phase: 'beginning' });
|
|
86
|
+
const output = await claudeLifecycleOutput(event, input, async (name, parameters) => {
|
|
87
|
+
const context = await (dependencies.callTool ?? callAgentTool)(app, name, parameters, request);
|
|
88
|
+
if (name === 'begin_task_context') await recordTranscriptTaskEntry(entryGuard, context);
|
|
89
|
+
return context;
|
|
90
|
+
});
|
|
91
|
+
if (event === 'UserPromptSubmit') sync = await claimTranscriptBoundary(app, input, 'claude_code', sync, {
|
|
92
|
+
normalize: normalizeClaudeRecord, verify: verifyClaudeTranscript
|
|
93
|
+
}, entryGuard);
|
|
94
|
+
if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
|
|
95
|
+
return withTranscriptNotice(output, sync);
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (event !== 'UserPromptSubmit' || !app.getCapturePolicy?.().enabled || !input.transcript_path) throw error;
|
|
99
|
+
return { decision: 'block', reason: error.code === 'EADDRINUSE'
|
|
100
|
+
? '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
|
+
: 'Fuli transcript handoff could not be completed safely. Retry task entry; no previous Agent context was supplied.' };
|
|
102
|
+
}
|
|
103
|
+
}, request);
|
|
104
|
+
} finally {
|
|
105
|
+
await Promise.allSettled([close(leases), close(app)]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function close(resource) { await resource?.close(); }
|
|
110
|
+
|
|
111
|
+
async function readHookInput() {
|
|
112
|
+
const chunks = [];
|
|
113
|
+
let size = 0;
|
|
114
|
+
for await (const chunk of process.stdin) {
|
|
115
|
+
size += chunk.length;
|
|
116
|
+
if (size > 256 * 1024) throw new TypeError('Hook input exceeds limit');
|
|
117
|
+
chunks.push(chunk);
|
|
118
|
+
}
|
|
119
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function main() {
|
|
123
|
+
const args = process.argv.slice(2);
|
|
124
|
+
const event = args[args.indexOf('--event') + 1];
|
|
125
|
+
const configured = args.includes('--timeout-ms') ? Number(args[args.indexOf('--timeout-ms') + 1]) : 28000;
|
|
126
|
+
const timeoutMs = Number.isSafeInteger(configured) && configured > 0 && configured <= 2_147_483_647
|
|
127
|
+
? configured : 28000;
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
let emitted = false;
|
|
130
|
+
let guarded = false;
|
|
131
|
+
const emit = (output, callback) => {
|
|
132
|
+
if (emitted) return;
|
|
133
|
+
emitted = true;
|
|
134
|
+
process.stdout.write(`${JSON.stringify(output)}\n`, callback);
|
|
135
|
+
};
|
|
136
|
+
// Finish before the host timeout, even if a dependency ignores cancellation.
|
|
137
|
+
// No prompt, transcript, path or raw exception is written to diagnostics.
|
|
138
|
+
const deadline = setTimeout(() => {
|
|
139
|
+
controller.abort();
|
|
140
|
+
emit(guarded && event === 'UserPromptSubmit' ? { decision: 'block', reason: 'Fuli transcript handoff timed out. Retry task entry; the previous Agent context was not supplied.' } : claudeLifecycleUnavailable(event), () => process.exit(0));
|
|
141
|
+
}, timeoutMs);
|
|
142
|
+
const abort = setTimeout(() => controller.abort(), Math.max(1, timeoutMs - 1500));
|
|
143
|
+
try {
|
|
144
|
+
const output = await runClaudeLifecycleHook(args, { signal: controller.signal, onTranscriptGuard: () => { guarded = true; } });
|
|
145
|
+
emit(output);
|
|
146
|
+
} catch {
|
|
147
|
+
emit(claudeLifecycleUnavailable(event));
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(abort);
|
|
150
|
+
clearTimeout(deadline);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { quoteShellArgument } from '../../cli/shell-argument.js';
|
|
3
|
+
|
|
4
|
+
export const CLAUDE_LIFECYCLE_MARKER = '--fuli-claude-lifecycle';
|
|
5
|
+
|
|
6
|
+
export function claudeLifecycleCommand(context, event, timeoutSec) {
|
|
7
|
+
const script = context.claudeLifecycleHookPath ??
|
|
8
|
+
join(dirname(context.mcpServerPath), 'agents', 'claude-code', 'lifecycle-hook.js');
|
|
9
|
+
return [
|
|
10
|
+
context.nodePath, script, CLAUDE_LIFECYCLE_MARKER,
|
|
11
|
+
'--runtime-config', context.runtimeConfigPath, '--event', event,
|
|
12
|
+
'--timeout-ms', String(Math.max(100, timeoutSec * 1000 - 2000))
|
|
13
|
+
].map(value => quoteShellArgument(value, context.platform)).join(' ');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isClaudeLifecycleCommand(hook) {
|
|
17
|
+
return hook?.type === 'command' && typeof hook.command === 'string' &&
|
|
18
|
+
hook.command.includes(CLAUDE_LIFECYCLE_MARKER) &&
|
|
19
|
+
hook.command.includes('lifecycle-hook.js');
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Codex rollout v1: visible response items only; event_msg mirrors are omitted.
|
|
2
|
+
export function normalizeCodexRecord(record) {
|
|
3
|
+
if (record?.type !== 'response_item') return [];
|
|
4
|
+
const item = record.payload;
|
|
5
|
+
if (item?.type === 'message' && ['user', 'assistant'].includes(item.role)) {
|
|
6
|
+
const content = (item.content ?? []).filter(block => ['input_text', 'output_text'].includes(block.type))
|
|
7
|
+
.map(block => block.text ?? '').join('\n');
|
|
8
|
+
return content ? [{ role: item.role, kind: 'message', content }] : [];
|
|
9
|
+
}
|
|
10
|
+
if (['function_call', 'custom_tool_call'].includes(item?.type)) {
|
|
11
|
+
return [{ role: 'assistant', kind: 'tool_call', content: JSON.stringify({
|
|
12
|
+
call_id: item.call_id, name: item.name, arguments: item.arguments ?? item.input
|
|
13
|
+
}) }];
|
|
14
|
+
}
|
|
15
|
+
if (['function_call_output', 'custom_tool_call_output'].includes(item?.type)) {
|
|
16
|
+
return [{ role: 'tool', kind: 'tool_result', content: JSON.stringify({ call_id: item.call_id, output: item.output }) }];
|
|
17
|
+
}
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
export function verifyCodexTranscript(firstRecord, sessionId) {
|
|
21
|
+
return firstRecord?.type === 'session_meta' && firstRecord.payload?.id === sessionId;
|
|
22
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { withTranscriptGuard } from '../../conversations/transcript-guard.js';
|
|
3
|
+
import { compactTaskContext } from '../../conversations/task-context-view.js';
|
|
4
|
+
import { syncConversationTranscript, withTranscriptNotice, claimTranscriptBoundary, recordTranscriptTaskEntry } from '../../conversations/sync-transcript.js';
|
|
5
|
+
import { normalizeCodexRecord, verifyCodexTranscript } from './conversation-transcript.js';
|
|
6
|
+
import { hookAdditionalContextToolResult } from '../../mcp/tool-result.js';
|
|
2
7
|
import { resolve } from 'node:path';
|
|
3
8
|
import { fileURLToPath } from 'node:url';
|
|
4
9
|
|
|
@@ -37,13 +42,26 @@ export async function codexStopLifecycleOutput(input, invoke) {
|
|
|
37
42
|
return { decision: 'block', reason: boundedHookMessage(check.reason) };
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
export async function codexLifecycleOutput(event, input, invoke) {
|
|
46
|
+
if (event === 'Stop') return codexStopLifecycleOutput(input, invoke);
|
|
47
|
+
if (!input?.session_id || typeof input.cwd !== 'string' || typeof input.prompt !== 'string') {
|
|
48
|
+
throw new TypeError('Codex task entry requires session, cwd and prompt');
|
|
49
|
+
}
|
|
50
|
+
const context = await invoke('begin_task_context', { sessionId: input.session_id,
|
|
51
|
+
turnId: input.turn_id ?? null, projectPath: input.cwd, taskPrompt: input.prompt.slice(0, 8192),
|
|
52
|
+
sourceApplication: 'codex', sourceSessionId: input.session_id });
|
|
53
|
+
return JSON.parse(hookAdditionalContextToolResult(compactTaskContext(context), { hookEventName: event,
|
|
54
|
+
label: 'Fuli task context. Apply effective_preferences and checkpoint with taskContextToken.',
|
|
55
|
+
limitBytes: 128 * 1024, itemLimit: 1000 }).content[0].text);
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
export async function runCodexLifecycleHook(args, dependencies = {}) {
|
|
41
59
|
const event = args[args.indexOf('--event') + 1];
|
|
42
|
-
if (
|
|
60
|
+
if (!['Stop', 'UserPromptSubmit'].includes(event)) throw new TypeError('Missing Codex lifecycle event');
|
|
43
61
|
const input = await (dependencies.readInput ?? readHookInput)();
|
|
44
62
|
const write = dependencies.write ?? ((value) => process.stdout.write(value));
|
|
45
63
|
if (dependencies.invoke) {
|
|
46
|
-
const output = await
|
|
64
|
+
const output = await codexLifecycleOutput(event, input, dependencies.invoke);
|
|
47
65
|
write(`${JSON.stringify(output)}\n`);
|
|
48
66
|
return output;
|
|
49
67
|
}
|
|
@@ -63,12 +81,33 @@ export async function runCodexLifecycleHook(args, dependencies = {}) {
|
|
|
63
81
|
runtimeConfigPath
|
|
64
82
|
});
|
|
65
83
|
const invokeTool = dependencies.callTool ?? callAgentTool;
|
|
66
|
-
const output = await leases.withGraphLease('codex-lifecycle', () =>
|
|
67
|
-
|
|
68
|
-
input,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
const output = await leases.withGraphLease('codex-lifecycle', async () => {
|
|
85
|
+
try {
|
|
86
|
+
return await withTranscriptGuard(runtimeConfigPath, input, 'codex', app.getCapturePolicy?.().enabled, async guard => {
|
|
87
|
+
let sync = await syncConversationTranscript(app, input, 'codex', {
|
|
88
|
+
normalize: normalizeCodexRecord, verify: verifyCodexTranscript
|
|
89
|
+
}, guard);
|
|
90
|
+
if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
|
|
91
|
+
const entryGuard = sync.status === 'capture_disabled' ? null : guard;
|
|
92
|
+
if (event === 'UserPromptSubmit' && entryGuard?.value) await entryGuard.write({ ...entryGuard.value, phase: 'beginning' });
|
|
93
|
+
const result = await codexLifecycleOutput(event, input, async (name, parameters) => {
|
|
94
|
+
const context = await invokeTool(app, name, parameters);
|
|
95
|
+
if (name === 'begin_task_context') await recordTranscriptTaskEntry(entryGuard, context);
|
|
96
|
+
return context;
|
|
97
|
+
});
|
|
98
|
+
if (event === 'UserPromptSubmit') sync = await claimTranscriptBoundary(app, input, 'codex', sync, {
|
|
99
|
+
normalize: normalizeCodexRecord, verify: verifyCodexTranscript
|
|
100
|
+
}, entryGuard);
|
|
101
|
+
if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
|
|
102
|
+
return withTranscriptNotice(result, sync);
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (event !== 'UserPromptSubmit' || !app.getCapturePolicy?.().enabled || !input.transcript_path) throw error;
|
|
106
|
+
return { decision: 'block', reason: error.code === 'EADDRINUSE'
|
|
107
|
+
? '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.'
|
|
108
|
+
: 'Fuli transcript handoff could not be completed safely. Retry task entry; no previous Agent context was supplied.' };
|
|
109
|
+
}
|
|
110
|
+
});
|
|
72
111
|
write(`${JSON.stringify(output)}\n`);
|
|
73
112
|
return output;
|
|
74
113
|
} finally {
|
|
@@ -18,8 +18,8 @@ export function withCodexLifecycleHooks(current, { timeout = 30, context = null
|
|
|
18
18
|
}],
|
|
19
19
|
['Stop', 'verify_task_checkpoint', { sessionId: '${session_id}' }]
|
|
20
20
|
]) {
|
|
21
|
-
const handler =
|
|
22
|
-
? { type: 'command', command: codexStopCommand(context), timeout }
|
|
21
|
+
const handler = context
|
|
22
|
+
? { type: 'command', command: codexStopCommand(context, event), timeout }
|
|
23
23
|
: { type: 'mcp_tool', server: 'fuli', tool, input, timeout };
|
|
24
24
|
hooks[event] = [...(hooks[event] ?? []), { hooks: [handler] }];
|
|
25
25
|
}
|
|
@@ -88,12 +88,12 @@ export function withCodexTomlLifecycleHooks(source, { timeout = 30, context = nu
|
|
|
88
88
|
const lines = clean.replaceAll('\r\n', '\n').split('\n');
|
|
89
89
|
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
|
90
90
|
const blocks = [
|
|
91
|
-
...codexTomlHookBlock('UserPromptSubmit', 'begin_task_context', {
|
|
91
|
+
...(context ? codexTomlCommandHookBlock('UserPromptSubmit', codexStopCommand(context, 'UserPromptSubmit'), timeout) : codexTomlHookBlock('UserPromptSubmit', 'begin_task_context', {
|
|
92
92
|
sessionId: '${session_id}',
|
|
93
93
|
turnId: '${turn_id}',
|
|
94
94
|
projectPath: '${cwd}',
|
|
95
95
|
taskPrompt: '${prompt}'
|
|
96
|
-
}, timeout),
|
|
96
|
+
}, timeout)),
|
|
97
97
|
...(context
|
|
98
98
|
? codexTomlCommandHookBlock('Stop', codexStopCommand(context), timeout)
|
|
99
99
|
: codexTomlHookBlock('Stop', 'verify_task_checkpoint', {
|
|
@@ -212,7 +212,7 @@ function isManagedCodexHook(hook) {
|
|
|
212
212
|
hook.command.includes(COMMAND_MARKER) && hook.command.includes('lifecycle-hook.js');
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
function codexStopCommand(context) {
|
|
215
|
+
function codexStopCommand(context, event = 'Stop') {
|
|
216
216
|
const script = context.codexLifecycleHookPath ??
|
|
217
217
|
join(dirname(context.mcpServerPath), 'agents', 'codex', 'lifecycle-hook.js');
|
|
218
218
|
return [
|
|
@@ -222,7 +222,7 @@ function codexStopCommand(context) {
|
|
|
222
222
|
'--runtime-config',
|
|
223
223
|
context.runtimeConfigPath,
|
|
224
224
|
'--event',
|
|
225
|
-
|
|
225
|
+
event
|
|
226
226
|
].map(value => quoteShellArgument(value, context.platform)).join(' ');
|
|
227
227
|
}
|
|
228
228
|
|
|
@@ -18,12 +18,13 @@ export async function cursorLifecycleOutput(event, input, invoke) {
|
|
|
18
18
|
}
|
|
19
19
|
const source = { sourceApplication: 'cursor', sourceSessionId: sessionId };
|
|
20
20
|
if (event === 'stop') {
|
|
21
|
-
//
|
|
22
|
-
if (input.status !== 'completed' || (input.loop_count ?? 0) >=
|
|
21
|
+
// One host-counted continuation at most; never restart cancelled/error turns.
|
|
22
|
+
if (input.status !== 'completed' || (input.loop_count ?? 0) >= 1) return {};
|
|
23
23
|
const check = await invoke('verify_task_checkpoint', { sessionId, ...source });
|
|
24
|
-
const
|
|
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') });
|
|
25
26
|
return check.status === 'checkpoint_required' && reason.trim()
|
|
26
|
-
? { followup_message: reason }
|
|
27
|
+
? { followup_message: reason + notice }
|
|
27
28
|
: {};
|
|
28
29
|
}
|
|
29
30
|
const roots = input.workspace_roots;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { testToolsEnabled } from './test-tools.js';
|
|
1
2
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
3
|
|
|
3
4
|
const requestContext = new AsyncLocalStorage();
|
|
@@ -8,7 +9,7 @@ export function runWithAgentRequestContext(value, operation) {
|
|
|
8
9
|
}
|
|
9
10
|
const signal = abortSignal(value?.signal);
|
|
10
11
|
signal?.throwIfAborted();
|
|
11
|
-
return
|
|
12
|
+
return requestContext.run({ signal, testToolsEnabled: value?.testToolsEnabled ?? testToolsEnabled() }, operation);
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function activeAgentRequestSignal() {
|
|
@@ -21,3 +22,7 @@ function abortSignal(value) {
|
|
|
21
22
|
? value
|
|
22
23
|
: null;
|
|
23
24
|
}
|
|
25
|
+
|
|
26
|
+
export function activeTestToolsEnabled() {
|
|
27
|
+
return requestContext.getStore()?.testToolsEnabled ?? testToolsEnabled();
|
|
28
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ApplicationError, ApplicationErrorCode } from './application-error.js';
|
|
2
|
+
|
|
3
|
+
/** Tools that must not appear on default production MCP surfaces. */
|
|
4
|
+
export const TEST_ONLY_TOOL_NAMES = Object.freeze([
|
|
5
|
+
'cleanup_test_project_agents'
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const TEST_ONLY_TOOL_NAME_SET = new Set(TEST_ONLY_TOOL_NAMES);
|
|
9
|
+
|
|
10
|
+
export function isTestOnlyToolName(name) {
|
|
11
|
+
return TEST_ONLY_TOOL_NAME_SET.has(name);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Opt-in via FULI_ENABLE_TEST_TOOLS=1|true for local harnesses and e2e. */
|
|
15
|
+
export function testToolsEnabled(env = process.env) {
|
|
16
|
+
const value = env?.FULI_ENABLE_TEST_TOOLS;
|
|
17
|
+
return value === '1' || value === 'true';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function assertTestToolsEnabled(env = process.env) {
|
|
21
|
+
if (testToolsEnabled(env)) return;
|
|
22
|
+
throw new ApplicationError(
|
|
23
|
+
ApplicationErrorCode.VALIDATION,
|
|
24
|
+
'cleanup_test_project_agents is disabled unless FULI_ENABLE_TEST_TOOLS=1'
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { initialTranscriptBoundary, readTranscriptBatch } from './transcript-reader.js';
|
|
2
|
+
import { transcriptDigest } from './transcript-guard.js';
|
|
3
|
+
|
|
4
|
+
const pending = reason => ({ status: 'partial', entryBlocked: true, reason });
|
|
5
|
+
const retryReason = 'Transcript handoff is pending. Retry task entry to continue its saved progress; no new Agent context was supplied';
|
|
6
|
+
|
|
7
|
+
export async function syncConversationTranscript(application, input, sourceApplication, adapter, guard = null) {
|
|
8
|
+
if (!application.getCapturePolicy?.().enabled) return { status: 'capture_disabled' };
|
|
9
|
+
if (!input?.transcript_path) return { status: 'partial', reason: 'Host did not supply a visible transcript' };
|
|
10
|
+
let committedPages = 0;
|
|
11
|
+
try {
|
|
12
|
+
const task = await application.personal.currentTaskContext({ personal_space_id: application.config.personal.spaceId,
|
|
13
|
+
session_id: input.session_id, source_application: sourceApplication });
|
|
14
|
+
if (!task?.project_agent_id) return guard?.value ? pending(retryReason) : { status: 'unassigned' };
|
|
15
|
+
const scope = { personal_space_id: application.config.personal.spaceId, personal_project_id: task.personal_project_id,
|
|
16
|
+
agent_id: task.project_agent_id, source_application: sourceApplication };
|
|
17
|
+
const policy = await application.personal.conversation('query', { ...scope, mode: 'policy' });
|
|
18
|
+
if (!policy.enabled) return { status: 'capture_disabled' };
|
|
19
|
+
let session = await application.personal.conversation('query', { ...scope, mode: 'session', session_id: input.session_id });
|
|
20
|
+
if (!session.initialized) return guard?.value ? pending('Transcript initialization changed during handoff. Start a new host session and resume the intended Agent') : { status: 'partial', reason: 'Transcript capture needs a verified task entry boundary' };
|
|
21
|
+
if (guard?.value?.phase === 'claim') {
|
|
22
|
+
if (guard.value.nextOwner !== transcriptDigest(task.token)) return pending('Transcript task ownership changed during handoff. Start a new host session and resume the intended Agent');
|
|
23
|
+
await application.personal.conversation('boundary', { ...scope, session_id: input.session_id,
|
|
24
|
+
task_context_token: task.token, expected_cursor: guard.value.boundary, cursor: guard.value.boundary });
|
|
25
|
+
await guard.clear();
|
|
26
|
+
} else if (guard?.value && guard.value.owner !== transcriptDigest(task.token)) {
|
|
27
|
+
return pending('Transcript task ownership changed during handoff. Start a new host session and resume the intended Agent');
|
|
28
|
+
}
|
|
29
|
+
if (typeof input.prompt === 'string' && guard && !guard.value) {
|
|
30
|
+
await guard.write({ version: 1, phase: 'scan', owner: transcriptDigest(task.token),
|
|
31
|
+
prompt: transcriptDigest(input.prompt), scanCursor: session.cursor ?? 0, userMessages: 0, omitted: false });
|
|
32
|
+
}
|
|
33
|
+
if (guard?.value && typeof input.prompt === 'string' && guard.value.prompt !== transcriptDigest(input.prompt)) {
|
|
34
|
+
return pending('An earlier transcript handoff is pending. Retry the earlier task entry before sending a different prompt');
|
|
35
|
+
}
|
|
36
|
+
if (guard?.value?.phase === 'beginning') return pending('Task entry was interrupted before its new ownership was recorded. Start a new host session and resume the intended Agent; this uncertain native tail was not imported');
|
|
37
|
+
if (guard?.value?.phase === 'scan') {
|
|
38
|
+
for (let page = 0; page < 8; page++) {
|
|
39
|
+
const state = guard.value;
|
|
40
|
+
const batch = await readTranscriptBatch(input.transcript_path, input.session_id, state.scanCursor, adapter,
|
|
41
|
+
{ stopBeforeDigest: state.prompt });
|
|
42
|
+
const next = { ...state, scanCursor: batch.cursor,
|
|
43
|
+
userMessages: state.userMessages + (batch.userMessages ?? 0), omitted: state.omitted || batch.omitted === true,
|
|
44
|
+
uncertainFrom: state.uncertainFrom ?? batch.uncertainFrom ?? null };
|
|
45
|
+
if (batch.atPromptBoundary) {
|
|
46
|
+
await guard.write({ ...next, scanCursor: batch.promptEndCursor,
|
|
47
|
+
candidate: batch.cursor, userMessages: next.userMessages + 1 });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// A repeated prompt in old backlog is not the new turn if visible
|
|
51
|
+
// messages follow it. Only the last visible prompt can prove a handoff.
|
|
52
|
+
if (batch.events.length) next.candidate = null;
|
|
53
|
+
// EOF is a prospective boundary only if no unverified user record or
|
|
54
|
+
// omitted fragment was traversed and the last native write is complete.
|
|
55
|
+
if (!batch.hasMore && batch.completeRecord && (Number.isSafeInteger(next.candidate) || (!next.userMessages && !next.omitted))) {
|
|
56
|
+
await guard.write({ ...next, phase: 'ready', boundary: next.candidate ?? batch.cursor });
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
if (!batch.hasMore && next.uncertainFrom !== null) next.ambiguousFrom = next.uncertainFrom;
|
|
60
|
+
await guard.write(next);
|
|
61
|
+
if (!batch.hasMore) return pending('The current prompt could not be verified in native history. Retry after the host finishes writing; if it cannot record a blocked prompt, start a new host session and resume the intended Agent');
|
|
62
|
+
if (batch.cursor === state.scanCursor) return pending(retryReason);
|
|
63
|
+
}
|
|
64
|
+
if (guard.value.phase === 'scan') return pending(retryReason);
|
|
65
|
+
}
|
|
66
|
+
if (guard?.value && (session.cursor ?? 0) > guard.value.boundary) return pending('Transcript cursor moved beyond the verified handoff boundary; no scope was changed');
|
|
67
|
+
let result, userMessages = 0, omitted = false;
|
|
68
|
+
for (let page = 0; page < 8; page += 1) {
|
|
69
|
+
const batch = await readTranscriptBatch(input.transcript_path, input.session_id, session.cursor ?? 0, adapter,
|
|
70
|
+
guard?.value ? { stopAtCursor: Math.max(session.cursor ?? 0, guard.value.ambiguousFrom ?? guard.value.boundary) } : { stopBeforePrompt: input.prompt ?? null });
|
|
71
|
+
userMessages += batch.userMessages ?? 0; omitted ||= batch.omitted === true;
|
|
72
|
+
// Without a durable guard, an unmatched user record cannot be attributed
|
|
73
|
+
// to the prior owner merely because the file currently ends after it.
|
|
74
|
+
if (!guard && typeof input.prompt === 'string' && userMessages && !batch.atPromptBoundary) return pending(retryReason);
|
|
75
|
+
result = await application.personal.conversation('append', { ...scope, session_id: input.session_id,
|
|
76
|
+
task_context_token: task.token, events: batch.events, expected_cursor: session.cursor ?? 0, cursor: batch.cursor });
|
|
77
|
+
if (result.status === 'saved') committedPages++;
|
|
78
|
+
if (!batch.hasMore && guard?.value?.ambiguousFrom !== undefined && batch.cursor < guard.value.boundary) {
|
|
79
|
+
const boundary = guard.value.boundary;
|
|
80
|
+
result = await application.personal.conversation('append', { ...scope, session_id: input.session_id,
|
|
81
|
+
task_context_token: task.token, expected_cursor: batch.cursor, cursor: boundary,
|
|
82
|
+
events: [{ event_id: `jsonl-v1:${batch.cursor}:${boundary}:ambiguous`, role: 'tool', kind: 'tool_result',
|
|
83
|
+
content: '[Visible transcript content omitted: ambiguous task ownership; original remains in native history.]' }] });
|
|
84
|
+
batch.cursor = boundary;
|
|
85
|
+
}
|
|
86
|
+
if (!batch.hasMore) return { ...result, cursor: batch.cursor,
|
|
87
|
+
atPromptBoundary: Boolean(guard?.value || batch.atPromptBoundary || (typeof input.prompt === 'string' && !userMessages && !omitted && batch.completeRecord)),
|
|
88
|
+
entryBlocked: Boolean(guard?.value && typeof input.prompt !== 'string'),
|
|
89
|
+
coverage: 'supported_visible_events', parser: 'jsonl-v1', ...(guard?.value && typeof input.prompt !== 'string' ? { reason: retryReason } : {}) };
|
|
90
|
+
if (batch.cursor === (session.cursor ?? 0)) break;
|
|
91
|
+
session = { ...session, cursor: batch.cursor };
|
|
92
|
+
}
|
|
93
|
+
return pending(retryReason);
|
|
94
|
+
} catch {
|
|
95
|
+
return { status: committedPages ? 'partial' : 'unsaved', entryBlocked: typeof input.prompt === 'string',
|
|
96
|
+
reason: committedPages ? 'Some transcript pages were saved before capture failed; the next hook resumes from committed progress'
|
|
97
|
+
: 'Visible transcript capture could not be confirmed; native history will be retried at the next hook' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function recordTranscriptTaskEntry(guard, context) {
|
|
102
|
+
if (!guard?.value) return;
|
|
103
|
+
const token = context?.taskContextToken ?? context?.task_context_token;
|
|
104
|
+
if (typeof token !== 'string' || !token) throw new Error('Task entry did not return its ownership token');
|
|
105
|
+
await guard.write({ ...guard.value, phase: 'claim', nextOwner: transcriptDigest(token) });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function withTranscriptNotice(output, sync) {
|
|
109
|
+
if (!sync?.entryBlocked && !['unsaved', 'partial'].includes(sync?.status)) return output;
|
|
110
|
+
return { ...output, systemMessage: [output.systemMessage, `Fuli: ${sync.reason}.`].filter(Boolean).join('\n') };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A scope switch is safe only after the old scope was flushed up to the current
|
|
114
|
+
// prompt. Failed flushes keep the old owner, so new Agents cannot inherit an
|
|
115
|
+
// unrelated session tail. Native files remain available for explicit recovery.
|
|
116
|
+
export async function claimTranscriptBoundary(application, input, sourceApplication, sync, adapter, guard = null) {
|
|
117
|
+
if (!application.getCapturePolicy?.().enabled || !input.transcript_path) return sync;
|
|
118
|
+
try {
|
|
119
|
+
const task = await application.personal.currentTaskContext({ personal_space_id: application.config.personal.spaceId,
|
|
120
|
+
session_id: input.session_id, source_application: sourceApplication });
|
|
121
|
+
if (!task?.project_agent_id) return sync;
|
|
122
|
+
if (guard?.value && (guard.value.phase !== 'claim' || guard.value.nextOwner !== transcriptDigest(task.token))) return pending('Transcript claim does not match the task entry that created it');
|
|
123
|
+
const scope = { personal_space_id: application.config.personal.spaceId,
|
|
124
|
+
personal_project_id: task.personal_project_id, agent_id: task.project_agent_id,
|
|
125
|
+
source_application: sourceApplication, session_id: input.session_id };
|
|
126
|
+
const policy = await application.personal.conversation('query', { ...scope, mode: 'policy' });
|
|
127
|
+
if (!policy.enabled) return { status: 'capture_disabled' };
|
|
128
|
+
const session = await application.personal.conversation('query', { ...scope, mode: 'session' });
|
|
129
|
+
if (!session.initialized) {
|
|
130
|
+
const cursor = await initialTranscriptBoundary(input.transcript_path, input.session_id, input.prompt, adapter);
|
|
131
|
+
await application.personal.conversation('boundary', { ...scope, task_context_token: task.token,
|
|
132
|
+
expected_cursor: 0, cursor, initialize_cursor: true });
|
|
133
|
+
return { status: 'ready', coverage: 'from_current_task' };
|
|
134
|
+
}
|
|
135
|
+
if (sync?.status === 'saved' && sync.atPromptBoundary) {
|
|
136
|
+
await application.personal.conversation('boundary', { ...scope, task_context_token: task.token,
|
|
137
|
+
expected_cursor: sync.cursor, cursor: sync.cursor });
|
|
138
|
+
await guard?.clear();
|
|
139
|
+
}
|
|
140
|
+
return sync;
|
|
141
|
+
} catch {
|
|
142
|
+
return { status: 'unsaved', entryBlocked: true, reason: 'Transcript boundary is unverified; saved task context is still available' };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Preserve every effective instruction; remove evidence envelopes from the hot
|
|
2
|
+
// path. Full records remain available through explicit read tools.
|
|
3
|
+
export function compactTaskContext(value) {
|
|
4
|
+
if (!value || typeof value !== 'object') return value;
|
|
5
|
+
const result = { ...value };
|
|
6
|
+
if (Array.isArray(value.effective_preferences)) result.effective_preferences = value.effective_preferences.map(item => ({
|
|
7
|
+
instruction: item.instruction, preference_key: item.preference_key,
|
|
8
|
+
preference_scope: item.preference_scope, confirmation_status: item.confirmation_status
|
|
9
|
+
}));
|
|
10
|
+
const continuity = value.project_agent_context;
|
|
11
|
+
if (continuity?.memory) {
|
|
12
|
+
const memory = continuity.memory;
|
|
13
|
+
const current = memory.current;
|
|
14
|
+
const notes = current?.memory;
|
|
15
|
+
result.project_agent_context = { ...continuity, knowledge: undefined, recent_tasks: undefined,
|
|
16
|
+
memory: { ...memory, history: undefined, workLog: undefined,
|
|
17
|
+
current: current && { ...current, memory: notes && {
|
|
18
|
+
summary: notes.summary?.slice(0, 800),
|
|
19
|
+
decisions: notes.decisions?.slice(0, 3).map(note => note.slice(0, 160)),
|
|
20
|
+
openThreads: notes.openThreads?.slice(0, 3).map(note => note.slice(0, 160)),
|
|
21
|
+
nextActions: notes.nextActions?.slice(0, 3).map(note => note.slice(0, 160))
|
|
22
|
+
} },
|
|
23
|
+
view: 'preview', requiresFullReadBeforeWrite: true,
|
|
24
|
+
guidance: 'Use get_project_agent_memory before merging or checkpointing working memory; this preview omits history and may truncate notes.'
|
|
25
|
+
} };
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, lstat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { createServer } from 'node:net';
|
|
5
|
+
|
|
6
|
+
export const transcriptDigest = value => createHash('sha256').update(value).digest('hex');
|
|
7
|
+
|
|
8
|
+
// Only opaque hashes and byte offsets are stored locally. The per-session lock
|
|
9
|
+
// covers scan, begin, and claim; a second hook must retry rather than race it.
|
|
10
|
+
export async function withTranscriptGuard(runtimeConfigPath, input, source, enabled, operation) {
|
|
11
|
+
if (!enabled || !input?.transcript_path || !input?.session_id) return operation(null);
|
|
12
|
+
const directory = join(dirname(runtimeConfigPath), 'transcript-guards');
|
|
13
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
14
|
+
if (!(await lstat(directory)).isDirectory()) throw new Error('Transcript guard directory is unavailable');
|
|
15
|
+
const key = transcriptDigest(`${runtimeConfigPath}\0${source}\0${input.session_id}`);
|
|
16
|
+
const path = join(directory, `${key}.json`);
|
|
17
|
+
// A loopback mutex is released by the OS even if the process is killed.
|
|
18
|
+
// Port collisions fail closed and request a retry; no transcript is served.
|
|
19
|
+
const lock = createServer(socket => socket.destroy());
|
|
20
|
+
const lockKey = transcriptDigest(`${directory}\0${key}`);
|
|
21
|
+
const port = 49152 + (parseInt(lockKey.slice(0, 8), 16) % 16384);
|
|
22
|
+
await new Promise((resolve, reject) => {
|
|
23
|
+
lock.once('error', reject);
|
|
24
|
+
lock.listen({ host: '127.0.0.1', port, exclusive: true }, resolve);
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
let value = null;
|
|
28
|
+
try {
|
|
29
|
+
if (!(await lstat(path)).isFile()) throw new Error('Invalid transcript guard');
|
|
30
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
31
|
+
if (value.version !== 1 || !['scan', 'ready', 'beginning', 'claim'].includes(value.phase)
|
|
32
|
+
|| !/^[a-f0-9]{64}$/.test(value.owner ?? '') || !/^[a-f0-9]{64}$/.test(value.prompt ?? '')
|
|
33
|
+
|| !Number.isSafeInteger(value.scanCursor) || value.scanCursor < 0
|
|
34
|
+
|| (value.boundary !== undefined && (!Number.isSafeInteger(value.boundary) || value.boundary < 0))) {
|
|
35
|
+
throw new Error('Invalid transcript guard state');
|
|
36
|
+
}
|
|
37
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
38
|
+
const guard = {
|
|
39
|
+
get value() { return value; },
|
|
40
|
+
async write(next) {
|
|
41
|
+
const temporary = `${path}.${randomUUID()}`;
|
|
42
|
+
try {
|
|
43
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
44
|
+
try { await file.writeFile(JSON.stringify(next)); await file.sync(); }
|
|
45
|
+
finally { await file.close(); }
|
|
46
|
+
await rename(temporary, path);
|
|
47
|
+
value = next;
|
|
48
|
+
} finally { await rm(temporary, { force: true }); }
|
|
49
|
+
},
|
|
50
|
+
async clear() { await rm(path, { force: true }); value = null; }
|
|
51
|
+
};
|
|
52
|
+
return await operation(guard);
|
|
53
|
+
} finally { await new Promise(resolve => lock.close(resolve)); }
|
|
54
|
+
}
|