ctxpool 0.1.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.
@@ -0,0 +1,868 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ensureCtxDirs } from "./paths.js";
4
+ import { captureGitSnapshot, inferTaskName } from "./repo.js";
5
+ import { resolveSessionSource } from "./session.js";
6
+ import { hashText, limit, slugify, unique } from "./util.js";
7
+ export async function createHandoff(options = {}) {
8
+ const cwd = options.cwd || process.cwd();
9
+ const paths = ensureCtxDirs();
10
+ const repo = captureGitSnapshot(cwd);
11
+ const task = inferTaskName(options.task, repo);
12
+ const sourceSession = resolveSessionSource({
13
+ from: options.from || "auto",
14
+ cwd,
15
+ session: options.session,
16
+ budget: options.budget
17
+ });
18
+ const sources = sourceSessionToSignals(sourceSession);
19
+ const sections = buildSections(task, repo.dirtyFiles, repo.diffStat, sourceSession);
20
+ const handoffSummary = buildHandoffSummary(task, repo, sourceSession, sections);
21
+ const createdAt = new Date().toISOString();
22
+ const id = `${createdAt.replace(/[:.]/g, "-")}-${hashText(`${task}:${createdAt}`)}`;
23
+ if (options.useOllama !== false) {
24
+ const improved = await improveWithOllama(task, sections).catch(() => undefined);
25
+ if (improved) {
26
+ sections.nextBestAction = improved;
27
+ handoffSummary.nextAction = improved;
28
+ }
29
+ }
30
+ const engineeringMemory = buildEngineeringMemory(task, repo, sourceSession, id, handoffSummary);
31
+ const markdown = renderHandoffMarkdown({
32
+ id,
33
+ task,
34
+ createdAt,
35
+ targetAgent: options.to,
36
+ repo,
37
+ sources,
38
+ sourceSession,
39
+ sections,
40
+ handoffSummary,
41
+ engineeringMemory,
42
+ markdown: "",
43
+ markdownPath: ""
44
+ }, options.includeTranscript !== false);
45
+ const taskDir = join(paths.handoffsDir, slugify(task));
46
+ 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
+ }
64
+ 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.";
83
+ 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),
92
+ completed,
93
+ inProgress: limit([...todos.slice(0, 4), ...mergedFilesTouched.slice(0, 6).map((file) => `Work appears active around ${file}.`)], 8),
94
+ todos,
95
+ blockers,
96
+ filesTouched: mergedFilesTouched,
97
+ commandsRun,
98
+ errors,
99
+ decisions,
100
+ doNotRepeat: match(messageSnippets, /(do not|don't|avoid|never|not repeat|already tried)/i, 8),
101
+ nextBestAction: next
102
+ };
103
+ }
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
+ export function buildHandoffSummary(task, repo, sourceSession, sections) {
230
+ 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).`] : []);
241
+ return {
242
+ version: 1,
243
+ originalQuestion: originalQuestion || `Continue the ${task} task.`,
244
+ 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),
248
+ verification,
249
+ repoStats,
250
+ contextSignals: buildContextSignals(sections, latestUserInstruction || originalQuestion, latestAssistantSummary),
251
+ completedWork: meaningfulList(sections.completed, []),
252
+ inProgressWork,
253
+ remainingWork,
254
+ blockers: sections.blockers,
255
+ nextAction: sections.nextBestAction,
256
+ fileChanges,
257
+ commandsRun: sections.commandsRun,
258
+ errors: sections.errors,
259
+ decisions: sections.decisions,
260
+ sourceSession: sourceSession
261
+ ? {
262
+ source: sourceSession.source,
263
+ id: sourceSession.id,
264
+ cwd: sourceSession.cwd,
265
+ matchedCwd: sourceSession.matchedCwd,
266
+ updatedAt: sourceSession.updatedAt,
267
+ transcriptItems: sourceSession.transcript.length
268
+ }
269
+ : undefined,
270
+ repo: {
271
+ repoPath: repo.repoPath,
272
+ branch: repo.branch,
273
+ commit: repo.commit,
274
+ isGitRepo: repo.isGitRepo
275
+ }
276
+ };
277
+ }
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);
384
+ }
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) => ({
537
+ command,
538
+ status: inferCommandStatus(command, evidence),
539
+ evidence: matchingEvidence(command, evidence)
540
+ }));
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));
542
+ 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)
567
+ return "blocked";
568
+ if (verification.status === "failed" || sections.errors.length > 0)
569
+ return "debugging";
570
+ if (verification.commands.length > 0)
571
+ return "verification";
572
+ if (sections.todos.length > 0 || fileChanges.length > 0)
573
+ return "implementation";
574
+ if (agentPlan)
575
+ return "planning";
576
+ return "handoff";
577
+ }
578
+ function summarizeRepoStats(fileChanges) {
579
+ return fileChanges.reduce((stats, file) => ({
580
+ dirtyFiles: stats.dirtyFiles + 1,
581
+ stagedFiles: stats.stagedFiles + (file.staged ? 1 : 0),
582
+ unstagedFiles: stats.unstagedFiles + (file.unstaged ? 1 : 0),
583
+ untrackedFiles: stats.untrackedFiles + (file.untracked ? 1 : 0),
584
+ additions: stats.additions + (file.additions || 0),
585
+ deletions: stats.deletions + (file.deletions || 0)
586
+ }), { dirtyFiles: 0, stagedFiles: 0, unstagedFiles: 0, untrackedFiles: 0, additions: 0, deletions: 0 });
587
+ }
588
+ function buildContextSignals(sections, latestUserInstruction, latestAssistantSummary) {
589
+ const signals = [];
590
+ for (const text of limit(sections.decisions, 4))
591
+ signals.push({ kind: "decision", text });
592
+ for (const text of limit(sections.doNotRepeat, 3))
593
+ signals.push({ kind: "constraint", text });
594
+ for (const text of limit([...sections.blockers, ...sections.errors], 4))
595
+ signals.push({ kind: "risk", text });
596
+ if (latestUserInstruction)
597
+ signals.push({ kind: "anchor", text: `Latest user instruction: ${latestUserInstruction}` });
598
+ if (latestAssistantSummary)
599
+ signals.push({ kind: "anchor", text: `Latest assistant summary: ${latestAssistantSummary}` });
600
+ return signals.slice(0, 10);
601
+ }
602
+ export function buildEngineeringMemory(task, repo, sourceSession, handoffId, summary) {
603
+ const sourceLabel = sourceSession ? `${sourceSession.source}:${sourceSession.id}` : "local-capture";
604
+ return {
605
+ version: 1,
606
+ identity: {
607
+ taskId: stableId("task", `${repo.repoPath}:${task}`),
608
+ repoId: stableId("repo", repo.repoPath),
609
+ branchId: stableId("branch", `${repo.repoPath}:${repo.branch}`),
610
+ commitId: stableId("commit", `${repo.repoPath}:${repo.commit}`),
611
+ handoffId,
612
+ sessionId: sourceSession?.id,
613
+ sourceAgent: sourceSession?.source
614
+ },
615
+ positioning: {
616
+ category: "handoff-ledger",
617
+ buyer: "ai-heavy-engineering-teams",
618
+ moat: "provenance-backed-engineering-memory"
619
+ },
620
+ intent: facts("intent", [summary.originalQuestion, summary.latestUserInstruction], "user-authored", "high", sourceLabel),
621
+ 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),
626
+ risks: facts("risk", [...summary.blockers, ...summary.errors], "transcript-derived", "medium", sourceLabel),
627
+ nextActions: facts("next-action", [summary.nextAction, ...summary.remainingWork], "ai-inferred", "medium", sourceLabel),
628
+ changedFiles: facts("changed-file", formatFileChanges(summary.fileChanges), "diff-derived", "high", repo.repoPath),
629
+ teamSync: {
630
+ mode: "local-only",
631
+ rawTranscript: "included",
632
+ filePreviewRedaction: "enabled",
633
+ secretsRedaction: "enabled",
634
+ userControlledSync: true
635
+ }
636
+ };
637
+ }
638
+ function facts(category, values, provenanceKind, confidence, source) {
639
+ return unique(values)
640
+ .filter((value) => !/^None captured\.?$/i.test(value))
641
+ .slice(0, 12)
642
+ .map((text) => ({
643
+ id: stableId(category, text),
644
+ text,
645
+ provenance: {
646
+ kind: provenanceKind,
647
+ confidence,
648
+ source
649
+ }
650
+ }));
651
+ }
652
+ function stableId(prefix, seed) {
653
+ return `${prefix}_${hashText(seed || prefix)}`;
654
+ }
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 [
673
+ `- 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");
678
+ }
679
+ 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");
687
+ }
688
+ 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
+ }));
698
+ }
699
+ function formatFileChanges(changes) {
700
+ return changes.map((change) => {
701
+ const stat = change.additions || change.deletions ? ` (+${change.additions} -${change.deletions})` : "";
702
+ const oldPath = change.oldPath ? `${change.oldPath} -> ` : "";
703
+ return `${statusLabel(change)} ${oldPath}${change.path}${stat}`;
704
+ });
705
+ }
706
+ function statusLabel(change) {
707
+ if (change.untracked)
708
+ 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) {
722
+ 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;
728
+ 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
739
+ };
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 "";
815
+ }
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));
824
+ }
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, ""));
834
+ }
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
+ }
867
+ }
868
+ //# sourceMappingURL=handoff.js.map