@roodriigoooo/pi-docket 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.
- package/CHANGELOG.md +132 -0
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/assets/docket_logo.jpeg +0 -0
- package/docs/adr/0001-bundle-first-checkpoints.md +21 -0
- package/docs/adr/0002-rename-to-docket.md +44 -0
- package/docs/architecture.md +101 -0
- package/docs/bundle-guidelines.md +39 -0
- package/docs/configuration.md +191 -0
- package/docs/releases/0.4.0.md +93 -0
- package/extensions/artifact-catalog.ts +467 -0
- package/extensions/background-work.ts +510 -0
- package/extensions/checkpoint-commands.ts +147 -0
- package/extensions/checkpoint-lifecycle.ts +195 -0
- package/extensions/checkpoint-selector.ts +162 -0
- package/extensions/checkpoint-store.ts +230 -0
- package/extensions/checkpoint-summarizer.ts +141 -0
- package/extensions/docket-command-grammar.ts +319 -0
- package/extensions/docket-command-router.ts +626 -0
- package/extensions/docket-config.ts +88 -0
- package/extensions/docket-extension-surface.ts +43 -0
- package/extensions/docket-navigator.ts +585 -0
- package/extensions/docket.README.md +46 -0
- package/extensions/docket.ts +2965 -0
- package/extensions/event-log.ts +121 -0
- package/extensions/git-context.ts +44 -0
- package/extensions/loaded-artifact-context.ts +228 -0
- package/extensions/search-index.ts +140 -0
- package/extensions/types.ts +40 -0
- package/extensions/worker-activity.ts +402 -0
- package/extensions/worker-changes.ts +180 -0
- package/extensions/worker-commands.ts +251 -0
- package/extensions/worker-dock-cache.ts +147 -0
- package/extensions/worker-events.ts +87 -0
- package/extensions/worker-eviction.ts +55 -0
- package/extensions/worker-guardrails.md +125 -0
- package/extensions/worker-kinds/patcher.md +23 -0
- package/extensions/worker-kinds/scout.md +17 -0
- package/extensions/worker-kinds.ts +280 -0
- package/extensions/worker-result.ts +193 -0
- package/extensions/worker-store.ts +621 -0
- package/extensions/worker-summary-embed.ts +98 -0
- package/package.json +53 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { createArtifactCatalog, buildReferenceList, type ArtifactCatalog } from "./artifact-catalog.js";
|
|
3
|
+
import { showCheckpointSelector } from "./checkpoint-selector.js";
|
|
4
|
+
import { createCheckpointStore, type CheckpointStore } from "./checkpoint-store.js";
|
|
5
|
+
import { createCheckpointSummarizer, type CheckpointSummarizer } from "./checkpoint-summarizer.js";
|
|
6
|
+
import { gitSnapshotLabel, readGitSnapshot } from "./git-context.js";
|
|
7
|
+
import { loadConfig, type DocketConfig } from "./docket-config.js";
|
|
8
|
+
import type { CheckpointCreateOptions } from "./docket-command-grammar.js";
|
|
9
|
+
import type { Artifact, CheckpointIndexEntry, GitSnapshot } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export type CheckpointLifecycle = {
|
|
12
|
+
create(options: CheckpointCreateOptions): Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type NotifyLevel = "info" | "warning" | "error";
|
|
16
|
+
|
|
17
|
+
type CheckpointLifecycleDeps = {
|
|
18
|
+
loadConfig?: (cwd: string) => Promise<DocketConfig>;
|
|
19
|
+
createCatalog?: (ctx: ExtensionCommandContext, config: DocketConfig) => ArtifactCatalog;
|
|
20
|
+
store?: CheckpointStore;
|
|
21
|
+
summarizer?: CheckpointSummarizer;
|
|
22
|
+
makeId?: () => string;
|
|
23
|
+
reviewMarkdown?: (markdown: string) => Promise<string | null>;
|
|
24
|
+
selectArtifactsForCheckpoint?: (artifacts: Artifact[], options: CheckpointCreateOptions) => Promise<Artifact[] | null> | Artifact[] | null;
|
|
25
|
+
notify?: (text: string, level: NotifyLevel) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function makeCheckpointId(): string {
|
|
29
|
+
const d = new Date();
|
|
30
|
+
const stamp = d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
31
|
+
return stamp.replace("T", "-");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Bundle-first checkpoint: a small deterministic orientation header. The artifact bundle
|
|
35
|
+
// (.artifacts.json) is the spine; it is mounted at zero token cost on continue/load. This header
|
|
36
|
+
// is all that enters a fresh session's context — never the artifact contents. Decisions and next
|
|
37
|
+
// steps are human-authored (the note + the editor pass), not model-guessed. See ADR-0001.
|
|
38
|
+
function buildOrientationHeader(
|
|
39
|
+
ctx: ExtensionCommandContext,
|
|
40
|
+
id: string,
|
|
41
|
+
note: string,
|
|
42
|
+
consumeOnUse: boolean,
|
|
43
|
+
artifacts: Artifact[],
|
|
44
|
+
references: string,
|
|
45
|
+
git?: GitSnapshot,
|
|
46
|
+
): string {
|
|
47
|
+
const usage = ctx.getContextUsage();
|
|
48
|
+
const files = [...new Set(artifacts.filter((a) => a.kind === "file").map((a) => a.title.replace(/^(read|write|edit|grep|find|ls)\s+/, "")))];
|
|
49
|
+
const errors = artifacts.filter((a) => a.kind === "error");
|
|
50
|
+
|
|
51
|
+
const lines: string[] = [];
|
|
52
|
+
lines.push(`# Docket checkpoint ${id}`);
|
|
53
|
+
lines.push("");
|
|
54
|
+
lines.push("mode: handoff");
|
|
55
|
+
lines.push(`cwd: ${ctx.cwd}`);
|
|
56
|
+
lines.push(`created: ${new Date().toISOString()}`);
|
|
57
|
+
if (ctx.sessionManager.getSessionFile()) lines.push(`sourceSession: ${ctx.sessionManager.getSessionFile()}`);
|
|
58
|
+
const gitLabel = gitSnapshotLabel(git);
|
|
59
|
+
if (gitLabel) lines.push(`git: ${gitLabel}`);
|
|
60
|
+
if (usage && usage.tokens !== null) lines.push(`context: ~${usage.tokens.toLocaleString()} / ${usage.contextWindow.toLocaleString()} tokens`);
|
|
61
|
+
if (note) lines.push(`note: ${note}`);
|
|
62
|
+
if (consumeOnUse) lines.push("consumeOnUse: true");
|
|
63
|
+
lines.push("");
|
|
64
|
+
lines.push("## Resuming");
|
|
65
|
+
lines.push(note || "(state the goal you are resuming)");
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push("## Decisions");
|
|
68
|
+
lines.push("<!-- decisions that constrain the continuation; state facts, not guesses -->");
|
|
69
|
+
lines.push("");
|
|
70
|
+
lines.push("## Next steps");
|
|
71
|
+
lines.push("<!-- concrete, ordered -->");
|
|
72
|
+
lines.push("");
|
|
73
|
+
lines.push("## Files touched or inspected");
|
|
74
|
+
lines.push(files.length ? files.map((f) => `- ${f}`).join("\n") : "- (none captured)");
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push("## Errors to avoid repeating");
|
|
77
|
+
lines.push(errors.length ? errors.slice(0, 8).map((a) => `- ${a.title}: ${a.subtitle}`).join("\n") : "- (none captured)");
|
|
78
|
+
lines.push("");
|
|
79
|
+
lines.push("## Mounted artifacts");
|
|
80
|
+
lines.push("This checkpoint's artifacts are mounted at zero token cost. Read current file contents from disk; chip an artifact with `/docket ref <ref>` when you need its detail.");
|
|
81
|
+
lines.push("");
|
|
82
|
+
lines.push(references);
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function defaultNotify(pi: ExtensionAPI, ctx: ExtensionCommandContext, text: string, level: NotifyLevel): void {
|
|
87
|
+
if (ctx.hasUI) ctx.ui.notify(text, level);
|
|
88
|
+
else pi.sendMessage({ customType: "docket", content: text, display: true, details: { kind: level === "error" ? "error" : "notice" } }, { triggerTurn: false });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function createCheckpointLifecycle(pi: ExtensionAPI, ctx: ExtensionCommandContext, deps: CheckpointLifecycleDeps = {}): Promise<CheckpointLifecycle> {
|
|
92
|
+
const config = await (deps.loadConfig ?? loadConfig)(ctx.cwd);
|
|
93
|
+
const catalog = deps.createCatalog ? deps.createCatalog(ctx, config) : createArtifactCatalog(ctx, config);
|
|
94
|
+
const store = deps.store ?? createCheckpointStore();
|
|
95
|
+
const summarizer = deps.summarizer ?? createCheckpointSummarizer();
|
|
96
|
+
const notify = deps.notify ?? ((text: string, level: NotifyLevel) => defaultNotify(pi, ctx, text, level));
|
|
97
|
+
|
|
98
|
+
const selectArtifacts = (): Artifact[] => {
|
|
99
|
+
return catalog.selectForCheckpoint(config.checkpointArtifacts);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const reviewArtifactSelection = async (artifacts: Artifact[], options: CheckpointCreateOptions): Promise<Artifact[] | null> => {
|
|
103
|
+
if (deps.selectArtifactsForCheckpoint) return deps.selectArtifactsForCheckpoint(artifacts, options);
|
|
104
|
+
if (!ctx.hasUI) return artifacts;
|
|
105
|
+
return showCheckpointSelector(ctx, artifacts, "handoff");
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const draftMarkdown = async (id: string, options: CheckpointCreateOptions, artifacts: Artifact[], git?: GitSnapshot): Promise<string> => {
|
|
109
|
+
const header = buildOrientationHeader(ctx, id, options.note, options.consumeOnUse, artifacts, buildReferenceList(artifacts, ctx.cwd), git);
|
|
110
|
+
if (!options.summarize || !config.summarizer.enabled) return header;
|
|
111
|
+
if (ctx.hasUI) ctx.ui.notify("Docket summarizing checkpoint...", "info");
|
|
112
|
+
try {
|
|
113
|
+
return await summarizer.summarize({
|
|
114
|
+
id,
|
|
115
|
+
mode: "handoff",
|
|
116
|
+
note: options.note,
|
|
117
|
+
consumeOnUse: options.consumeOnUse,
|
|
118
|
+
cwd: ctx.cwd,
|
|
119
|
+
sourceSession: ctx.sessionManager.getSessionFile(),
|
|
120
|
+
git,
|
|
121
|
+
artifactsFile: store.artifactsFile(id),
|
|
122
|
+
payload: catalog.checkpointPayload(artifacts),
|
|
123
|
+
references: buildReferenceList(artifacts, ctx.cwd),
|
|
124
|
+
activeModel: ctx.model,
|
|
125
|
+
modelRegistry: ctx.modelRegistry,
|
|
126
|
+
config: config.summarizer,
|
|
127
|
+
overrides: { model: options.model, maxOutputTokens: options.maxOutputTokens },
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
notify(`Docket summarizer failed; using bundle header: ${String(err)}`, "warning");
|
|
131
|
+
return header;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const reviewMarkdown = async (markdown: string): Promise<string | null> => {
|
|
136
|
+
if (deps.reviewMarkdown) return deps.reviewMarkdown(markdown);
|
|
137
|
+
if (!ctx.hasUI) return markdown;
|
|
138
|
+
const edited = await ctx.ui.editor("Edit Docket checkpoint", markdown);
|
|
139
|
+
if (edited === undefined) return null;
|
|
140
|
+
return edited;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const persistCheckpoint = async (id: string, options: CheckpointCreateOptions, markdown: string, artifacts: Artifact[], git?: GitSnapshot): Promise<CheckpointIndexEntry> => {
|
|
144
|
+
return store.save({
|
|
145
|
+
id,
|
|
146
|
+
mode: "handoff",
|
|
147
|
+
markdown,
|
|
148
|
+
artifacts,
|
|
149
|
+
cwd: ctx.cwd,
|
|
150
|
+
sourceSession: ctx.sessionManager.getSessionFile(),
|
|
151
|
+
git,
|
|
152
|
+
note: options.note,
|
|
153
|
+
consumeOnUse: options.consumeOnUse,
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const labelSession = (id: string, entry: CheckpointIndexEntry): void => {
|
|
158
|
+
pi.appendEntry("docket:checkpoint", entry);
|
|
159
|
+
const leaf = ctx.sessionManager.getLeafId();
|
|
160
|
+
if (leaf) pi.setLabel(leaf, `docket:${id}`);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
async create(options: CheckpointCreateOptions): Promise<void> {
|
|
165
|
+
const candidates = selectArtifacts();
|
|
166
|
+
if (candidates.length === 0) {
|
|
167
|
+
notify("Docket found no artifacts to checkpoint", "warning");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const artifacts = await reviewArtifactSelection(candidates, options);
|
|
172
|
+
if (artifacts === null) {
|
|
173
|
+
notify("Docket checkpoint cancelled", "info");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (artifacts.length === 0) {
|
|
177
|
+
notify("Docket found no artifacts to checkpoint", "warning");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const id = (deps.makeId ?? makeCheckpointId)();
|
|
182
|
+
const git = readGitSnapshot(ctx.cwd);
|
|
183
|
+
const draft = await draftMarkdown(id, options, artifacts, git);
|
|
184
|
+
const markdown = await reviewMarkdown(draft);
|
|
185
|
+
if (markdown === null) {
|
|
186
|
+
notify("Docket checkpoint cancelled", "info");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const entry = await persistCheckpoint(id, options, markdown, artifacts, git);
|
|
191
|
+
labelSession(id, entry);
|
|
192
|
+
notify(`Docket checkpoint saved: ${id}${options.consumeOnUse ? " (once)" : ""}`, "info");
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Box, Key, Text, matchesKey, truncateToWidth, type Component, type TUI } from "@mariozechner/pi-tui";
|
|
3
|
+
import type { Artifact, ArtifactKind, CheckpointMode } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type CheckpointSelectionState = {
|
|
6
|
+
selected: number;
|
|
7
|
+
checked: boolean[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type CheckpointSelectionStats = {
|
|
11
|
+
total: number;
|
|
12
|
+
selected: number;
|
|
13
|
+
estimatedTokens: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function artifactChars(artifact: Artifact): number {
|
|
17
|
+
return artifact.title.length + artifact.subtitle.length + artifact.body.length;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function initialCheckpointSelection(artifacts: Artifact[]): CheckpointSelectionState {
|
|
21
|
+
return { selected: 0, checked: artifacts.map(() => true) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function toggleCheckpointSelection(state: CheckpointSelectionState, index = state.selected): CheckpointSelectionState {
|
|
25
|
+
if (index < 0 || index >= state.checked.length) return state;
|
|
26
|
+
return { ...state, checked: state.checked.map((checked, i) => (i === index ? !checked : checked)) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function selectAllCheckpointArtifacts(state: CheckpointSelectionState): CheckpointSelectionState {
|
|
30
|
+
return { ...state, checked: state.checked.map(() => true) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function selectNoCheckpointArtifacts(state: CheckpointSelectionState): CheckpointSelectionState {
|
|
34
|
+
return { ...state, checked: state.checked.map(() => false) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function selectedCheckpointArtifacts(artifacts: Artifact[], state: CheckpointSelectionState): Artifact[] {
|
|
38
|
+
return artifacts.filter((_, index) => state.checked[index]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function checkpointSelectionStats(artifacts: Artifact[], state: CheckpointSelectionState): CheckpointSelectionStats {
|
|
42
|
+
const selected = selectedCheckpointArtifacts(artifacts, state);
|
|
43
|
+
return {
|
|
44
|
+
total: artifacts.length,
|
|
45
|
+
selected: selected.length,
|
|
46
|
+
estimatedTokens: Math.ceil(selected.reduce((sum, artifact) => sum + artifactChars(artifact), 0) / 4),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clampSelected(selected: number, artifacts: Artifact[]): number {
|
|
51
|
+
return Math.min(Math.max(0, selected), Math.max(0, artifacts.length - 1));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function kindLabel(kind: ArtifactKind): string {
|
|
55
|
+
const labels: Record<ArtifactKind, string> = { command: "cmd", error: "err", file: "file", code: "code", prompt: "user", response: "ai", checkpoint: "ckpt" };
|
|
56
|
+
return labels[kind];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function colorKind(theme: any, kind: ArtifactKind, text: string): string {
|
|
60
|
+
if (kind === "error") return theme.fg("error", text);
|
|
61
|
+
if (kind === "command") return theme.fg("success", text);
|
|
62
|
+
if (kind === "file") return theme.fg("toolDiffAdded", text);
|
|
63
|
+
if (kind === "code") return theme.fg("warning", text);
|
|
64
|
+
if (kind === "checkpoint") return theme.fg("accent", text);
|
|
65
|
+
if (kind === "prompt") return theme.fg("customMessageLabel", text);
|
|
66
|
+
return theme.fg("muted", text);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class CheckpointSelectorView implements Component {
|
|
70
|
+
private state: CheckpointSelectionState;
|
|
71
|
+
private message = "";
|
|
72
|
+
private cachedWidth?: number;
|
|
73
|
+
private cachedLines?: string[];
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
private tui: TUI,
|
|
77
|
+
private theme: any,
|
|
78
|
+
private artifacts: Artifact[],
|
|
79
|
+
private mode: CheckpointMode,
|
|
80
|
+
private done: (result: Artifact[] | null) => void,
|
|
81
|
+
) {
|
|
82
|
+
this.state = initialCheckpointSelection(artifacts);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
handleInput(data: string): void {
|
|
86
|
+
const selected = clampSelected(this.state.selected, this.artifacts);
|
|
87
|
+
this.state = selected === this.state.selected ? this.state : { ...this.state, selected };
|
|
88
|
+
|
|
89
|
+
if (matchesKey(data, Key.escape) || data === "q" || matchesKey(data, Key.ctrl("c"))) {
|
|
90
|
+
this.done(null);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (data === "j" || matchesKey(data, Key.down)) this.state = { ...this.state, selected: clampSelected(selected + 1, this.artifacts) };
|
|
94
|
+
else if (data === "k" || matchesKey(data, Key.up)) this.state = { ...this.state, selected: Math.max(0, selected - 1) };
|
|
95
|
+
else if (data === "g") this.state = { ...this.state, selected: 0 };
|
|
96
|
+
else if (data === "G") this.state = { ...this.state, selected: Math.max(0, this.artifacts.length - 1) };
|
|
97
|
+
else if (data === " ") this.state = toggleCheckpointSelection(this.state);
|
|
98
|
+
else if (data === "a") this.state = selectAllCheckpointArtifacts(this.state);
|
|
99
|
+
else if (data === "n") this.state = selectNoCheckpointArtifacts(this.state);
|
|
100
|
+
else if (matchesKey(data, Key.enter)) {
|
|
101
|
+
const selectedArtifacts = selectedCheckpointArtifacts(this.artifacts, this.state);
|
|
102
|
+
if (selectedArtifacts.length === 0) this.message = "select at least one artifact or q cancel";
|
|
103
|
+
else this.done(selectedArtifacts);
|
|
104
|
+
this.invalidate();
|
|
105
|
+
this.tui.requestRender();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.message = "";
|
|
109
|
+
this.invalidate();
|
|
110
|
+
this.tui.requestRender();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
invalidate(): void {
|
|
114
|
+
this.cachedWidth = undefined;
|
|
115
|
+
this.cachedLines = undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
render(width: number): string[] {
|
|
119
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
120
|
+
const container = new Box(2, 1, (s) => this.theme.bg("customMessageBg", s));
|
|
121
|
+
const innerWidth = Math.max(20, width - 4);
|
|
122
|
+
const accent = (s: string) => this.theme.fg("accent", s);
|
|
123
|
+
const dim = (s: string) => this.theme.fg("dim", s);
|
|
124
|
+
const muted = (s: string) => this.theme.fg("muted", s);
|
|
125
|
+
const warning = (s: string) => this.theme.fg("warning", s);
|
|
126
|
+
const stats = checkpointSelectionStats(this.artifacts, this.state);
|
|
127
|
+
const header = `${accent(this.theme.bold("docket · checkpoint"))} ${dim(this.mode)} ${dim("·")} ${stats.selected}/${stats.total} selected ${dim("·")} ~${stats.estimatedTokens} tok`;
|
|
128
|
+
container.addChild(new Text(truncateToWidth(header, innerWidth - 2), 1, 0));
|
|
129
|
+
|
|
130
|
+
const windowSize = 14;
|
|
131
|
+
const selected = clampSelected(this.state.selected, this.artifacts);
|
|
132
|
+
const start = Math.max(0, Math.min(selected - Math.floor(windowSize / 2), this.artifacts.length - windowSize));
|
|
133
|
+
const visible = this.artifacts.slice(start, start + windowSize);
|
|
134
|
+
for (let i = 0; i < visible.length; i++) {
|
|
135
|
+
const artifact = visible[i]!;
|
|
136
|
+
const absolute = start + i;
|
|
137
|
+
const isSelected = absolute === selected;
|
|
138
|
+
const marker = isSelected ? accent("▸") : dim(" ");
|
|
139
|
+
const checked = this.state.checked[absolute] ? accent("[x]") : muted("[ ]");
|
|
140
|
+
const id = isSelected ? accent(this.theme.bold(artifact.displayId.padEnd(5))) : muted(artifact.displayId.padEnd(5));
|
|
141
|
+
const kind = colorKind(this.theme, artifact.kind, kindLabel(artifact.kind).padEnd(5));
|
|
142
|
+
const title = isSelected ? this.theme.bold(this.theme.fg("text", artifact.title)) : muted(artifact.title);
|
|
143
|
+
const line = `${marker} ${checked} ${id} ${kind} ${title} ${dim(artifact.subtitle)}`;
|
|
144
|
+
container.addChild(new Text(truncateToWidth(line, innerWidth - 2), 1, 0));
|
|
145
|
+
}
|
|
146
|
+
for (let i = visible.length; i < windowSize; i++) container.addChild(new Text("", 1, 0));
|
|
147
|
+
|
|
148
|
+
if (this.message) container.addChild(new Text(warning(this.message), 1, 0));
|
|
149
|
+
else container.addChild(new Text(dim("space toggle · a all · n none · enter create · q cancel"), 1, 0));
|
|
150
|
+
|
|
151
|
+
this.cachedLines = container.render(width);
|
|
152
|
+
this.cachedWidth = width;
|
|
153
|
+
return this.cachedLines;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function showCheckpointSelector(ctx: ExtensionCommandContext, artifacts: Artifact[], mode: CheckpointMode): Promise<Artifact[] | null> {
|
|
158
|
+
return ctx.ui.custom((tui, theme, _kb, done) => new CheckpointSelectorView(tui, theme, artifacts, mode, done), {
|
|
159
|
+
overlay: true,
|
|
160
|
+
overlayOptions: { anchor: "center", width: "88%", minWidth: 84, maxHeight: "90%", margin: 1 },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import { createEventLog, type EventLog, replayEvents } from "./event-log.js";
|
|
5
|
+
import type { Artifact, CheckpointIndexEntry, CheckpointMode, GitSnapshot } from "./types.js";
|
|
6
|
+
|
|
7
|
+
type CheckpointSaveInput = {
|
|
8
|
+
id: string;
|
|
9
|
+
mode: CheckpointMode;
|
|
10
|
+
markdown: string;
|
|
11
|
+
artifacts: Artifact[];
|
|
12
|
+
cwd: string;
|
|
13
|
+
sourceSession?: string;
|
|
14
|
+
git?: GitSnapshot;
|
|
15
|
+
note?: string;
|
|
16
|
+
consumeOnUse?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type CheckpointSummary = {
|
|
20
|
+
entry: CheckpointIndexEntry;
|
|
21
|
+
artifactCount: number;
|
|
22
|
+
files: number;
|
|
23
|
+
errors: number;
|
|
24
|
+
commands: number;
|
|
25
|
+
estimatedTokens: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ListOptions = { includeConsumed?: boolean };
|
|
29
|
+
|
|
30
|
+
export type CheckpointStore = {
|
|
31
|
+
save(input: CheckpointSaveInput): Promise<CheckpointIndexEntry>;
|
|
32
|
+
find(idOrLast: string, options?: ListOptions): Promise<CheckpointIndexEntry | undefined>;
|
|
33
|
+
list(options?: ListOptions): Promise<CheckpointIndexEntry[]>;
|
|
34
|
+
listSummaries(options?: ListOptions): Promise<CheckpointSummary[]>;
|
|
35
|
+
readMarkdown(checkpoint: CheckpointIndexEntry): Promise<string>;
|
|
36
|
+
readArtifacts(checkpoint: CheckpointIndexEntry): Promise<Artifact[]>;
|
|
37
|
+
markConsumed(checkpoint: CheckpointIndexEntry, timestamp?: string): Promise<void>;
|
|
38
|
+
purge(checkpoint: CheckpointIndexEntry): Promise<void>;
|
|
39
|
+
sweepConsumed(retentionDays: number): Promise<number>;
|
|
40
|
+
artifactsFile(id: string): string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function checkpointDir(): string {
|
|
44
|
+
return path.join(getAgentDir(), "docket", "checkpoints");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function checkpointIndexFile(): string {
|
|
48
|
+
return path.join(getAgentDir(), "docket", "index.json");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function checkpointMarkdownFile(id: string): string {
|
|
52
|
+
return path.join(checkpointDir(), `${id}.md`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function checkpointArtifactsFile(id: string): string {
|
|
56
|
+
return path.join(checkpointDir(), `${id}.artifacts.json`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readJsonFile<T>(file: string, fallback: T): Promise<T> {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(await fs.readFile(file, "utf8")) as T;
|
|
62
|
+
} catch {
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fileExists(file: string): Promise<boolean> {
|
|
68
|
+
try {
|
|
69
|
+
await fs.access(file);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function writeFileAtomic(file: string, content: string): Promise<void> {
|
|
77
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
78
|
+
const suffix = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
79
|
+
const tempFile = path.join(path.dirname(file), `.${path.basename(file)}.${suffix}.tmp`);
|
|
80
|
+
try {
|
|
81
|
+
await fs.writeFile(tempFile, content, "utf8");
|
|
82
|
+
await fs.rename(tempFile, file);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
await fs.rm(tempFile, { force: true });
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function loadLegacyIndex(): Promise<CheckpointIndexEntry[]> {
|
|
90
|
+
return readJsonFile<CheckpointIndexEntry[]>(checkpointIndexFile(), []);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function writeIndexSnapshot(entries: CheckpointIndexEntry[]): Promise<void> {
|
|
94
|
+
await writeFileAtomic(checkpointIndexFile(), `${JSON.stringify(entries, null, 2)}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function existingMarkdownEntries(entries: CheckpointIndexEntry[]): Promise<CheckpointIndexEntry[]> {
|
|
98
|
+
const checks = await Promise.all(entries.map((entry) => fileExists(entry.file)));
|
|
99
|
+
return entries.filter((_, index) => checks[index]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function checkpointArtifacts(id: string): Promise<Artifact[]> {
|
|
103
|
+
return readJsonFile<Artifact[]>(checkpointArtifactsFile(id), []);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function checkpointSummary(entry: CheckpointIndexEntry): Promise<CheckpointSummary> {
|
|
107
|
+
const [markdown, artifacts] = await Promise.all([
|
|
108
|
+
fs.readFile(entry.file, "utf8").catch(() => ""),
|
|
109
|
+
checkpointArtifacts(entry.id),
|
|
110
|
+
]);
|
|
111
|
+
const fileNames = new Set(artifacts.filter((artifact) => artifact.kind === "file").map((artifact) => artifact.title));
|
|
112
|
+
return {
|
|
113
|
+
entry,
|
|
114
|
+
artifactCount: artifacts.length,
|
|
115
|
+
files: fileNames.size,
|
|
116
|
+
errors: artifacts.filter((artifact) => artifact.kind === "error").length,
|
|
117
|
+
commands: artifacts.filter((artifact) => artifact.kind === "command").length,
|
|
118
|
+
estimatedTokens: Math.ceil(markdown.length / 4),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function applyConsumedFilter(entries: CheckpointIndexEntry[], options?: ListOptions): CheckpointIndexEntry[] {
|
|
123
|
+
if (options?.includeConsumed) return entries;
|
|
124
|
+
return entries.filter((entry) => !entry.consumedAt);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function loadIndexFromEvents(log: EventLog): Promise<CheckpointIndexEntry[]> {
|
|
128
|
+
const events = await log.read();
|
|
129
|
+
if (events.length > 0) return replayEvents(events);
|
|
130
|
+
const legacy = await loadLegacyIndex();
|
|
131
|
+
if (legacy.length > 0) {
|
|
132
|
+
await log.backfillFromIndex(legacy);
|
|
133
|
+
return replayEvents(await log.read());
|
|
134
|
+
}
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createCheckpointStore(): CheckpointStore {
|
|
139
|
+
const log = createEventLog();
|
|
140
|
+
return {
|
|
141
|
+
async save(input: CheckpointSaveInput): Promise<CheckpointIndexEntry> {
|
|
142
|
+
const file = checkpointMarkdownFile(input.id);
|
|
143
|
+
await writeFileAtomic(file, `${input.markdown.trim()}\n`);
|
|
144
|
+
await writeFileAtomic(checkpointArtifactsFile(input.id), `${JSON.stringify(input.artifacts, null, 2)}\n`);
|
|
145
|
+
|
|
146
|
+
const entry: CheckpointIndexEntry = {
|
|
147
|
+
id: input.id,
|
|
148
|
+
mode: input.mode,
|
|
149
|
+
file,
|
|
150
|
+
createdAt: new Date().toISOString(),
|
|
151
|
+
cwd: input.cwd,
|
|
152
|
+
sourceSession: input.sourceSession,
|
|
153
|
+
git: input.git,
|
|
154
|
+
note: input.note,
|
|
155
|
+
consumeOnUse: input.consumeOnUse,
|
|
156
|
+
};
|
|
157
|
+
await log.append({ type: "checkpoint_saved", timestamp: entry.createdAt, entry });
|
|
158
|
+
await writeIndexSnapshot(await loadIndexFromEvents(log));
|
|
159
|
+
return entry;
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async find(idOrLast: string, options?: ListOptions): Promise<CheckpointIndexEntry | undefined> {
|
|
163
|
+
const index = await this.list(options);
|
|
164
|
+
if (index.length === 0) return undefined;
|
|
165
|
+
if (!idOrLast || idOrLast === "last") return index[index.length - 1];
|
|
166
|
+
return [...index].reverse().find((entry) => entry.id === idOrLast || entry.id.startsWith(idOrLast));
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async list(options?: ListOptions): Promise<CheckpointIndexEntry[]> {
|
|
170
|
+
const present = await existingMarkdownEntries(await loadIndexFromEvents(log));
|
|
171
|
+
return applyConsumedFilter(present, options);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async listSummaries(options?: ListOptions): Promise<CheckpointSummary[]> {
|
|
175
|
+
return Promise.all((await this.list(options)).map((entry) => checkpointSummary(entry)));
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async readMarkdown(checkpoint: CheckpointIndexEntry): Promise<string> {
|
|
179
|
+
return fs.readFile(checkpoint.file, "utf8");
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async readArtifacts(checkpoint: CheckpointIndexEntry): Promise<Artifact[]> {
|
|
183
|
+
return checkpointArtifacts(checkpoint.id);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async markConsumed(checkpoint: CheckpointIndexEntry, timestamp?: string): Promise<void> {
|
|
187
|
+
const stamp = timestamp ?? new Date().toISOString();
|
|
188
|
+
const current = await loadIndexFromEvents(log);
|
|
189
|
+
const known = current.find((entry) => entry.id === checkpoint.id);
|
|
190
|
+
if (!known || known.consumedAt) return;
|
|
191
|
+
await log.append({ type: "checkpoint_consumed", timestamp: stamp, id: checkpoint.id, consumedAt: stamp });
|
|
192
|
+
await writeIndexSnapshot(await loadIndexFromEvents(log));
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async purge(checkpoint: CheckpointIndexEntry): Promise<void> {
|
|
196
|
+
await log.append({ type: "checkpoint_purged", timestamp: new Date().toISOString(), id: checkpoint.id });
|
|
197
|
+
await writeIndexSnapshot(await loadIndexFromEvents(log));
|
|
198
|
+
await fs.rm(checkpoint.file, { force: true });
|
|
199
|
+
await fs.rm(checkpointArtifactsFile(checkpoint.id), { force: true });
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
async sweepConsumed(retentionDays: number): Promise<number> {
|
|
203
|
+
if (!Number.isFinite(retentionDays) || retentionDays < 0) return 0;
|
|
204
|
+
const current = await loadIndexFromEvents(log);
|
|
205
|
+
const cutoff = Date.now() - retentionDays * 86400000;
|
|
206
|
+
const expired: CheckpointIndexEntry[] = [];
|
|
207
|
+
for (const entry of current) {
|
|
208
|
+
const consumed = entry.consumedAt ? Date.parse(entry.consumedAt) : NaN;
|
|
209
|
+
if (Number.isFinite(consumed) && consumed <= cutoff) expired.push(entry);
|
|
210
|
+
}
|
|
211
|
+
if (expired.length === 0) return 0;
|
|
212
|
+
await log.append({
|
|
213
|
+
type: "checkpoint_swept",
|
|
214
|
+
timestamp: new Date().toISOString(),
|
|
215
|
+
ids: expired.map((entry) => entry.id),
|
|
216
|
+
retentionDays,
|
|
217
|
+
});
|
|
218
|
+
await writeIndexSnapshot(await loadIndexFromEvents(log));
|
|
219
|
+
await Promise.all(expired.flatMap((entry) => [
|
|
220
|
+
fs.rm(entry.file, { force: true }),
|
|
221
|
+
fs.rm(checkpointArtifactsFile(entry.id), { force: true }),
|
|
222
|
+
]));
|
|
223
|
+
return expired.length;
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
artifactsFile(id: string): string {
|
|
227
|
+
return checkpointArtifactsFile(id);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|