ctxpool 0.1.0 → 0.4.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.
Files changed (58) hide show
  1. package/README.md +95 -105
  2. package/dist/cli.js +328 -184
  3. package/dist/cli.js.map +1 -1
  4. package/dist/clipboard.js +8 -10
  5. package/dist/clipboard.js.map +1 -1
  6. package/dist/format.d.ts +32 -0
  7. package/dist/format.js +110 -0
  8. package/dist/format.js.map +1 -0
  9. package/dist/handoff.d.ts +16 -13
  10. package/dist/handoff.js +441 -706
  11. package/dist/handoff.js.map +1 -1
  12. package/dist/mcp.js +99 -221
  13. package/dist/mcp.js.map +1 -1
  14. package/dist/pack.d.ts +14 -0
  15. package/dist/pack.js +121 -0
  16. package/dist/pack.js.map +1 -0
  17. package/dist/paths.d.ts +15 -0
  18. package/dist/paths.js +35 -5
  19. package/dist/paths.js.map +1 -1
  20. package/dist/redact.d.ts +6 -0
  21. package/dist/redact.js +85 -0
  22. package/dist/redact.js.map +1 -0
  23. package/dist/repo.d.ts +6 -1
  24. package/dist/repo.js +109 -123
  25. package/dist/repo.js.map +1 -1
  26. package/dist/session.d.ts +40 -1
  27. package/dist/session.js +1295 -302
  28. package/dist/session.js.map +1 -1
  29. package/dist/store.d.ts +13 -6
  30. package/dist/store.js +88 -115
  31. package/dist/store.js.map +1 -1
  32. package/dist/text.d.ts +6 -0
  33. package/dist/text.js +25 -0
  34. package/dist/text.js.map +1 -0
  35. package/dist/types.d.ts +44 -7
  36. package/dist/ui.d.ts +10 -1
  37. package/dist/ui.js +329 -383
  38. package/dist/ui.js.map +1 -1
  39. package/dist/util.d.ts +10 -1
  40. package/dist/util.js +40 -10
  41. package/dist/util.js.map +1 -1
  42. package/dist/verify.d.ts +16 -0
  43. package/dist/verify.js +74 -0
  44. package/dist/verify.js.map +1 -0
  45. package/dist/version.d.ts +1 -0
  46. package/dist/version.js +4 -0
  47. package/dist/version.js.map +1 -0
  48. package/dist-web/assets/contextpool-mark-4vSRiUeZ.svg +1 -0
  49. package/dist-web/assets/index-BBjy_S2c.css +2 -0
  50. package/dist-web/assets/index-QwGbBEu2.js +17 -0
  51. package/dist-web/index.html +4 -3
  52. package/package.json +19 -18
  53. package/dist/logs.d.ts +0 -9
  54. package/dist/logs.js +0 -82
  55. package/dist/logs.js.map +0 -1
  56. package/dist-web/assets/contextpool-mark-Bu_00i5t.svg +0 -16
  57. package/dist-web/assets/index-BfHRocKl.js +0 -14
  58. package/dist-web/assets/index-l1QjYqDb.css +0 -2
package/dist/handoff.js CHANGED
@@ -1,262 +1,148 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { ensureCtxDirs } from "./paths.js";
4
+ import { redactDeep, redactSecrets } from "./redact.js";
4
5
  import { captureGitSnapshot, inferTaskName } from "./repo.js";
5
- import { resolveSessionSource } from "./session.js";
6
- import { hashText, limit, slugify, unique } from "./util.js";
6
+ import { resolveSessionSource, sourceLabel } from "./session.js";
7
+ import { estimateTokens, oneLine, truncate } from "./text.js";
8
+ import { canonicalPath, hashText, limit, slugify, unique } from "./util.js";
9
+ import { isVerificationCommand, verificationKey, verificationLabel } from "./verify.js";
10
+ import { VERSION } from "./version.js";
11
+ export { createContextPack, normalizeContextPack, renderContextPackOutput } from "./pack.js";
12
+ // Agents narrate obstacles they then work around; only their latest messages describe the current state.
13
+ const BLOCKER_WINDOW = 2;
14
+ const TODO_WINDOW = 4;
7
15
  export async function createHandoff(options = {}) {
8
- const cwd = options.cwd || process.cwd();
16
+ const cwd = resolve(options.cwd || process.cwd());
9
17
  const paths = ensureCtxDirs();
10
18
  const repo = captureGitSnapshot(cwd);
11
19
  const task = inferTaskName(options.task, repo);
12
- const sourceSession = resolveSessionSource({
20
+ const sourceSession = withRepoRelativeEdits(redactDeep(resolveSessionSource({
13
21
  from: options.from || "auto",
14
22
  cwd,
23
+ repoRoot: repo.isGitRepo ? repo.repoPath : undefined,
15
24
  session: options.session,
16
25
  budget: options.budget
17
- });
18
- const sources = sourceSessionToSignals(sourceSession);
26
+ })), repo.repoPath);
19
27
  const sections = buildSections(task, repo.dirtyFiles, repo.diffStat, sourceSession);
20
28
  const handoffSummary = buildHandoffSummary(task, repo, sourceSession, sections);
21
29
  const createdAt = new Date().toISOString();
22
30
  const id = `${createdAt.replace(/[:.]/g, "-")}-${hashText(`${task}:${createdAt}`)}`;
23
- if (options.useOllama !== false) {
24
- const improved = await improveWithOllama(task, sections).catch(() => undefined);
31
+ if (options.useOllama) {
32
+ const improved = await suggestNextActionWithOllama(task, handoffSummary, options.ollamaModel).catch(() => undefined);
25
33
  if (improved) {
26
34
  sections.nextBestAction = improved;
27
35
  handoffSummary.nextAction = improved;
28
36
  }
29
37
  }
30
- const engineeringMemory = buildEngineeringMemory(task, repo, sourceSession, id, handoffSummary);
31
- const markdown = renderHandoffMarkdown({
38
+ const packet = {
32
39
  id,
33
40
  task,
34
41
  createdAt,
35
42
  targetAgent: options.to,
36
43
  repo,
37
- sources,
44
+ sources: [{ source: sourceSession.source, path: sourceSession.path, modifiedAt: sourceSession.updatedAt, snippets: [] }],
38
45
  sourceSession,
39
46
  sections,
40
47
  handoffSummary,
41
- engineeringMemory,
48
+ engineeringMemory: buildEngineeringMemory(task, repo, sourceSession, id, handoffSummary),
42
49
  markdown: "",
43
50
  markdownPath: ""
44
- }, options.includeTranscript !== false);
51
+ };
52
+ packet.markdown = renderHandoffMarkdown(packet, options.includeTranscript !== false);
53
+ packet.tokenEstimate = estimateTokens(packet.markdown);
45
54
  const taskDir = join(paths.handoffsDir, slugify(task));
46
55
  mkdirSync(taskDir, { recursive: true });
47
- const markdownPath = join(taskDir, `${createdAt.replace(/[:.]/g, "-")}.md`);
48
- writeFileSync(markdownPath, markdown, "utf8");
49
- return {
50
- id,
51
- task,
52
- createdAt,
53
- targetAgent: options.to,
54
- repo,
55
- sources,
56
- sourceSession,
57
- sections,
58
- handoffSummary,
59
- engineeringMemory,
60
- markdown,
61
- markdownPath
62
- };
63
- }
56
+ packet.markdownPath = join(taskDir, `${createdAt.replace(/[:.]/g, "-")}.md`);
57
+ writeFileSync(packet.markdownPath, packet.markdown, { encoding: "utf8", mode: 0o600 });
58
+ return packet;
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Extraction
62
+ /**
63
+ * Pull the working state out of a session. Structured agent data (plans, command exit codes, edits)
64
+ * wins; prose heuristics fill gaps and only read the user's and the agent's own words, never tool output.
65
+ */
64
66
  export function buildSections(task, filesTouched, diffStat, sourceSessionOrSources) {
65
- const sourceSession = Array.isArray(sourceSessionOrSources) ? undefined : sourceSessionOrSources;
66
- const sources = Array.isArray(sourceSessionOrSources) ? sourceSessionOrSources : sourceSessionToSignals(sourceSessionOrSources);
67
- const messageSnippets = sourceSession
68
- ? sourceSession.transcript.filter((entry) => entry.role !== "tool").flatMap((entry) => segmentText(entry.text)).map(toHandoffSignal).filter(Boolean)
69
- : sources.flatMap((source) => source.snippets);
70
- const toolSnippets = sourceSession
71
- ? sourceSession.transcript.filter((entry) => entry.role === "tool").flatMap((entry) => segmentText(entry.text)).map(toHandoffSignal).filter(Boolean)
72
- : [];
73
- const operationalSnippets = [...messageSnippets, ...toolSnippets];
74
- const completed = match(messageSnippets, /(done|completed|fixed|implemented|added|created|updated|changed)/i, 8);
75
- const todos = match(messageSnippets, /(todo|need to|remaining|follow.?up|please implement|implement this plan|make sure|fix\b|add\b|replace\b|update\b|continue\b)/i, 10);
76
- const blockers = match(messageSnippets, /(blocked|blocker|stuck|missing|cannot|can't|failed because|error|failure)/i, 8);
77
- const errors = match(operationalSnippets, /(error|failed|failure|exception|traceback|test failed|build failed)/i, 8);
78
- const decisions = match(messageSnippets, /(decision|decided|use |prefer |do not|don't|avoid)/i, 8);
79
- const commandsRun = match(toolSnippets, /\b(npm|pnpm|yarn|bun|git|node|tsx|tsc|vitest|pytest|cargo|go test)\b/i, 12);
80
- const transcriptFiles = extractFilePaths(messageSnippets);
81
- const mergedFilesTouched = limit(unique([...filesTouched, ...transcriptFiles]), 40);
82
- const next = todos[0] || blockers[0] || "Review the changed files, run the relevant tests, and continue from the current git diff.";
67
+ const session = Array.isArray(sourceSessionOrSources) ? undefined : sourceSessionOrSources;
68
+ const legacySnippets = Array.isArray(sourceSessionOrSources) ? sourceSessionOrSources.flatMap((source) => source.snippets) : [];
69
+ const userMessages = session ? session.transcript.filter((entry) => entry.role === "user").map((entry) => entry.text) : [];
70
+ const assistantMessages = session ? session.transcript.filter((entry) => entry.role === "assistant").map((entry) => entry.text) : [];
71
+ const allProse = session ? assistantMessages.flatMap(sentences) : legacySnippets.flatMap(sentences);
72
+ const recent = (count) => (session ? assistantMessages.slice(-count).flatMap(sentences) : allProse);
73
+ const plan = session?.plan;
74
+ const completed = plan ? plan.filter((item) => item.status === "completed").map((item) => item.text) : pick(allProse, COMPLETED, 8);
75
+ const inProgress = plan ? plan.filter((item) => item.status === "in_progress").map((item) => item.text) : [];
76
+ const todos = plan ? plan.filter((item) => item.status !== "completed").map((item) => item.text) : pick(recent(TODO_WINDOW), TODO, 10);
77
+ const blockers = pick(recent(BLOCKER_WINDOW), BLOCKER, 6);
78
+ const verification = summarizeVerification(session, legacySnippets);
79
+ const errors = session
80
+ ? verification.commands.filter((command) => command.status === "failed").map((command) => `\`${command.command}\` failed${command.evidence ? `: ${command.evidence}` : ""}`)
81
+ : pick(legacySnippets, /\b(error|failed|failure|exception|traceback)\b/i, 8);
82
+ const commandsRun = session ? limit((session.commands || []).map((run) => run.command), 20) : pick(legacySnippets, SHELL_TOOL, 12);
83
+ const editedFiles = (session?.edits || []).map((edit) => edit.path);
84
+ const mentionedFiles = session ? [] : extractFilePaths(legacySnippets);
85
+ const decisions = pick(allProse, DECISION, 8);
86
+ // The first and latest requests are rendered in full, so constraints come from the messages in between.
87
+ const constraints = limit([...userMessages.slice(1, -1).flatMap(sentences).filter((sentence) => CONSTRAINT.test(sentence)), ...pick(allProse, DEAD_END, 4)], 8);
88
+ const changedFiles = limit([...filesTouched, ...editedFiles, ...mentionedFiles], 40);
83
89
  return {
84
- goal: `Continue the ${task} task without re-discovering prior context.`,
85
- currentStatus: limit([
86
- filesTouched.length > 0 ? `${filesTouched.length} dirty file(s) are present in the working tree.` : "No dirty files were detected in the working tree.",
87
- diffStat ? `Current diff summary: ${diffStat.split("\n").join("; ")}` : "No diff stat is available.",
88
- sourceSession
89
- ? `Exported ${sourceSession.transcript.length} transcript item(s) from ${sourceSession.source} session ${sourceSession.id}.`
90
- : sources.length > 0 ? `Imported signals from ${sources.length} recent agent log file(s).` : "No recent Codex or Claude log signals were found."
91
- ], 6),
90
+ goal: `Continue the ${task} task.`,
91
+ currentStatus: [
92
+ changedFiles.length ? `${changedFiles.length} changed file(s)${diffStat ? ` (${diffStat})` : ""}.` : "Working tree is clean.",
93
+ `Verification: ${verification.status}.`
94
+ ],
92
95
  completed,
93
- inProgress: limit([...todos.slice(0, 4), ...mergedFilesTouched.slice(0, 6).map((file) => `Work appears active around ${file}.`)], 8),
96
+ inProgress,
94
97
  todos,
95
98
  blockers,
96
- filesTouched: mergedFilesTouched,
99
+ filesTouched: changedFiles,
97
100
  commandsRun,
98
101
  errors,
99
102
  decisions,
100
- doNotRepeat: match(messageSnippets, /(do not|don't|avoid|never|not repeat|already tried)/i, 8),
101
- nextBestAction: next
103
+ doNotRepeat: constraints,
104
+ nextBestAction: chooseNextAction({
105
+ verification,
106
+ blockers,
107
+ plan,
108
+ todos,
109
+ changedFileCount: changedFiles.length,
110
+ latestUserMessage: userMessages.at(-1)
111
+ })
102
112
  };
103
113
  }
104
- export function renderHandoffMarkdown(packet, includeTranscript = true) {
105
- const normalized = normalizeHandoffPacket(packet);
106
- const summary = normalized.handoffSummary;
107
- const sourceLines = packet.sources.map((source) => `- ${source.source}: ${source.path} (${source.modifiedAt})`);
108
- const session = packet.sourceSession;
109
- return [
110
- `# Handoff: ${packet.task}`,
111
- "",
112
- `Generated: ${packet.createdAt}`,
113
- packet.targetAgent ? `Target agent: ${packet.targetAgent}` : "",
114
- "",
115
- "## Original Question",
116
- summary?.originalQuestion || "Not captured.",
117
- "",
118
- "## Latest User Instruction",
119
- summary?.latestUserInstruction || "Not captured.",
120
- "",
121
- "## Current State",
122
- summary ? renderCurrentState(summary) : "Not captured.",
123
- "",
124
- "## AI Plan",
125
- summary?.agentPlan || "Not captured.",
126
- "",
127
- "## Goal",
128
- packet.sections.goal,
129
- "",
130
- "## What Changed",
131
- list(formatFileChanges(summary?.fileChanges || [])),
132
- "",
133
- "## Current Status",
134
- list(packet.sections.currentStatus),
135
- "",
136
- "## Completed",
137
- list(packet.sections.completed),
138
- "",
139
- "## In Progress",
140
- list(packet.sections.inProgress),
141
- "",
142
- "## Leftover Work",
143
- list(summary?.remainingWork || packet.sections.todos),
144
- "",
145
- "## Blockers",
146
- list(packet.sections.blockers),
147
- "",
148
- "## Files Touched",
149
- list(packet.sections.filesTouched),
150
- "",
151
- "## Commands Run",
152
- list(packet.sections.commandsRun),
153
- "",
154
- "## Verification",
155
- summary ? renderVerification(summary.verification) : "- Unknown.",
156
- "",
157
- "## Errors and Test Failures",
158
- list(packet.sections.errors),
159
- "",
160
- "## Important Decisions",
161
- list(packet.sections.decisions),
162
- "",
163
- "## Do Not Repeat",
164
- list(packet.sections.doNotRepeat),
165
- "",
166
- "## Engineering Memory",
167
- normalized.engineeringMemory ? renderEngineeringMemory(normalized.engineeringMemory) : "- Not captured.",
168
- "",
169
- "## Next Best Action",
170
- summary?.nextAction || packet.sections.nextBestAction,
171
- "",
172
- "## Source Session",
173
- ...(session
174
- ? [
175
- `- Agent: ${session.source}`,
176
- `- Session ID: ${session.id}`,
177
- `- Session path: ${session.path}`,
178
- `- Session cwd: ${session.cwd || "unknown"}`,
179
- `- Matched cwd: ${session.matchedCwd ? "yes" : "no"}`,
180
- `- Updated: ${session.updatedAt}`,
181
- `- Transcript items: ${session.transcript.length}`,
182
- `- Session lines: ${session.lineCount}`,
183
- ...(session.threadName ? [`- Thread: ${session.threadName}`] : []),
184
- ...(session.supplementalPaths?.length ? [`- Supplemental paths: ${session.supplementalPaths.join(", ")}`] : [])
185
- ]
186
- : sourceLines.length ? sourceLines : ["- None captured."]),
187
- "",
188
- ...(includeTranscript
189
- ? [
190
- "## Exported Transcript Excerpt",
191
- session?.transcriptExcerpt || "No transcript excerpt captured.",
192
- ""
193
- ]
194
- : []),
195
- "",
196
- "## Source Provenance",
197
- `- Repo: ${packet.repo.repoPath}`,
198
- `- Branch: ${packet.repo.branch}`,
199
- `- Commit: ${packet.repo.commit}`,
200
- `- Git repo: ${packet.repo.isGitRepo ? "yes" : "no"}`,
201
- ...sourceLines,
202
- ""
203
- ]
204
- .filter((line) => line !== "")
205
- .join("\n");
206
- }
207
- export function normalizeHandoffPacket(packet) {
208
- const repo = {
209
- ...packet.repo,
210
- fileChanges: packet.repo.fileChanges?.length ? packet.repo.fileChanges : legacyFileChanges(packet.repo.dirtyFiles || [])
211
- };
212
- const derivedSummary = buildHandoffSummary(packet.task, repo, packet.sourceSession, packet.sections);
213
- const existingSummary = packet.handoffSummary;
214
- const handoffSummary = existingSummary
215
- ? {
216
- ...derivedSummary,
217
- ...existingSummary,
218
- fileChanges: existingSummary.fileChanges?.length ? existingSummary.fileChanges : derivedSummary.fileChanges,
219
- verification: existingSummary.verification || derivedSummary.verification,
220
- repoStats: existingSummary.repoStats || derivedSummary.repoStats,
221
- contextSignals: existingSummary.contextSignals?.length ? existingSummary.contextSignals : derivedSummary.contextSignals,
222
- sourceSession: existingSummary.sourceSession || derivedSummary.sourceSession,
223
- repo: existingSummary.repo || derivedSummary.repo
224
- }
225
- : derivedSummary;
226
- const engineeringMemory = packet.engineeringMemory || buildEngineeringMemory(packet.task, repo, packet.sourceSession, packet.id, handoffSummary);
227
- return { ...packet, repo, handoffSummary, engineeringMemory };
228
- }
229
114
  export function buildHandoffSummary(task, repo, sourceSession, sections) {
230
115
  const fileChanges = repo.fileChanges?.length ? repo.fileChanges : legacyFileChanges(repo.dirtyFiles || []);
231
- const originalQuestion = sourceSession ? firstUsefulUserMessage(sourceSession) : "";
232
- const latestUserInstruction = sourceSession ? latestUsefulUserMessage(sourceSession) : "";
233
- const latestAssistantSummary = sourceSession ? latestAssistantMessage(sourceSession) : "";
234
- const agentPlan = sourceSession ? latestPlanMessage(sourceSession) : "";
235
- const verification = inferVerification(sections, sourceSession);
236
- const repoStats = summarizeRepoStats(fileChanges);
237
- const remainingWork = meaningfulList(sections.todos, [
238
- "No explicit leftover work was captured. Review the changed files and run the relevant tests."
239
- ]);
240
- const inProgressWork = meaningfulList(sections.inProgress.filter((item) => !/^Work appears active around /i.test(item)), fileChanges.length ? [`Active work is concentrated in ${fileChanges.length} changed file(s).`] : []);
116
+ const userMessages = sourceSession?.transcript.filter((entry) => entry.role === "user").map((entry) => entry.text) || [];
117
+ const assistantMessages = sourceSession?.transcript.filter((entry) => entry.role === "assistant").map((entry) => entry.text) || [];
118
+ const verification = summarizeVerification(sourceSession, sections.commandsRun);
119
+ const plan = sourceSession?.plan;
120
+ const agentPlan = sourceSession?.planText || (plan ? plan.map(formatPlanItem).join("\n") : latestPlanMessage(assistantMessages));
121
+ const originalQuestion = truncate(userMessages[0] || "", 1_500);
122
+ const latestUserInstruction = truncate(userMessages.at(-1) || "", 1_500);
123
+ const latestAssistantSummary = truncate(assistantMessages.at(-1) || "", 1_500);
241
124
  return {
242
125
  version: 1,
126
+ title: sourceSession?.threadName,
243
127
  originalQuestion: originalQuestion || `Continue the ${task} task.`,
244
128
  latestUserInstruction: latestUserInstruction || originalQuestion || `Continue the ${task} task.`,
245
- latestAssistantSummary: latestAssistantSummary || "No assistant progress summary was captured.",
246
- agentPlan: agentPlan || "No explicit AI plan was captured in the source session.",
247
- workPhase: inferWorkPhase(sections, verification, agentPlan, fileChanges),
129
+ latestAssistantSummary,
130
+ agentPlan,
131
+ workPhase: inferWorkPhase(sections, verification, plan, agentPlan, fileChanges),
248
132
  verification,
249
- repoStats,
133
+ repoStats: summarizeRepoStats(fileChanges),
250
134
  contextSignals: buildContextSignals(sections, latestUserInstruction || originalQuestion, latestAssistantSummary),
251
- completedWork: meaningfulList(sections.completed, []),
252
- inProgressWork,
253
- remainingWork,
135
+ completedWork: sections.completed,
136
+ inProgressWork: sections.inProgress,
137
+ remainingWork: sections.todos,
254
138
  blockers: sections.blockers,
255
139
  nextAction: sections.nextBestAction,
256
140
  fileChanges,
257
141
  commandsRun: sections.commandsRun,
258
142
  errors: sections.errors,
259
143
  decisions: sections.decisions,
144
+ constraints: sections.doNotRepeat,
145
+ plan,
260
146
  sourceSession: sourceSession
261
147
  ? {
262
148
  source: sourceSession.source,
@@ -275,306 +161,108 @@ export function buildHandoffSummary(task, repo, sourceSession, sections) {
275
161
  }
276
162
  };
277
163
  }
278
- export function renderAgentContextMarkdown(packet) {
279
- const normalized = normalizeHandoffPacket(packet);
280
- const summary = normalized.handoffSummary;
281
- return [
282
- `# ContextPool Agent Handoff: ${normalized.task}`,
283
- "",
284
- `Generated: ${normalized.createdAt}`,
285
- `Repo: ${summary?.repo.repoPath || normalized.repo.repoPath}`,
286
- `Branch/commit: ${normalized.repo.branch}@${normalized.repo.commit}`,
287
- "",
288
- "## Original Question",
289
- summary?.originalQuestion || "Not captured.",
290
- "",
291
- "## Latest User Instruction",
292
- summary?.latestUserInstruction || "Not captured.",
293
- "",
294
- "## Current State",
295
- summary ? renderCurrentState(summary) : "Not captured.",
296
- "",
297
- "## AI Plan",
298
- summary?.agentPlan || "Not captured.",
299
- "",
300
- "## Completed",
301
- list(summary?.completedWork || []),
302
- "",
303
- "## In Progress",
304
- list(summary?.inProgressWork || []),
305
- "",
306
- "## Leftover Work",
307
- list(summary?.remainingWork || []),
308
- "",
309
- "## Verification",
310
- summary ? renderVerification(summary.verification) : "- Unknown.",
311
- "",
312
- "## Blockers",
313
- list(summary?.blockers || []),
314
- "",
315
- "## Files Changed",
316
- list(formatFileChanges(summary?.fileChanges || [])),
317
- "",
318
- "## Commands Run",
319
- list(summary?.commandsRun || []),
320
- "",
321
- "## Errors",
322
- list(summary?.errors || []),
323
- "",
324
- "## Engineering Memory",
325
- normalized.engineeringMemory ? renderEngineeringMemory(normalized.engineeringMemory) : "- Not captured.",
326
- "",
327
- "## Next Action",
328
- summary?.nextAction || normalized.sections.nextBestAction
329
- ].join("\n");
330
- }
331
- export function buildAgentContext(packet) {
332
- const normalized = normalizeHandoffPacket(packet);
333
- return {
334
- markdown: renderAgentContextMarkdown(normalized),
335
- summary: normalized.handoffSummary,
336
- engineeringMemory: normalized.engineeringMemory,
337
- packet: normalized
338
- };
339
- }
340
- export function deriveContextBlocks(packet) {
341
- const normalized = normalizeHandoffPacket(packet);
342
- const summary = normalized.handoffSummary;
343
- const memory = normalized.engineeringMemory;
344
- const source = `${summary.sourceSession?.source || "handoff"}:${summary.sourceSession?.id || normalized.id}`;
345
- const blocks = [];
346
- const push = (category, title, text, provenance, confidence, provenanceSource = source) => {
347
- const clean = cleanContextText(text);
348
- if (!clean || /^None captured\.?$/i.test(clean) || /^Not captured\.?$/i.test(clean))
349
- return;
350
- blocks.push({
351
- id: stableId(`${normalized.id}:${category}`, `${title}:${clean}`),
352
- sourceHandoffId: normalized.id,
353
- sourceTask: normalized.task,
354
- sourceCreatedAt: normalized.createdAt,
355
- category,
356
- title,
357
- text: clean,
358
- provenance: { kind: provenance, confidence, source: provenanceSource },
359
- estimatedChars: clean.length
360
- });
361
- };
362
- push("intent", "Original question", summary.originalQuestion, "user-authored", "high");
363
- push("instruction", "Latest user instruction", summary.latestUserInstruction, "user-authored", "high");
364
- push("next-action", "Next action", summary.nextAction, "ai-inferred", "medium");
365
- for (const fact of memory.decisions)
366
- push("decision", "Decision", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
367
- for (const fact of memory.constraints)
368
- push("constraint", "Constraint", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
369
- for (const fact of memory.verification)
370
- push("verification", "Verification", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
371
- for (const fact of memory.risks)
372
- push("risk", "Risk", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
373
- for (const fact of memory.nextActions.slice(1))
374
- push("next-action", "Remaining work", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
375
- for (const fact of memory.changedFiles)
376
- push("changed-file", "Changed file", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
377
- for (const entry of normalized.sourceSession?.transcript || []) {
378
- if (entry.role === "tool")
379
- continue;
380
- const text = cleanContextText(entry.text);
381
- if (!text || text.length < 20)
382
- continue;
383
- push("transcript", `${labelize(entry.role)} transcript`, text, "transcript-derived", "medium", source);
164
+ const COMPLETED = /^(?:done|completed|fixed|implemented|added|created|updated|refactored|removed|shipped|wrote)\b|\b(?:i(?:'ve| have)|we(?:'ve| have)|now)\s+(?:added|fixed|implemented|updated|created|removed|refactored|wired)\b/i;
165
+ const TODO = /\b(?:todo|still need(?:s)? to|left to do|not yet (?:done|implemented|handled)|remaining(?: work)?:|follow[- ]?up:|next step)/i;
166
+ const BLOCKER = /\b(?:blocked|blocker|stuck|cannot proceed|can't proceed|unable to (?:proceed|continue)|waiting (?:on|for) (?:you|the user|approval|access)|need(?:s)? (?:your|user) (?:input|approval|confirmation)|permission denied|is missing|not configured|missing (?:credentials|access|env))\b/i;
167
+ const DECISION = /^(?:decision|decided)\b|\b(?:decided to|chose to|opted to|going with|we(?:'ll| will) use|i(?:'ll| will) use|instead of)\b/i;
168
+ const CONSTRAINT = /\b(?:don't|do not|never|always|must(?! have)|should not|shouldn't|avoid|make sure|only use|without)\b/i;
169
+ const DEAD_END = /\b(?:already tried|didn't work|doesn't work|did not work|reverted|rolled back)\b/i;
170
+ const SHELL_TOOL = /\b(npm|pnpm|yarn|bun|git|node|tsx|tsc|vitest|pytest|cargo|go test)\b/i;
171
+ function pick(snippets, pattern, count) {
172
+ return limit(snippets.filter((snippet) => pattern.test(snippet)).reverse().map((snippet) => truncate(snippet, 300)), count);
173
+ }
174
+ function sentences(text) {
175
+ return text
176
+ .split(/\n+|(?<=[.!?])\s+(?=[A-Z0-9`*])/)
177
+ .map((part) => part.replace(/^[-*>\d.)\s]+/, "").replace(/\*\*/g, "").trim())
178
+ .filter((part) => part.length > 8 && part.length < 600 && !/^```/.test(part));
179
+ }
180
+ function chooseNextAction(input) {
181
+ const failed = input.verification.commands.find((command) => command.status === "failed");
182
+ if (failed)
183
+ return `Fix the failing \`${failed.command}\`${failed.evidence ? ` (${failed.evidence})` : ""}, then re-run it.`;
184
+ if (input.blockers[0])
185
+ return `Resolve the blocker: ${input.blockers[0]}`;
186
+ const active = input.plan?.find((item) => item.status === "in_progress");
187
+ if (active)
188
+ return `Continue: ${active.text}`;
189
+ const pending = input.plan?.find((item) => item.status === "pending");
190
+ if (pending)
191
+ return `Next: ${pending.text}`;
192
+ const unverified = input.verification.gaps.some((gap) => /edited after|No tests/.test(gap));
193
+ if (input.changedFileCount && unverified) {
194
+ const checks = unique(input.verification.commands.map((command) => `\`${command.command}\``)).slice(-2);
195
+ return `Verify the ${input.changedFileCount} changed file(s) with ${checks.length ? checks.join(" and ") : "the project's tests and type checks"}.`;
384
196
  }
385
- return dedupeBlocks(blocks).slice(0, 160);
386
- }
387
- export function createContextPack(name, blocks = [], options = {}) {
388
- const now = new Date().toISOString();
389
- const id = `pack_${hashText(`${name}:${now}`)}`;
390
- return {
391
- id,
392
- name: name.trim() || "Untitled context pack",
393
- createdAt: now,
394
- updatedAt: now,
395
- includeRawTranscript: options.includeRawTranscript === true,
396
- items: blocks.map((block, index) => ({
397
- id: `item_${hashText(`${id}:${block.id}:${index}`)}`,
398
- blockId: block.id,
399
- block,
400
- order: index,
401
- enabled: true
402
- }))
403
- };
404
- }
405
- export function renderContextPackOutput(pack) {
406
- const normalizedPack = normalizeContextPack(pack);
407
- const warnings = buildPackWarnings(normalizedPack);
408
- const enabledItems = normalizedPack.items.filter((item) => item.enabled);
409
- const estimatedChars = enabledItems.reduce((total, item) => total + item.block.text.length, 0);
410
- const markdown = renderContextPackMarkdown(normalizedPack, enabledItems, estimatedChars, warnings);
411
- return {
412
- pack: normalizedPack,
413
- markdown,
414
- json: {
415
- id: normalizedPack.id,
416
- name: normalizedPack.name,
417
- createdAt: normalizedPack.createdAt,
418
- updatedAt: normalizedPack.updatedAt,
419
- includeRawTranscript: normalizedPack.includeRawTranscript,
420
- estimatedChars,
421
- warnings,
422
- items: enabledItems
423
- },
424
- estimatedChars,
425
- warnings
426
- };
427
- }
428
- export function normalizeContextPack(pack) {
429
- const includeRawTranscript = pack.includeRawTranscript === true;
430
- return {
431
- ...pack,
432
- includeRawTranscript,
433
- name: pack.name.trim() || "Untitled context pack",
434
- items: pack.items
435
- .map((item, index) => ({
436
- ...item,
437
- order: Number.isFinite(item.order) ? item.order : index,
438
- enabled: item.enabled !== false,
439
- block: sanitizeBlockForPack(item.block, includeRawTranscript)
440
- }))
441
- .sort((a, b) => a.order - b.order)
442
- .map((item, index) => ({ ...item, order: index }))
443
- };
444
- }
445
- export function buildTeamTimelineExport(packet, options = {}) {
446
- const normalized = normalizeHandoffPacket(packet);
447
- const includeRawTranscript = options.includeRawTranscript === true;
448
- const sourceSession = normalized.sourceSession
449
- ? {
450
- ...normalized.sourceSession,
451
- transcript: includeRawTranscript ? normalized.sourceSession.transcript : [],
452
- transcriptExcerpt: includeRawTranscript ? normalized.sourceSession.transcriptExcerpt : "Raw transcript excluded from team sync export."
453
- }
454
- : undefined;
455
- const sources = includeRawTranscript
456
- ? normalized.sources
457
- : normalized.sources.map((source) => ({ ...source, snippets: [] }));
458
- const engineeringMemory = {
459
- ...normalized.engineeringMemory,
460
- teamSync: {
461
- mode: "sync-ready",
462
- rawTranscript: includeRawTranscript ? "included" : "excluded",
463
- filePreviewRedaction: "enabled",
464
- secretsRedaction: "enabled",
465
- userControlledSync: true
466
- }
467
- };
468
- const exportPacket = {
469
- ...normalized,
470
- sourceSession,
471
- sources,
472
- engineeringMemory,
473
- markdown: "",
474
- markdownPath: normalized.markdownPath
475
- };
476
- return {
477
- ...exportPacket,
478
- markdown: renderHandoffMarkdown(exportPacket, includeRawTranscript)
479
- };
480
- }
481
- function match(snippets, pattern, count) {
482
- return limit(snippets.filter((snippet) => pattern.test(snippet)).reverse(), count);
483
- }
484
- function list(values) {
485
- const clean = unique(values);
486
- if (clean.length === 0)
487
- return "- None captured.";
488
- return clean.map((value) => `- ${value}`).join("\n");
489
- }
490
- function sourceSessionToSignals(session) {
491
- return [
492
- {
493
- source: session.source,
494
- path: session.path,
495
- modifiedAt: session.updatedAt,
496
- snippets: session.transcript.map((entry) => entry.text)
497
- }
498
- ];
499
- }
500
- function firstUsefulUserMessage(session) {
501
- return cleanLongText(session.transcript.find((entry) => entry.role === "user" && toHandoffSignal(entry.text))?.text || "");
502
- }
503
- function latestUsefulUserMessage(session) {
504
- const candidates = session.transcript
505
- .filter((entry) => entry.role === "user")
506
- .map((entry) => cleanLongText(entry.text))
507
- .filter(Boolean);
508
- return candidates.at(-1) || "";
509
- }
510
- function latestAssistantMessage(session) {
511
- const candidates = session.transcript
512
- .filter((entry) => entry.role === "assistant")
513
- .map((entry) => cleanLongText(entry.text))
514
- .filter(Boolean);
515
- return candidates.at(-1) || "";
516
- }
517
- function latestPlanMessage(session) {
518
- const candidates = session.transcript
519
- .filter((entry) => entry.role === "assistant")
520
- .map((entry) => cleanLongText(entry.text))
521
- .filter((text) => /(plan|steps|approach|implementation|todo|next|will|going to|checklist)/i.test(text));
522
- return candidates.at(-1) || "";
523
- }
524
- function cleanLongText(text) {
525
- const clean = toHandoffSignal(text).replace(/\s+/g, " ").trim();
526
- return clean.length > 1200 ? `${clean.slice(0, 1200)}...` : clean;
527
- }
528
- function meaningfulList(values, fallback) {
529
- const clean = unique(values.filter((value) => !/None captured/i.test(value)));
530
- return clean.length ? clean : fallback;
531
- }
532
- function inferVerification(sections, sourceSession) {
533
- const transcriptText = sourceSession?.transcript.map((entry) => entry.text).join("\n") || "";
534
- const evidence = [...sections.commandsRun, ...sections.errors, transcriptText].join("\n");
535
- const verificationCommands = unique(sections.commandsRun.filter((command) => /\b(npm (run )?(test|typecheck|build)|pnpm (test|build)|yarn (test|build)|bun test|vitest|jest|tsc|pytest|cargo test|go test)\b/i.test(command)));
536
- const commands = verificationCommands.map((command) => ({
197
+ if (input.todos[0])
198
+ return input.todos[0];
199
+ if (input.latestUserMessage)
200
+ return `Confirm the latest request is fully handled: “${truncate(oneLine(input.latestUserMessage), 160)}”`;
201
+ return "Review the current diff and continue the task.";
202
+ }
203
+ function summarizeVerification(session, legacyEvidence) {
204
+ const runs = (session?.commands || []).filter((run) => isVerificationCommand(run.command));
205
+ if (!session?.commands)
206
+ return legacyVerification(legacyEvidence);
207
+ const latest = new Map();
208
+ for (const run of runs)
209
+ latest.set(verificationKey(run.command), run);
210
+ const latestRuns = [...latest.values()].sort((a, b) => a.seq - b.seq);
211
+ const commands = latestRuns.map((run) => ({
212
+ command: truncate(verificationLabel(run.command), 160),
213
+ status: run.status,
214
+ exitCode: run.exitCode,
215
+ evidence: run.errorLine
216
+ }));
217
+ const failed = commands.some((command) => command.status === "failed");
218
+ const passed = commands.length > 0 && commands.every((command) => command.status === "passed");
219
+ const lastEdit = Math.max(-1, ...(session.edits || []).map((edit) => edit.seq));
220
+ const lastPass = Math.max(-1, ...latestRuns.filter((run) => run.status === "passed").map((run) => run.seq));
221
+ const stale = passed && lastEdit > lastPass;
222
+ const gaps = [];
223
+ if (!commands.length)
224
+ gaps.push("No tests, type checks, or builds were run in the session.");
225
+ else if (!failed && !passed)
226
+ gaps.push("Some checks ran without a recorded result.");
227
+ if (stale)
228
+ gaps.push("Files were edited after the last passing check.");
229
+ return { status: failed ? "failed" : passed && !stale ? "passed" : "unknown", commands, gaps };
230
+ }
231
+ /** Packets and inputs without structured command data: look for verification commands in text. */
232
+ function legacyVerification(evidence) {
233
+ const commands = unique(evidence.filter(isVerificationCommand)).map((command) => ({
537
234
  command,
538
- status: inferCommandStatus(command, evidence),
539
- evidence: matchingEvidence(command, evidence)
235
+ status: /\b(failed|failure|error)\b/i.test(command) ? "failed" : /\b(passed|succeeded)\b/i.test(command) ? "passed" : "unknown"
540
236
  }));
541
- const failed = commands.some((command) => command.status === "failed") || sections.errors.some((error) => /(test|typecheck|build|lint).{0,80}(fail|error)|failed/i.test(error));
237
+ const failed = commands.some((command) => command.status === "failed");
542
238
  const passed = commands.length > 0 && commands.every((command) => command.status === "passed");
543
- const status = failed ? "failed" : passed ? "passed" : "unknown";
544
- const gaps = commands.length === 0
545
- ? ["No verification command was captured in the source session."]
546
- : status === "unknown"
547
- ? ["Verification commands were captured, but their result was not explicit."]
548
- : [];
549
- return { status, commands, gaps };
550
- }
551
- function inferCommandStatus(command, evidence) {
552
- const commandName = command.replace(/\s+/g, " ").slice(0, 80).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
553
- const nearCommand = new RegExp(`${commandName}[\\s\\S]{0,500}`, "i").exec(evidence)?.[0] || evidence;
554
- if (/(failed|failure|error|exception|exit code [1-9]|Process exited with code [1-9])/i.test(nearCommand))
555
- return "failed";
556
- if (/(passed|success|succeeded|0 failed|Process exited with code 0|✓|✔)/i.test(nearCommand))
557
- return "passed";
558
- return "unknown";
559
- }
560
- function matchingEvidence(command, evidence) {
561
- const commandWords = command.split(/\s+/).filter((word) => word.length > 2).slice(0, 3);
562
- const line = evidence.split("\n").find((candidate) => commandWords.some((word) => candidate.includes(word)));
563
- return line ? cleanLongText(line).slice(0, 240) : undefined;
564
- }
565
- function inferWorkPhase(sections, verification, agentPlan, fileChanges) {
566
- if (sections.blockers.length > 0)
239
+ return {
240
+ status: failed ? "failed" : passed ? "passed" : "unknown",
241
+ commands,
242
+ gaps: commands.length ? (failed || passed ? [] : ["Some checks ran without a recorded result."]) : ["No tests, type checks, or builds were run in the session."]
243
+ };
244
+ }
245
+ function inferWorkPhase(sections, verification, plan, agentPlan, fileChanges) {
246
+ if (sections.blockers.length)
567
247
  return "blocked";
568
- if (verification.status === "failed" || sections.errors.length > 0)
248
+ if (verification.status === "failed")
569
249
  return "debugging";
570
- if (verification.commands.length > 0)
250
+ if (plan?.some((item) => item.status !== "completed"))
251
+ return plan.some((item) => item.status !== "pending") || fileChanges.length ? "implementation" : "planning";
252
+ if (verification.status === "passed")
253
+ return "review";
254
+ if (verification.commands.length)
571
255
  return "verification";
572
- if (sections.todos.length > 0 || fileChanges.length > 0)
256
+ if (sections.todos.length || fileChanges.length)
573
257
  return "implementation";
574
258
  if (agentPlan)
575
259
  return "planning";
576
260
  return "handoff";
577
261
  }
262
+ function latestPlanMessage(assistantMessages) {
263
+ const plan = [...assistantMessages].reverse().find((text) => /^(?:plan|here'?s (?:the|my) plan|steps?|approach)\b\s*:?/im.test(text));
264
+ return plan ? truncate(plan, 1_500) : "";
265
+ }
578
266
  function summarizeRepoStats(fileChanges) {
579
267
  return fileChanges.reduce((stats, file) => ({
580
268
  dirtyFiles: stats.dirtyFiles + 1,
@@ -589,18 +277,57 @@ function buildContextSignals(sections, latestUserInstruction, latestAssistantSum
589
277
  const signals = [];
590
278
  for (const text of limit(sections.decisions, 4))
591
279
  signals.push({ kind: "decision", text });
592
- for (const text of limit(sections.doNotRepeat, 3))
280
+ for (const text of limit(sections.doNotRepeat, 4))
593
281
  signals.push({ kind: "constraint", text });
594
282
  for (const text of limit([...sections.blockers, ...sections.errors], 4))
595
283
  signals.push({ kind: "risk", text });
596
284
  if (latestUserInstruction)
597
- signals.push({ kind: "anchor", text: `Latest user instruction: ${latestUserInstruction}` });
285
+ signals.push({ kind: "anchor", text: `Latest user instruction: ${truncate(latestUserInstruction, 300)}` });
598
286
  if (latestAssistantSummary)
599
- signals.push({ kind: "anchor", text: `Latest assistant summary: ${latestAssistantSummary}` });
600
- return signals.slice(0, 10);
287
+ signals.push({ kind: "anchor", text: `Latest agent update: ${truncate(latestAssistantSummary, 300)}` });
288
+ return signals.slice(0, 12);
289
+ }
290
+ function extractFilePaths(snippets) {
291
+ const pattern = /(?:^|[\s`'"(])((?:\.{0,2}\/)?(?:[\w.-]+\/)*[\w.-]+\.(?:tsx?|jsx?|mjs|cjs|json|md|ya?ml|toml|sql|sh|py|go|rs|rb|java|kt|swift|css|html))(?=$|[\s`'"),.;:!?])/g;
292
+ return unique(snippets.flatMap((snippet) => Array.from(snippet.matchAll(pattern), (match) => match[1])));
293
+ }
294
+ function withRepoRelativeEdits(session, repoPath) {
295
+ const root = canonicalPath(repoPath);
296
+ const edits = (session.edits || []).map((edit) => {
297
+ if (!isAbsolute(edit.path))
298
+ return edit;
299
+ const rel = relative(root, canonicalPath(edit.path));
300
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? { ...edit, path: rel } : edit;
301
+ });
302
+ return { ...session, edits };
303
+ }
304
+ // ---------------------------------------------------------------------------
305
+ // Normalization (packets saved by older versions)
306
+ export function normalizeHandoffPacket(packet) {
307
+ const repo = {
308
+ ...packet.repo,
309
+ fileChanges: packet.repo.fileChanges?.length ? packet.repo.fileChanges : legacyFileChanges(packet.repo.dirtyFiles || [])
310
+ };
311
+ const derived = buildHandoffSummary(packet.task, repo, packet.sourceSession, packet.sections);
312
+ const existing = packet.handoffSummary;
313
+ const handoffSummary = existing
314
+ ? {
315
+ ...derived,
316
+ ...existing,
317
+ fileChanges: existing.fileChanges?.length ? existing.fileChanges : derived.fileChanges,
318
+ verification: existing.verification || derived.verification,
319
+ repoStats: existing.repoStats || derived.repoStats,
320
+ contextSignals: existing.contextSignals?.length ? existing.contextSignals : derived.contextSignals,
321
+ sourceSession: existing.sourceSession || derived.sourceSession,
322
+ repo: existing.repo || derived.repo
323
+ }
324
+ : derived;
325
+ const engineeringMemory = packet.engineeringMemory || buildEngineeringMemory(packet.task, repo, packet.sourceSession, packet.id, handoffSummary);
326
+ return { ...packet, repo, handoffSummary, engineeringMemory };
601
327
  }
602
328
  export function buildEngineeringMemory(task, repo, sourceSession, handoffId, summary) {
603
329
  const sourceLabel = sourceSession ? `${sourceSession.source}:${sourceSession.id}` : "local-capture";
330
+ const verified = summary.verification.commands.length > 0;
604
331
  return {
605
332
  version: 1,
606
333
  identity: {
@@ -612,19 +339,12 @@ export function buildEngineeringMemory(task, repo, sourceSession, handoffId, sum
612
339
  sessionId: sourceSession?.id,
613
340
  sourceAgent: sourceSession?.source
614
341
  },
615
- positioning: {
616
- category: "handoff-ledger",
617
- buyer: "ai-heavy-engineering-teams",
618
- moat: "provenance-backed-engineering-memory"
619
- },
620
342
  intent: facts("intent", [summary.originalQuestion, summary.latestUserInstruction], "user-authored", "high", sourceLabel),
621
343
  decisions: facts("decision", summary.decisions, "transcript-derived", "medium", sourceLabel),
622
- constraints: facts("constraint", summary.contextSignals.filter((signal) => signal.kind === "constraint").map((signal) => signal.text), "transcript-derived", "medium", sourceLabel),
623
- verification: facts("verification", summary.verification.commands.length
624
- ? summary.verification.commands.map((command) => `${command.status}: ${command.command}`)
625
- : summary.verification.gaps, "command-derived", summary.verification.commands.length ? "high" : "low", sourceLabel),
344
+ constraints: facts("constraint", summary.constraints || [], "user-authored", "medium", sourceLabel),
345
+ verification: facts("verification", verified ? summary.verification.commands.map((command) => `${command.status}: ${command.command}`) : summary.verification.gaps, "command-derived", verified ? "high" : "low", sourceLabel),
626
346
  risks: facts("risk", [...summary.blockers, ...summary.errors], "transcript-derived", "medium", sourceLabel),
627
- nextActions: facts("next-action", [summary.nextAction, ...summary.remainingWork], "ai-inferred", "medium", sourceLabel),
347
+ nextActions: facts("next-action", [summary.nextAction, ...summary.remainingWork], summary.plan ? "transcript-derived" : "ai-inferred", "medium", sourceLabel),
628
348
  changedFiles: facts("changed-file", formatFileChanges(summary.fileChanges), "diff-derived", "high", repo.repoPath),
629
349
  teamSync: {
630
350
  mode: "local-only",
@@ -637,232 +357,247 @@ export function buildEngineeringMemory(task, repo, sourceSession, handoffId, sum
637
357
  }
638
358
  function facts(category, values, provenanceKind, confidence, source) {
639
359
  return unique(values)
640
- .filter((value) => !/^None captured\.?$/i.test(value))
641
360
  .slice(0, 12)
642
- .map((text) => ({
643
- id: stableId(category, text),
644
- text,
645
- provenance: {
646
- kind: provenanceKind,
647
- confidence,
648
- source
649
- }
650
- }));
361
+ .map((text) => ({ id: stableId(category, text), text, provenance: { kind: provenanceKind, confidence, source } }));
651
362
  }
652
363
  function stableId(prefix, seed) {
653
364
  return `${prefix}_${hashText(seed || prefix)}`;
654
365
  }
655
- function renderEngineeringMemory(memory) {
656
- return [
657
- `- Category: ${memory.positioning.category}`,
658
- `- Buyer: ${memory.positioning.buyer}`,
659
- `- Moat: ${memory.positioning.moat}`,
660
- `- IDs: task=${memory.identity.taskId}, repo=${memory.identity.repoId}, branch=${memory.identity.branchId}, commit=${memory.identity.commitId}, handoff=${memory.identity.handoffId}`,
661
- `- Team sync: ${memory.teamSync.mode}, transcript ${memory.teamSync.rawTranscript}, redaction ${memory.teamSync.secretsRedaction}`,
662
- ...memory.decisions.slice(0, 4).map(renderMemoryFact),
663
- ...memory.verification.slice(0, 4).map(renderMemoryFact),
664
- ...memory.risks.slice(0, 4).map(renderMemoryFact),
665
- ...memory.nextActions.slice(0, 4).map(renderMemoryFact)
666
- ].join("\n");
667
- }
668
- function renderMemoryFact(fact) {
669
- return `- ${fact.text} [${fact.provenance.kind}, ${fact.provenance.confidence}]`;
670
- }
671
- function renderCurrentState(summary) {
672
- return [
366
+ // ---------------------------------------------------------------------------
367
+ // Rendering
368
+ /**
369
+ * The handoff an agent reads. Ordered by what the next agent needs first; empty sections are omitted.
370
+ */
371
+ export function renderHandoffMarkdown(packet, includeTranscript = true) {
372
+ const normalized = normalizeHandoffPacket(packet);
373
+ const summary = normalized.handoffSummary;
374
+ const session = normalized.sourceSession;
375
+ const stats = summary.repoStats;
376
+ const meta = [
377
+ basename(normalized.repo.repoPath),
378
+ normalized.repo.isGitRepo ? `${normalized.repo.branch} @ ${normalized.repo.commit}` : "not a git repo",
379
+ stats.dirtyFiles ? `${stats.dirtyFiles} changed ${stats.dirtyFiles === 1 ? "file" : "files"} (+${stats.additions} -${stats.deletions})` : "clean tree",
380
+ session ? `${sourceLabel(session.source)} session` : undefined,
381
+ normalized.createdAt.slice(0, 16).replace("T", " ")
382
+ ].filter(Boolean);
383
+ const lines = [
384
+ `# Handoff: ${normalized.task}`,
385
+ "",
386
+ `> ${meta.join(" · ")}`,
387
+ "",
388
+ `${normalized.targetAgent ? `For ${normalized.targetAgent}: c` : "C"}ontinue this task. Check that the repo matches the state below, then start from **Next action**.`
389
+ ];
390
+ const section = (title, body) => {
391
+ const content = Array.isArray(body) ? body.filter(Boolean).join("\n") : body?.trim();
392
+ if (content)
393
+ lines.push("", `## ${title}`, content);
394
+ };
395
+ const objective = summary.originalQuestion;
396
+ section("Objective", objective);
397
+ if (summary.latestUserInstruction && summary.latestUserInstruction !== objective)
398
+ section("Latest request", summary.latestUserInstruction);
399
+ section("Next action", summary.nextAction);
400
+ section("Status", [
673
401
  `- Phase: ${summary.workPhase}`,
674
- `- Repo state: ${summary.repoStats.dirtyFiles} dirty, ${summary.repoStats.stagedFiles} staged, ${summary.repoStats.unstagedFiles} unstaged, ${summary.repoStats.untrackedFiles} untracked`,
675
- `- Diff volume: +${summary.repoStats.additions} -${summary.repoStats.deletions}`,
676
- `- Verification: ${summary.verification.status}`
677
- ].join("\n");
402
+ `- Verification: ${summary.verification.status}`,
403
+ summary.latestAssistantSummary ? `- Last update from the agent: ${truncate(oneLine(summary.latestAssistantSummary), 600)}` : ""
404
+ ]);
405
+ section("Plan", summary.plan?.length ? summary.plan.map(formatPlanItem) : summary.agentPlan && !summary.plan ? truncate(summary.agentPlan, 1_500) : undefined);
406
+ section("Verification", renderVerification(summary.verification));
407
+ section("Changed files", renderFileChanges(summary.fileChanges));
408
+ section("Decisions", bullets(summary.decisions, 6));
409
+ section("Constraints", bullets(summary.constraints || normalized.sections.doNotRepeat, 6));
410
+ section("Blockers", bullets(summary.blockers, 6));
411
+ if (!summary.verification.commands.length)
412
+ section("Errors", bullets(summary.errors, 6));
413
+ section("Earlier context", session?.compactSummary ? truncate(session.compactSummary, 2_000) : undefined);
414
+ section("Links", bullets(session?.links || [], 5));
415
+ if (includeTranscript)
416
+ section("Recent transcript", session?.transcriptExcerpt);
417
+ lines.push("", "---", `Source: ${session ? `${sourceLabel(session.source)} session ${session.id}${session.threadName ? ` (“${session.threadName}”)` : ""}` : "local capture"}`, `Repo: ${normalized.repo.repoPath}`, `Generated by ContextPool ${VERSION} at ${normalized.createdAt}`);
418
+ return lines.join("\n");
419
+ }
420
+ /** Import-ready context for an agent: the handoff without the transcript, plus the structured summary. */
421
+ export function buildAgentContext(packet) {
422
+ const normalized = normalizeHandoffPacket(packet);
423
+ return {
424
+ markdown: renderHandoffMarkdown(normalized, false),
425
+ summary: normalized.handoffSummary,
426
+ engineeringMemory: normalized.engineeringMemory
427
+ };
428
+ }
429
+ export function renderAgentContextMarkdown(packet) {
430
+ return renderHandoffMarkdown(packet, false);
678
431
  }
679
432
  function renderVerification(verification) {
680
- const lines = [`- Status: ${verification.status}`];
681
- for (const command of verification.commands) {
682
- lines.push(`- ${command.status}: ${command.command}`);
683
- }
684
- for (const gap of verification.gaps)
685
- lines.push(`- Gap: ${gap}`);
686
- return lines.join("\n");
433
+ const glyph = { passed: "✓", failed: "✗", unknown: "?" };
434
+ return [
435
+ ...verification.commands.map((command) => {
436
+ const exit = command.status === "failed" && command.exitCode ? ` (exit ${command.exitCode})` : command.status === "unknown" ? " (result not recorded)" : "";
437
+ return `- ${glyph[command.status]} \`${command.command}\`${exit}${command.evidence ? ` — ${command.evidence}` : ""}`;
438
+ }),
439
+ ...verification.gaps.map((gap) => `- Gap: ${gap}`)
440
+ ];
441
+ }
442
+ function renderFileChanges(changes) {
443
+ const lines = formatFileChanges(changes.slice(0, 30)).map((line) => `- ${line}`);
444
+ if (changes.length > 30)
445
+ lines.push(`- …and ${changes.length - 30} more`);
446
+ return lines;
447
+ }
448
+ function bullets(values, max) {
449
+ return unique(values)
450
+ .slice(0, max)
451
+ .map((value) => `- ${truncate(oneLine(value), 300)}`);
452
+ }
453
+ function formatPlanItem(item) {
454
+ if (item.status === "completed")
455
+ return `- [x] ${item.text}`;
456
+ return `- [ ] ${item.text}${item.status === "in_progress" ? " (in progress)" : ""}`;
687
457
  }
688
458
  function legacyFileChanges(files) {
689
- return unique(files).map((path) => ({
690
- path,
691
- status: "modified",
692
- additions: 0,
693
- deletions: 0,
694
- staged: false,
695
- unstaged: true,
696
- untracked: false
697
- }));
459
+ return unique(files).map((path) => ({ path, status: "modified", additions: 0, deletions: 0, staged: false, unstaged: true, untracked: false }));
698
460
  }
699
461
  function formatFileChanges(changes) {
700
462
  return changes.map((change) => {
701
463
  const stat = change.additions || change.deletions ? ` (+${change.additions} -${change.deletions})` : "";
702
- const oldPath = change.oldPath ? `${change.oldPath} -> ` : "";
464
+ const oldPath = change.oldPath ? `${change.oldPath} → ` : "";
703
465
  return `${statusLabel(change)} ${oldPath}${change.path}${stat}`;
704
466
  });
705
467
  }
706
468
  function statusLabel(change) {
707
469
  if (change.untracked)
708
470
  return "??";
709
- if (change.status === "added")
710
- return "A";
711
- if (change.status === "deleted")
712
- return "D";
713
- if (change.status === "renamed")
714
- return "R";
715
- if (change.status === "copied")
716
- return "C";
717
- if (change.status === "modified")
718
- return "M";
719
- return "?";
720
- }
721
- function dedupeBlocks(blocks) {
471
+ return { added: "A", deleted: "D", renamed: "R", copied: "C", modified: "M", untracked: "??", unknown: "?" }[change.status];
472
+ }
473
+ // ---------------------------------------------------------------------------
474
+ // Context blocks
475
+ export function deriveContextBlocks(packet) {
476
+ const normalized = normalizeHandoffPacket(packet);
477
+ const summary = normalized.handoffSummary;
478
+ const memory = normalized.engineeringMemory;
479
+ const source = `${summary.sourceSession?.source || "handoff"}:${summary.sourceSession?.id || normalized.id}`;
480
+ const blocks = [];
722
481
  const seen = new Set();
723
- const result = [];
724
- for (const block of blocks) {
725
- const key = `${block.category}:${block.text}`;
726
- if (seen.has(key))
727
- continue;
482
+ const push = (category, title, text, provenance, confidence, provenanceSource = source) => {
483
+ const clean = redactSecrets(oneLine(text)).slice(0, 2_000);
484
+ const key = `${category}:${clean}`;
485
+ if (clean.length < 3 || seen.has(key))
486
+ return;
728
487
  seen.add(key);
729
- result.push(block);
730
- }
731
- return result;
732
- }
733
- function sanitizeBlockForPack(block, includeRawTranscript) {
734
- const text = includeRawTranscript || block.category !== "transcript" ? block.text : redactSensitiveText(block.text);
735
- return {
736
- ...block,
737
- text,
738
- estimatedChars: text.length
488
+ blocks.push({
489
+ id: stableId(`${normalized.id}:${category}`, `${title}:${clean}`),
490
+ sourceHandoffId: normalized.id,
491
+ sourceTask: normalized.task,
492
+ sourceCreatedAt: normalized.createdAt,
493
+ category,
494
+ title,
495
+ text: clean,
496
+ provenance: { kind: provenance, confidence, source: provenanceSource },
497
+ estimatedChars: clean.length
498
+ });
739
499
  };
740
- }
741
- function cleanContextText(text) {
742
- return redactSensitiveText(toHandoffSignal(text).replace(/\s+/g, " ").trim()).slice(0, 2000);
743
- }
744
- function redactSensitiveText(text) {
745
- return text
746
- .replace(/\b[A-Z0-9_]*(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|DATABASE_URL|DSN)[A-Z0-9_]*\s*=\s*[^\s,;]+/gi, (match) => {
747
- const [key] = match.split("=");
748
- return `${key.trim()}=[redacted]`;
749
- })
750
- .replace(/\b(?:sk|pk|rk|ghp|gho|github_pat)_[A-Za-z0-9_-]{12,}\b/g, "[redacted-token]")
751
- .replace(/\b[A-Za-z0-9+/]{32,}={0,2}\b/g, "[redacted-secret]");
752
- }
753
- function buildPackWarnings(pack) {
754
- const warnings = [];
755
- if (!pack.includeRawTranscript && pack.items.some((item) => item.block.category === "transcript")) {
756
- warnings.push("Raw transcript blocks were redacted by default. Enable raw transcript only when the destination is trusted.");
757
- }
758
- if (pack.items.some((item) => /\[redacted/.test(item.block.text))) {
759
- warnings.push("Potential secrets were redacted from one or more context blocks.");
760
- }
761
- if (!pack.items.some((item) => item.enabled))
762
- warnings.push("No enabled context blocks are present in this pack.");
763
- return warnings;
764
- }
765
- function renderContextPackMarkdown(pack, items, estimatedChars, warnings) {
766
- const sections = new Map();
767
- for (const item of items) {
768
- const current = sections.get(item.block.category) || [];
769
- current.push(item);
770
- sections.set(item.block.category, current);
771
- }
772
- const lines = [
773
- `# Context Pack: ${pack.name}`,
774
- "",
775
- `Pack ID: ${pack.id}`,
776
- `Updated: ${pack.updatedAt}`,
777
- `Estimated characters: ${estimatedChars}`,
778
- `Raw transcript: ${pack.includeRawTranscript ? "included" : "excluded/redacted"}`,
779
- "",
780
- "## Use This Context",
781
- "Reuse the selected context below without re-discovering prior decisions, verification, changed files, or next actions.",
782
- ""
783
- ];
784
- if (warnings.length) {
785
- lines.push("## Warnings", ...warnings.map((warning) => `- ${warning}`), "");
786
- }
787
- for (const [category, categoryItems] of sections) {
788
- lines.push(`## ${contextCategoryTitle(category)}`);
789
- for (const item of categoryItems) {
790
- lines.push(`- ${item.block.text}`);
791
- lines.push(` - Source: ${item.block.sourceTask} / ${item.block.sourceHandoffId}`);
792
- lines.push(` - Provenance: ${item.block.provenance.kind}, ${item.block.provenance.confidence}`);
793
- }
794
- lines.push("");
795
- }
796
- return lines.join("\n").trimEnd();
797
- }
798
- function contextCategoryTitle(category) {
799
- switch (category) {
800
- case "next-action":
801
- return "Next Actions";
802
- case "changed-file":
803
- return "Changed Files";
804
- default:
805
- return labelize(category);
806
- }
807
- }
808
- function labelize(value) {
809
- return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
810
- }
811
- function toHandoffSignal(text) {
812
- if (/(<permissions instructions>|<collaboration_mode>|<skills_instructions>|<plugins_instructions>|<environment_context>|<image>)/i.test(text) ||
813
- /Knowledge cutoff:|You are Codex|Filesystem sandboxing defines|Available skills|Skill roots|Tool definitions|Valid channels:|# Handoff:/i.test(text)) {
814
- return "";
500
+ push("intent", "Objective", summary.originalQuestion, "user-authored", "high");
501
+ if (summary.latestUserInstruction !== summary.originalQuestion)
502
+ push("instruction", "Latest request", summary.latestUserInstruction, "user-authored", "high");
503
+ push("next-action", "Next action", summary.nextAction, summary.plan ? "transcript-derived" : "ai-inferred", "medium");
504
+ for (const fact of memory.decisions)
505
+ push("decision", "Decision", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
506
+ for (const fact of memory.constraints)
507
+ push("constraint", "Constraint", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
508
+ for (const fact of memory.verification)
509
+ push("verification", "Verification", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
510
+ for (const fact of memory.risks)
511
+ push("risk", "Risk", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
512
+ for (const fact of memory.nextActions.slice(1))
513
+ push("next-action", "Remaining work", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
514
+ for (const fact of memory.changedFiles)
515
+ push("changed-file", "Changed file", fact.text, fact.provenance.kind, fact.provenance.confidence, fact.provenance.source);
516
+ for (const entry of normalized.sourceSession?.transcript || []) {
517
+ if (entry.role === "tool" || entry.text.length < 20)
518
+ continue;
519
+ push("transcript", entry.role === "user" ? "User" : "Agent", entry.text, "transcript-derived", "medium");
815
520
  }
816
- return text.length > 1200 ? `${text.slice(0, 1200)}...` : text;
817
- }
818
- function segmentText(text) {
819
- return text
820
- .split(/\n+|(?<=[.!?])\s+/)
821
- .map((part) => part.trim())
822
- .filter((part) => part.length > 8)
823
- .map((part) => (part.length > 800 ? `${part.slice(0, 800)}...` : part));
521
+ return blocks.slice(0, 160);
824
522
  }
825
- function extractFilePaths(snippets) {
826
- const matches = [];
827
- const pattern = /\b(?:\.{0,2}\/)?(?:src|apps|app|packages|lib|server|client|tests?|docs)\/[A-Za-z0-9._/-]+\.[A-Za-z0-9]{1,8}\b|\b[A-Za-z0-9._/-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|yml|yaml|toml|sql|sh|py|go|rs)\b/g;
828
- for (const snippet of snippets) {
829
- const found = snippet.match(pattern);
830
- if (!found)
831
- continue;
832
- for (const file of found) {
833
- matches.push(file.replace(/[),.;:!?]+$/g, ""));
523
+ // ---------------------------------------------------------------------------
524
+ // Team export
525
+ export function buildTeamTimelineExport(packet, options = {}) {
526
+ const normalized = normalizeHandoffPacket(packet);
527
+ const includeRawTranscript = options.includeRawTranscript === true;
528
+ const sourceSession = normalized.sourceSession
529
+ ? {
530
+ ...normalized.sourceSession,
531
+ transcript: includeRawTranscript ? normalized.sourceSession.transcript : [],
532
+ transcriptExcerpt: includeRawTranscript ? normalized.sourceSession.transcriptExcerpt : "",
533
+ compactSummary: includeRawTranscript ? normalized.sourceSession.compactSummary : undefined
834
534
  }
835
- }
836
- return unique(matches);
837
- }
838
- async function improveWithOllama(task, sections) {
839
- const model = process.env.CTXPOOL_OLLAMA_MODEL || "llama3.1";
840
- const controller = new AbortController();
841
- const timeout = setTimeout(() => controller.abort(), 3500);
842
- try {
843
- const response = await fetch("http://127.0.0.1:11434/api/generate", {
844
- method: "POST",
845
- signal: controller.signal,
846
- headers: { "content-type": "application/json" },
847
- body: JSON.stringify({
848
- model,
849
- stream: false,
850
- prompt: [
851
- "Write one concise next best action for a coding-agent handoff.",
852
- `Task: ${task}`,
853
- `Todos: ${sections.todos.join("; ")}`,
854
- `Blockers: ${sections.blockers.join("; ")}`,
855
- `Files: ${sections.filesTouched.join("; ")}`
856
- ].join("\n")
857
- })
858
- });
859
- if (!response.ok)
860
- return undefined;
861
- const json = (await response.json());
862
- return json.response?.trim().split("\n")[0]?.slice(0, 500);
863
- }
864
- finally {
865
- clearTimeout(timeout);
866
- }
535
+ : undefined;
536
+ const exportPacket = {
537
+ ...normalized,
538
+ sourceSession,
539
+ sources: normalized.sources.map((source) => ({ ...source, snippets: includeRawTranscript ? source.snippets : [] })),
540
+ engineeringMemory: {
541
+ ...normalized.engineeringMemory,
542
+ teamSync: {
543
+ mode: "sync-ready",
544
+ rawTranscript: includeRawTranscript ? "included" : "excluded",
545
+ filePreviewRedaction: "enabled",
546
+ secretsRedaction: "enabled",
547
+ userControlledSync: true
548
+ }
549
+ },
550
+ markdown: ""
551
+ };
552
+ exportPacket.markdown = renderHandoffMarkdown(exportPacket, includeRawTranscript);
553
+ // Packets saved by older versions were not redacted at capture; redact everything on the way out.
554
+ return redactDeep(exportPacket);
555
+ }
556
+ // ---------------------------------------------------------------------------
557
+ // Optional local model
558
+ async function suggestNextActionWithOllama(task, summary, configuredModel) {
559
+ const host = process.env.OLLAMA_HOST ? normalizeOllamaHost(process.env.OLLAMA_HOST) : "http://127.0.0.1:11434";
560
+ const model = configuredModel || process.env.CTXPOOL_OLLAMA_MODEL || (await firstOllamaModel(host));
561
+ if (!model)
562
+ return undefined;
563
+ const response = await fetch(`${host}/api/generate`, {
564
+ method: "POST",
565
+ signal: AbortSignal.timeout(15_000),
566
+ headers: { "content-type": "application/json" },
567
+ body: JSON.stringify({
568
+ model,
569
+ stream: false,
570
+ think: false,
571
+ options: { temperature: 0.2 },
572
+ prompt: [
573
+ "You write the single next action for a coding agent taking over a task.",
574
+ "Reply with one imperative sentence under 30 words. No preamble.",
575
+ "",
576
+ `Task: ${task}`,
577
+ `Objective: ${truncate(summary.originalQuestion, 400)}`,
578
+ `Latest request: ${truncate(summary.latestUserInstruction, 400)}`,
579
+ `Remaining: ${summary.remainingWork.slice(0, 5).join("; ") || "none recorded"}`,
580
+ `Verification: ${summary.verification.status}${summary.verification.commands.length ? ` (${summary.verification.commands.map((command) => `${command.command}: ${command.status}`).join("; ")})` : ""}`,
581
+ `Blockers: ${summary.blockers.join("; ") || "none"}`,
582
+ `Changed files: ${summary.fileChanges.slice(0, 10).map((change) => change.path).join(", ") || "none"}`
583
+ ].join("\n")
584
+ })
585
+ });
586
+ if (!response.ok)
587
+ return undefined;
588
+ const json = (await response.json());
589
+ const sentence = json.response?.replace(/<think>[\s\S]*?<\/think>/g, "").trim().split("\n")[0]?.replace(/^["'“]|["'”]$/g, "");
590
+ return sentence ? truncate(sentence, 400) : undefined;
591
+ }
592
+ async function firstOllamaModel(host) {
593
+ const response = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(1_500) });
594
+ if (!response.ok)
595
+ return undefined;
596
+ const json = (await response.json());
597
+ return json.models?.find((model) => model.name)?.name;
598
+ }
599
+ function normalizeOllamaHost(value) {
600
+ const withScheme = /^https?:\/\//.test(value) ? value : `http://${value}`;
601
+ return withScheme.replace(/\/+$/, "");
867
602
  }
868
603
  //# sourceMappingURL=handoff.js.map