pi-agenticoding 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,36 @@
1
+ /**
2
+ * /handoff command for the agenticoding extension.
3
+ *
4
+ * Collects a user direction, asks the LLM to complete the picture in a
5
+ * handoff brief, and lets the handoff tool perform the actual compaction.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import type { AgenticodingState } from "../state.js";
10
+
11
+ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingState): void {
12
+ pi.registerCommand("handoff", {
13
+ description:
14
+ "Ask the LLM to draft a handoff brief that completes the picture from " +
15
+ "your direction, then perform the handoff automatically.",
16
+
17
+ handler: async (args, ctx) => {
18
+ const direction = args.trim();
19
+ if (!direction) {
20
+ if (ctx.hasUI) ctx.ui.notify("Usage: /handoff <direction>", "error");
21
+ return;
22
+ }
23
+
24
+ state.pendingRequestedHandoff = {
25
+ direction,
26
+ enforcementAttempts: 0,
27
+ toolCalled: false,
28
+ };
29
+
30
+ pi.sendUserMessage(
31
+ `Handoff direction: ${direction}\n\nPrepare a real handoff in the current session and current context. Before calling the handoff tool, capture any reusable state in the ledger if needed. Then complete the picture in a concise but sufficiently detailed handoff brief and call the handoff tool in this turn. Preserve the important knowledge that is still only present in the current context so the next clean context can start well without re-deriving it. Use any structure that makes the next work unambiguous. Include findings, current state, unresolved questions, failed paths worth avoiding, next steps, refs, constraints, and spawn ideas when useful. Reference ledger entries by name when relevant.`,
32
+ ctx.isIdle() ? undefined : { deliverAs: "followUp" },
33
+ );
34
+ },
35
+ });
36
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * session_before_compact hook for deliberate handoff compactions.
3
+ *
4
+ * Replaces the active context with the queued handoff task and keeps no
5
+ * pre-handoff messages in LLM context.
6
+ */
7
+
8
+ import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
9
+ import type { AgenticodingState } from "../state.js";
10
+
11
+ function getImpossibleKeptId(branchEntries: SessionEntry[]): string {
12
+ const leaf = branchEntries[branchEntries.length - 1];
13
+ return `${leaf?.id ?? "handoff"}-handoff-cut`;
14
+ }
15
+
16
+ export function registerHandoffCompaction(pi: ExtensionAPI, state: AgenticodingState): void {
17
+ pi.on("session_before_compact", async (event) => {
18
+ const pending = state.pendingHandoff;
19
+ if (!pending) {
20
+ return;
21
+ }
22
+
23
+ state.pendingHandoff = null;
24
+ state.pendingRequestedHandoff = null;
25
+
26
+ return {
27
+ compaction: {
28
+ summary: pending.task,
29
+ firstKeptEntryId: getImpossibleKeptId(event.branchEntries),
30
+ tokensBefore: event.preparation.tokensBefore,
31
+ details: { handoff: true, task: pending.task },
32
+ },
33
+ };
34
+ });
35
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Handoff tool for the agenticoding extension.
3
+ *
4
+ * Tools can trigger compaction directly, so handoff is implemented as a
5
+ * deliberate compaction that replaces noisy context with a clean restart brief.
6
+ *
7
+ * The brief should complete the picture: preserve the important knowledge that
8
+ * is still only present in the current context, while referenced ledger entry
9
+ * bodies seed the post-handoff context immediately.
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { Type } from "typebox";
14
+ import type { AgenticodingState } from "../state.js";
15
+
16
+ const MAX_INLINE_ENTRIES = 3;
17
+ const MAX_INLINE_CHARS = 4000;
18
+
19
+ /**
20
+ * Extract names of existing ledger entries referenced in the task text.
21
+ * Returns up to MAX_INLINE_ENTRIES entries with their full bodies,
22
+ * capped at MAX_INLINE_CHARS total.
23
+ */
24
+ function extractReferencedLedgerEntries(
25
+ task: string,
26
+ state: AgenticodingState,
27
+ ): { name: string; body: string }[] {
28
+ const entryNames = Array.from(state.ledger.keys()).sort();
29
+ const matched: { name: string; body: string }[] = [];
30
+ const seen = new Set<string>();
31
+ let totalChars = 0;
32
+
33
+ for (const name of entryNames) {
34
+ if (task.includes(name) && !seen.has(name)) {
35
+ const body = state.ledger.get(name);
36
+ if (body) {
37
+ const chars = body.length;
38
+ if (totalChars + chars <= MAX_INLINE_CHARS && matched.length < MAX_INLINE_ENTRIES) {
39
+ matched.push({ name, body });
40
+ seen.add(name);
41
+ totalChars += chars;
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ return matched;
48
+ }
49
+
50
+ /**
51
+ * Build the enriched task that becomes the compaction summary.
52
+ *
53
+ * Shape: handoff primer + inlined ledger bodies + original task.
54
+ */
55
+ function buildEnrichedTask(
56
+ task: string,
57
+ state: AgenticodingState,
58
+ ): string {
59
+ const refs = extractReferencedLedgerEntries(task, state);
60
+
61
+ const parts: string[] = [
62
+ "## Handoff — Continue Previous Work",
63
+ "",
64
+ "You are continuing a previous agent's work in a clean context. Available knowledge:",
65
+ "- Use `ledger_get` to retrieve detailed entries by name on demand",
66
+ "- Use `spawn` to delegate isolated subtasks to child agents",
67
+ "- Build on ledger knowledge and the handoff brief rather than reconstructing old context",
68
+ "- Treat the handoff brief as the missing picture that survived the cut",
69
+ ];
70
+
71
+ if (refs.length > 0) {
72
+ parts.push("", "### Inlined Ledger Context");
73
+ for (const { name, body } of refs) {
74
+ parts.push("", `Ledger: \`${name}\``, body, "---");
75
+ }
76
+ }
77
+
78
+ parts.push("", "## Task", "", task);
79
+
80
+ return parts.join("\n");
81
+ }
82
+
83
+ export function registerHandoffTool(
84
+ pi: ExtensionAPI,
85
+ state: AgenticodingState,
86
+ ): void {
87
+ pi.registerTool({
88
+ name: "handoff",
89
+ label: "Handoff",
90
+ description:
91
+ "Replace the active context with a compact handoff task at the end of " +
92
+ "the current turn while keeping full history in the session file.\n\n" +
93
+ "WHEN TO USE:\n" +
94
+ " 1. Context past ~30% and the current job is no longer cleanly " +
95
+ "represented near the front of attention.\n" +
96
+ " 2. Context is filled with mechanics irrelevant to what comes " +
97
+ "next (research traces, planning deliberation, dead ends).\n" +
98
+ " 3. The current job is complete and a new distinct task starts.\n\n" +
99
+ "Rule: one context, one job. When the job changes, call handoff.\n\n" +
100
+ "AFTER HANDOFF the LLM sees:\n" +
101
+ " • System prompt + context primer\n" +
102
+ " • The handoff task — as a compaction summary at the top of context\n" +
103
+ " • All ledger entries — accessible via ledger_get / ledger_list",
104
+
105
+ promptSnippet: "Pivot to a new job via deliberate handoff compaction",
106
+ promptGuidelines: [
107
+ "Call handoff when the job changes, or when context is past ~30% and noisy. " +
108
+ "Capture reusable state in the ledger if needed, then draft a concise but " +
109
+ "sufficiently detailed brief that completes the picture for the next clean context.",
110
+ ],
111
+
112
+ executionMode: "sequential",
113
+
114
+ parameters: Type.Object({
115
+ task: Type.String({
116
+ description:
117
+ "What to do next. A concise but sufficiently detailed handoff brief. " +
118
+ "This becomes the FIRST thing the LLM sees after handoff. Complete the " +
119
+ "picture by preserving the important knowledge still missing from the ledger, " +
120
+ "then make the next work unambiguous using any structure you want.",
121
+ }),
122
+ }),
123
+
124
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
125
+ const enrichedTask = buildEnrichedTask(params.task, state);
126
+ state.pendingHandoff = { task: enrichedTask, source: "tool" };
127
+ if (state.pendingRequestedHandoff) {
128
+ state.pendingRequestedHandoff.toolCalled = true;
129
+ }
130
+ ctx.compact({
131
+ onComplete: () => {
132
+ pi.sendUserMessage("Proceed.");
133
+ },
134
+ onError: () => {
135
+ state.pendingHandoff = null;
136
+ // Safe: pendingRequestedHandoff may already be cleaned up by watchdog
137
+ if (state.pendingRequestedHandoff) {
138
+ state.pendingRequestedHandoff.toolCalled = false;
139
+ }
140
+ },
141
+ });
142
+
143
+ return {
144
+ content: [{ type: "text", text: "Handoff started." }],
145
+ details: {},
146
+ terminate: true,
147
+ };
148
+ },
149
+
150
+ });
151
+ }
package/index.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Agenticoding v2 — Extension factory.
3
+ *
4
+ * Wires together the three primitives:
5
+ * spawn — delegate isolated work to child contexts
6
+ * ledger — sparse continuity cache
7
+ * handoff — deliberate task pivot via compaction
8
+ *
9
+ * Also registers:
10
+ * - watchdog (advisory primacy-zone reminder after each turn)
11
+ * - system prompt injection (CONTEXT_PRIMER, nudge, ledger listing)
12
+ * - state reset on /new
13
+ */
14
+
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { createState, resetState, type AgenticodingState } from "./state.js";
17
+ import { CONTEXT_PRIMER } from "./system-prompt.js";
18
+ import { buildNudge, registerWatchdog } from "./watchdog.js";
19
+ import { registerLedgerTools } from "./ledger/tools.js";
20
+ import { registerLedgerRehydration } from "./ledger/rehydration.js";
21
+ import { registerHandoffTool } from "./handoff/tool.js";
22
+ import { registerHandoffCommand } from "./handoff/command.js";
23
+ import { registerHandoffCompaction } from "./handoff/compact.js";
24
+ import { registerSpawnTool } from "./spawn/index.js";
25
+
26
+ /** Build a status bar preview from ledger entries. */
27
+ function formatLedgerPreview(state: AgenticodingState): string {
28
+ const names = Array.from(state.ledger.keys()).sort();
29
+ if (names.length === 0) return "(empty)";
30
+ return names
31
+ .map((name) => {
32
+ const content = state.ledger.get(name)!;
33
+ const firstLine = (content.split("\n")[0] ?? "").slice(0, 60);
34
+ return `${name}: ${firstLine}`;
35
+ })
36
+ .join("\n");
37
+ }
38
+
39
+ /** Update TUI indicators: context usage + ledger count. */
40
+ function updateIndicators(ctx: ExtensionContext, state: AgenticodingState): void {
41
+ if (!ctx.hasUI) return;
42
+
43
+ const theme = ctx.ui.theme;
44
+
45
+ // Context usage
46
+ const usage = ctx.getContextUsage();
47
+ if (usage && usage.percent !== null) {
48
+ const pct = Math.round(usage.percent);
49
+ const tone = pct >= 70 ? "error" : pct >= 50 ? "warning" : pct >= 30 ? "accent" : "dim";
50
+ ctx.ui.setStatus("agenticoding-ctx", theme.fg("dim", "ctx ") + theme.fg(tone, `${pct}%`));
51
+ } else {
52
+ ctx.ui.setStatus("agenticoding-ctx", theme.fg("dim", "ctx --%"));
53
+ }
54
+
55
+ // Ledger count
56
+ const count = state.ledger.size;
57
+ ctx.ui.setStatus("agenticoding-ledger", count > 0 ? `\u{1F4D2} ${count}` : "");
58
+ }
59
+
60
+ export default function (pi: ExtensionAPI): void {
61
+ const state: AgenticodingState = createState();
62
+
63
+ // ── Register all tools ──────────────────────────────────────────
64
+ registerLedgerTools(pi, state);
65
+ registerHandoffTool(pi, state);
66
+ registerSpawnTool(pi, state);
67
+
68
+ // ── Register event handlers ─────────────────────────────────────
69
+ registerWatchdog(pi, state);
70
+ registerLedgerRehydration(pi, state);
71
+ registerHandoffCompaction(pi, state);
72
+
73
+ // ── Register commands ───────────────────────────────────────────
74
+ registerHandoffCommand(pi, state);
75
+
76
+ // ── /ledger command — show entries in overlay ───────────────────
77
+ pi.registerCommand("ledger", {
78
+ description: "Show ledger entries with name, line count, and first-line preview",
79
+ handler: async (_args, ctx) => {
80
+ const preview = formatLedgerPreview(state);
81
+ ctx.ui.notify(`Ledger (${state.ledger.size} entries):\n${preview}`, "info");
82
+ },
83
+ });
84
+
85
+ // ── before_agent_start: inject context primer + ledger ─────────
86
+ pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
87
+ // Update TUI indicators before each user-prompt agent run
88
+ updateIndicators(ctx, state);
89
+
90
+ const parts: string[] = [event.systemPrompt];
91
+
92
+ // Inject context management primer at the end of the system prompt
93
+ parts.push("\n" + CONTEXT_PRIMER);
94
+
95
+ // Inject ledger listing so the LLM always knows what's available
96
+ const entryNames = Array.from(state.ledger.keys()).sort();
97
+ if (entryNames.length > 0) {
98
+ const listing = entryNames
99
+ .map((name) => {
100
+ const content = state.ledger.get(name)!;
101
+ const firstLine = (content.split("\n")[0] ?? "").slice(0, 80);
102
+ return ` ${name}: ${firstLine}`;
103
+ })
104
+ .join("\n");
105
+ parts.push(
106
+ `\n## Active Ledger Entries\n` +
107
+ `The following entries are available via ledger_get by name:\n${listing}\n` +
108
+ `Reference entries by name — never paste bodies into prompts.`,
109
+ );
110
+ }
111
+
112
+ return { systemPrompt: parts.join("\n\n") };
113
+ });
114
+
115
+ // ── context: inject primacy-zone nudge before each LLM call ────
116
+ pi.on("context", async (event, ctx: ExtensionContext) => {
117
+ const usage = ctx.getContextUsage();
118
+ if (!usage || usage.percent === null || usage.percent < 30) {
119
+ return;
120
+ }
121
+
122
+ state.lastContextPercent = usage.percent;
123
+ return {
124
+ messages: [
125
+ ...event.messages,
126
+ {
127
+ role: "custom",
128
+ customType: "agenticoding-watchdog",
129
+ content: buildNudge(usage.percent),
130
+ display: false,
131
+ timestamp: Date.now(),
132
+ },
133
+ ],
134
+ };
135
+ });
136
+
137
+ // ── session_start: reset state + update indicators ─────────────
138
+ pi.on("session_start", async (event, ctx: ExtensionContext) => {
139
+ if (event.reason === "new") {
140
+ resetState(state);
141
+ }
142
+ updateIndicators(ctx, state);
143
+ });
144
+
145
+ // ── update TUI indicators after each turn ───────────────────────
146
+ pi.on("turn_end", async (_event, ctx: ExtensionContext) => {
147
+ updateIndicators(ctx, state);
148
+ });
149
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Ledger rehydration for the agenticoding extension.
3
+ *
4
+ * A session_start handler that scans the current branch newest-to-oldest for
5
+ * persisted ledger-entry custom entries, rebuilds the in-memory state.ledger
6
+ * Map (newest wins per name), and ensures ledger_get / ledger_list are active.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import type { AgenticodingState } from "../state.js";
11
+
12
+ // ── Types ─────────────────────────────────────────────────────────────
13
+
14
+ interface LedgerEntryData {
15
+ version: number;
16
+ epoch: number;
17
+ name: string;
18
+ content: string;
19
+ }
20
+
21
+ interface LedgerCandidate {
22
+ epoch: number;
23
+ content: string;
24
+ }
25
+
26
+ // ── Registration ──────────────────────────────────────────────────────
27
+
28
+ export function registerLedgerRehydration(
29
+ pi: ExtensionAPI,
30
+ state: AgenticodingState,
31
+ ): void {
32
+ pi.on("session_start", async (_event, ctx) => {
33
+ const branch = ctx.sessionManager.getBranch();
34
+
35
+ // Scan newest-to-oldest; first occurrence of each name wins (newest).
36
+ const candidates = new Map<string, LedgerCandidate>();
37
+
38
+ for (let i = branch.length - 1; i >= 0; i--) {
39
+ const entry = branch[i];
40
+
41
+ if (
42
+ entry.type !== "custom" ||
43
+ (entry as Record<string, unknown>).customType !== "ledger-entry"
44
+ ) {
45
+ continue;
46
+ }
47
+
48
+ const data = (entry as Record<string, unknown>).data as LedgerEntryData | undefined;
49
+ if (!data?.name || typeof data.content !== "string") continue;
50
+
51
+ // Skip if we already have a newer version of this name
52
+ if (candidates.has(data.name)) continue;
53
+
54
+ candidates.set(data.name, {
55
+ epoch: data.epoch,
56
+ content: data.content,
57
+ });
58
+ }
59
+
60
+ if (candidates.size === 0) return;
61
+
62
+ // Determine the current epoch from candidates.
63
+ // If state.epoch is already set (e.g., from first add before rehydration),
64
+ // filter to entries matching that epoch. Otherwise adopt the max epoch found.
65
+ let currentEpoch = state.epoch;
66
+ if (currentEpoch === 0) {
67
+ for (const [, c] of candidates) {
68
+ if (c.epoch > currentEpoch) currentEpoch = c.epoch;
69
+ }
70
+ state.epoch = currentEpoch;
71
+ }
72
+
73
+ // Rebuild state.ledger, filtering by epoch
74
+ state.ledger.clear();
75
+ for (const [name, candidate] of candidates) {
76
+ if (candidate.epoch === currentEpoch) {
77
+ state.ledger.set(name, candidate.content);
78
+ }
79
+ }
80
+
81
+ // Ensure ledger_get and ledger_list are active so the LLM can fetch entries
82
+ const active = pi.getActiveTools();
83
+ let changed = false;
84
+ if (!active.includes("ledger_get")) {
85
+ active.push("ledger_get");
86
+ changed = true;
87
+ }
88
+ if (!active.includes("ledger_list")) {
89
+ active.push("ledger_list");
90
+ changed = true;
91
+ }
92
+ if (changed) pi.setActiveTools(active);
93
+ });
94
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared ledger storage helpers.
3
+ *
4
+ * Keeps parent and spawned-child ledger writes on the same persistence path.
5
+ */
6
+
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ DEFAULT_MAX_BYTES,
10
+ DEFAULT_MAX_LINES,
11
+ truncateHead,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import type { AgenticodingState } from "../state.js";
14
+
15
+ let writeLock: Promise<void> = Promise.resolve();
16
+
17
+ async function acquireWriteLock(): Promise<() => void> {
18
+ let release: () => void;
19
+ const prev = writeLock;
20
+ writeLock = new Promise<void>((resolve) => {
21
+ release = resolve;
22
+ });
23
+ await prev;
24
+ return release!;
25
+ }
26
+
27
+ export function getEntryNames(state: AgenticodingState): string[] {
28
+ return Array.from(state.ledger.keys()).sort();
29
+ }
30
+
31
+ const PREVIEW_MAX_CHARS = 80;
32
+ const ELLIPSIS_LENGTH = 3;
33
+
34
+ export function formatEntryList(state: AgenticodingState): string {
35
+ const names = getEntryNames(state);
36
+ if (names.length === 0) return "";
37
+
38
+ return names
39
+ .map((name) => {
40
+ const content = state.ledger.get(name)!;
41
+ const firstLine = content.split("\n")[0] ?? "";
42
+ const preview =
43
+ firstLine.length > PREVIEW_MAX_CHARS
44
+ ? firstLine.slice(0, PREVIEW_MAX_CHARS - ELLIPSIS_LENGTH) + "..."
45
+ : firstLine;
46
+ return ` ${name}: ${preview}`;
47
+ })
48
+ .join("\n");
49
+ }
50
+
51
+ export async function saveLedgerEntry(
52
+ pi: ExtensionAPI,
53
+ state: AgenticodingState,
54
+ name: string,
55
+ content: string,
56
+ assertWritable?: () => void,
57
+ ): Promise<string[]> {
58
+ const release = await acquireWriteLock();
59
+ try {
60
+ assertWritable?.();
61
+ const truncated = truncateHead(content, {
62
+ maxLines: DEFAULT_MAX_LINES,
63
+ maxBytes: DEFAULT_MAX_BYTES,
64
+ });
65
+
66
+ if (state.epoch === 0) {
67
+ state.epoch = Date.now();
68
+ }
69
+
70
+ state.ledger.set(name, truncated.content);
71
+ pi.appendEntry("ledger-entry", {
72
+ version: 1,
73
+ epoch: state.epoch,
74
+ name,
75
+ content: truncated.content,
76
+ });
77
+
78
+ return getEntryNames(state);
79
+ } finally {
80
+ release();
81
+ }
82
+ }