@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,121 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import type { CheckpointIndexEntry } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type CheckpointEvent =
|
|
7
|
+
| { type: "checkpoint_saved"; timestamp: string; entry: CheckpointIndexEntry }
|
|
8
|
+
| { type: "checkpoint_consumed"; timestamp: string; id: string; consumedAt: string }
|
|
9
|
+
| { type: "checkpoint_unconsumed"; timestamp: string; id: string }
|
|
10
|
+
| { type: "checkpoint_purged"; timestamp: string; id: string }
|
|
11
|
+
| { type: "checkpoint_swept"; timestamp: string; ids: string[]; retentionDays: number };
|
|
12
|
+
|
|
13
|
+
export type EventLog = {
|
|
14
|
+
append(event: CheckpointEvent): Promise<void>;
|
|
15
|
+
read(): Promise<CheckpointEvent[]>;
|
|
16
|
+
rebuildIndex(): Promise<CheckpointIndexEntry[]>;
|
|
17
|
+
backfillFromIndex(entries: CheckpointIndexEntry[]): Promise<void>;
|
|
18
|
+
path(): string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function eventLogFile(): string {
|
|
22
|
+
return path.join(getAgentDir(), "docket", "events.ndjson");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function ensureParent(file: string): Promise<void> {
|
|
26
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fileExists(file: string): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
await fs.access(file);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function applyEvent(state: Map<string, CheckpointIndexEntry>, event: CheckpointEvent): void {
|
|
39
|
+
if (event.type === "checkpoint_saved") {
|
|
40
|
+
state.set(event.entry.id, { ...event.entry });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (event.type === "checkpoint_consumed") {
|
|
44
|
+
const entry = state.get(event.id);
|
|
45
|
+
if (entry && !entry.consumedAt) state.set(event.id, { ...entry, consumedAt: event.consumedAt });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (event.type === "checkpoint_unconsumed") {
|
|
49
|
+
const entry = state.get(event.id);
|
|
50
|
+
if (entry?.consumedAt) {
|
|
51
|
+
const { consumedAt: _drop, ...rest } = entry;
|
|
52
|
+
state.set(event.id, rest);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (event.type === "checkpoint_purged") {
|
|
57
|
+
state.delete(event.id);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (event.type === "checkpoint_swept") {
|
|
61
|
+
for (const id of event.ids) state.delete(id);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function replayEvents(events: CheckpointEvent[]): CheckpointIndexEntry[] {
|
|
67
|
+
const state = new Map<string, CheckpointIndexEntry>();
|
|
68
|
+
for (const event of events) applyEvent(state, event);
|
|
69
|
+
return [...state.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseLine(line: string): CheckpointEvent | undefined {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (!trimmed) return undefined;
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(trimmed) as Partial<CheckpointEvent> & { type?: string };
|
|
77
|
+
if (typeof parsed.type !== "string") return undefined;
|
|
78
|
+
return parsed as CheckpointEvent;
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createEventLog(): EventLog {
|
|
85
|
+
const file = eventLogFile();
|
|
86
|
+
return {
|
|
87
|
+
path() {
|
|
88
|
+
return file;
|
|
89
|
+
},
|
|
90
|
+
async append(event: CheckpointEvent): Promise<void> {
|
|
91
|
+
await ensureParent(file);
|
|
92
|
+
await fs.appendFile(file, `${JSON.stringify(event)}\n`, "utf8");
|
|
93
|
+
},
|
|
94
|
+
async read(): Promise<CheckpointEvent[]> {
|
|
95
|
+
if (!(await fileExists(file))) return [];
|
|
96
|
+
const raw = await fs.readFile(file, "utf8");
|
|
97
|
+
const events: CheckpointEvent[] = [];
|
|
98
|
+
for (const line of raw.split("\n")) {
|
|
99
|
+
const event = parseLine(line);
|
|
100
|
+
if (event) events.push(event);
|
|
101
|
+
}
|
|
102
|
+
return events;
|
|
103
|
+
},
|
|
104
|
+
async rebuildIndex(): Promise<CheckpointIndexEntry[]> {
|
|
105
|
+
return replayEvents(await this.read());
|
|
106
|
+
},
|
|
107
|
+
async backfillFromIndex(entries: CheckpointIndexEntry[]): Promise<void> {
|
|
108
|
+
if (entries.length === 0) return;
|
|
109
|
+
if (await fileExists(file)) return;
|
|
110
|
+
await ensureParent(file);
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
lines.push(JSON.stringify({ type: "checkpoint_saved", timestamp: entry.createdAt, entry } satisfies CheckpointEvent));
|
|
114
|
+
if (entry.consumedAt) {
|
|
115
|
+
lines.push(JSON.stringify({ type: "checkpoint_consumed", timestamp: entry.consumedAt, id: entry.id, consumedAt: entry.consumedAt } satisfies CheckpointEvent));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
await fs.writeFile(file, `${lines.join("\n")}\n`, "utf8");
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import type { GitSnapshot } from "./types.js";
|
|
3
|
+
|
|
4
|
+
function runGit(cwd: string, args: string[]): string | undefined {
|
|
5
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
6
|
+
if (result.error || result.status !== 0) return undefined;
|
|
7
|
+
return result.stdout.trim();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseGitPorcelain(output: string): Pick<GitSnapshot, "dirty" | "staged" | "unstaged" | "untracked"> {
|
|
11
|
+
const lines = output.split(/\r?\n/).filter((line) => line.length > 0);
|
|
12
|
+
let staged = 0;
|
|
13
|
+
let unstaged = 0;
|
|
14
|
+
let untracked = 0;
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
if (line.startsWith("??")) {
|
|
17
|
+
untracked++;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (line[0] && line[0] !== " ") staged++;
|
|
21
|
+
if (line[1] && line[1] !== " ") unstaged++;
|
|
22
|
+
}
|
|
23
|
+
return { dirty: lines.length, staged, unstaged, untracked };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readGitSnapshot(cwd: string): GitSnapshot | undefined {
|
|
27
|
+
if (runGit(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true") return undefined;
|
|
28
|
+
const branch = runGit(cwd, ["branch", "--show-current"]);
|
|
29
|
+
const head = runGit(cwd, ["rev-parse", "--short", "HEAD"]);
|
|
30
|
+
const porcelain = runGit(cwd, ["status", "--porcelain"]);
|
|
31
|
+
const counts = parseGitPorcelain(porcelain ?? "");
|
|
32
|
+
return {
|
|
33
|
+
branch: branch || undefined,
|
|
34
|
+
head: head || undefined,
|
|
35
|
+
...counts,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function gitSnapshotLabel(git: GitSnapshot | undefined): string | undefined {
|
|
40
|
+
if (!git) return undefined;
|
|
41
|
+
const base = git.branch || (git.head ? `@${git.head}` : undefined);
|
|
42
|
+
if (!base) return undefined;
|
|
43
|
+
return git.dirty && git.dirty > 0 ? `${base} ±${git.dirty}` : base;
|
|
44
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { createArtifactCatalog, type ArtifactCatalog, type DocketRuntimeContext } from "./artifact-catalog.js";
|
|
2
|
+
import { loadConfig, type DocketConfig } from "./docket-config.js";
|
|
3
|
+
import type { Artifact, ArtifactKind, CheckpointIndexEntry } from "./types.js";
|
|
4
|
+
import { workerShortLabel, type WorkerStatus } from "./background-work.js";
|
|
5
|
+
|
|
6
|
+
export type ChipMode = "ref" | "full";
|
|
7
|
+
|
|
8
|
+
export type Chip = {
|
|
9
|
+
displayId: string;
|
|
10
|
+
ref: string;
|
|
11
|
+
mode: ChipMode;
|
|
12
|
+
kind: ArtifactKind;
|
|
13
|
+
title: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ChipToggleResult = "added" | "removed" | "upgraded" | "downgraded";
|
|
17
|
+
|
|
18
|
+
export type CarryoverKind = "checkpoint" | "worker";
|
|
19
|
+
|
|
20
|
+
export type CarryoverSlot = {
|
|
21
|
+
slot: string;
|
|
22
|
+
kind: CarryoverKind;
|
|
23
|
+
sourceId: string;
|
|
24
|
+
artifacts: Artifact[];
|
|
25
|
+
checkpoint?: CheckpointIndexEntry;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type LoadableSource =
|
|
29
|
+
| { kind: "checkpoint"; checkpoint: CheckpointIndexEntry }
|
|
30
|
+
| { kind: "worker"; worker: WorkerStatus };
|
|
31
|
+
|
|
32
|
+
export type LoadableSourceCandidates = {
|
|
33
|
+
checkpoints: CheckpointIndexEntry[];
|
|
34
|
+
workers: WorkerStatus[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type LoadResult = {
|
|
38
|
+
source: LoadableSource;
|
|
39
|
+
slot: CarryoverSlot;
|
|
40
|
+
queuedConsume: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ChipExpansion = {
|
|
44
|
+
text: string;
|
|
45
|
+
expanded: number;
|
|
46
|
+
missing: string[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type LoadedArtifactContextDeps = {
|
|
50
|
+
loadConfig?: (cwd: string) => Promise<DocketConfig>;
|
|
51
|
+
createCatalog?: (ctx: DocketRuntimeContext, config: DocketConfig, carryover: Artifact[]) => ArtifactCatalog;
|
|
52
|
+
readCheckpointArtifacts: (checkpoint: CheckpointIndexEntry) => Promise<Artifact[]>;
|
|
53
|
+
readWorkerArtifacts: (worker: WorkerStatus) => Promise<Artifact[]>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type LoadedArtifactContext = {
|
|
57
|
+
chips(): Chip[];
|
|
58
|
+
slots(): CarryoverSlot[];
|
|
59
|
+
carryoverArtifacts(): Artifact[];
|
|
60
|
+
reset(): void;
|
|
61
|
+
defaultLoadSource(candidates: LoadableSourceCandidates): LoadableSource | undefined;
|
|
62
|
+
loadSource(source: LoadableSource): Promise<LoadResult>;
|
|
63
|
+
loadCheckpoint(checkpoint: CheckpointIndexEntry): Promise<CarryoverSlot>;
|
|
64
|
+
loadWorker(worker: WorkerStatus): Promise<CarryoverSlot>;
|
|
65
|
+
unloadSlot(slot: string): CarryoverSlot | undefined;
|
|
66
|
+
unloadSource(kind: CarryoverKind, sourceId: string): CarryoverSlot | undefined;
|
|
67
|
+
queueCheckpointConsume(checkpoint: CheckpointIndexEntry): void;
|
|
68
|
+
drainCheckpointConsumes(markConsumed: (checkpoint: CheckpointIndexEntry) => Promise<void>): Promise<void>;
|
|
69
|
+
toggleChip(artifact: Artifact, mode: ChipMode): ChipToggleResult;
|
|
70
|
+
clearChips(): boolean;
|
|
71
|
+
expandChipsForSubmit(ctx: DocketRuntimeContext, userText: string): Promise<ChipExpansion>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function namespaceCarryover(artifacts: Artifact[], slot: string): Artifact[] {
|
|
75
|
+
return artifacts.map((artifact) => {
|
|
76
|
+
const namespacedId = `${slot}.${artifact.displayId}`;
|
|
77
|
+
return { ...artifact, id: namespacedId, displayId: namespacedId, source: slot };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderChipBlock(chip: Chip, content: string): string {
|
|
82
|
+
const opener = `<<docket @${chip.displayId} ${chip.mode}>>`;
|
|
83
|
+
const closer = `<</docket>>`;
|
|
84
|
+
return `${opener}\n${content}\n${closer}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createLoadedArtifactContext(deps: LoadedArtifactContextDeps): LoadedArtifactContext {
|
|
88
|
+
let chips: Chip[] = [];
|
|
89
|
+
let carryover: Map<string, CarryoverSlot> = new Map();
|
|
90
|
+
let pendingCheckpointConsumes: Map<string, CheckpointIndexEntry> = new Map();
|
|
91
|
+
let nextCheckpointSlotIndex = 1;
|
|
92
|
+
|
|
93
|
+
const findSlotForSource = (kind: CarryoverKind, sourceId: string): CarryoverSlot | undefined => {
|
|
94
|
+
for (const slot of carryover.values()) {
|
|
95
|
+
if (slot.kind === kind && slot.sourceId === sourceId) return slot;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const carryoverArtifacts = (): Artifact[] => {
|
|
101
|
+
const out: Artifact[] = [];
|
|
102
|
+
for (const slot of carryover.values()) out.push(...slot.artifacts);
|
|
103
|
+
return out;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const unloadSlot = (slot: string): CarryoverSlot | undefined => {
|
|
107
|
+
const entry = carryover.get(slot);
|
|
108
|
+
if (!entry) return undefined;
|
|
109
|
+
carryover.delete(slot);
|
|
110
|
+
if (entry.kind === "checkpoint") pendingCheckpointConsumes.delete(entry.sourceId);
|
|
111
|
+
return entry;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const loadCheckpoint = async (checkpoint: CheckpointIndexEntry): Promise<CarryoverSlot> => {
|
|
115
|
+
const existing = findSlotForSource("checkpoint", checkpoint.id);
|
|
116
|
+
if (existing) return existing;
|
|
117
|
+
const raw = await deps.readCheckpointArtifacts(checkpoint);
|
|
118
|
+
const slot = `c${nextCheckpointSlotIndex++}`;
|
|
119
|
+
const entry: CarryoverSlot = { slot, kind: "checkpoint", sourceId: checkpoint.id, artifacts: namespaceCarryover(raw, slot), checkpoint };
|
|
120
|
+
carryover.set(slot, entry);
|
|
121
|
+
return entry;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const loadWorker = async (worker: WorkerStatus): Promise<CarryoverSlot> => {
|
|
125
|
+
const existing = findSlotForSource("worker", worker.id);
|
|
126
|
+
if (existing) return existing;
|
|
127
|
+
const raw = await deps.readWorkerArtifacts(worker);
|
|
128
|
+
const slot = workerShortLabel(worker.index);
|
|
129
|
+
const entry: CarryoverSlot = { slot, kind: "worker", sourceId: worker.id, artifacts: namespaceCarryover(raw, slot) };
|
|
130
|
+
carryover.set(slot, entry);
|
|
131
|
+
return entry;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const loadSource = async (source: LoadableSource): Promise<LoadResult> => {
|
|
135
|
+
const slot = source.kind === "checkpoint" ? await loadCheckpoint(source.checkpoint) : await loadWorker(source.worker);
|
|
136
|
+
const queuedConsume = source.kind === "checkpoint" && source.checkpoint.consumeOnUse === true;
|
|
137
|
+
if (queuedConsume) pendingCheckpointConsumes.set(source.checkpoint.id, source.checkpoint);
|
|
138
|
+
return { source, slot, queuedConsume };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
chips() {
|
|
143
|
+
return [...chips];
|
|
144
|
+
},
|
|
145
|
+
slots() {
|
|
146
|
+
return [...carryover.values()];
|
|
147
|
+
},
|
|
148
|
+
carryoverArtifacts,
|
|
149
|
+
reset() {
|
|
150
|
+
chips = [];
|
|
151
|
+
carryover = new Map();
|
|
152
|
+
pendingCheckpointConsumes = new Map();
|
|
153
|
+
nextCheckpointSlotIndex = 1;
|
|
154
|
+
},
|
|
155
|
+
defaultLoadSource(candidates: LoadableSourceCandidates): LoadableSource | undefined {
|
|
156
|
+
const checkpoint = candidates.checkpoints[candidates.checkpoints.length - 1];
|
|
157
|
+
if (checkpoint) return { kind: "checkpoint", checkpoint };
|
|
158
|
+
const worker = candidates.workers[candidates.workers.length - 1];
|
|
159
|
+
return worker ? { kind: "worker", worker } : undefined;
|
|
160
|
+
},
|
|
161
|
+
loadSource,
|
|
162
|
+
loadCheckpoint(checkpoint: CheckpointIndexEntry): Promise<CarryoverSlot> {
|
|
163
|
+
return loadSource({ kind: "checkpoint", checkpoint }).then((result) => result.slot);
|
|
164
|
+
},
|
|
165
|
+
loadWorker(worker: WorkerStatus): Promise<CarryoverSlot> {
|
|
166
|
+
return loadSource({ kind: "worker", worker }).then((result) => result.slot);
|
|
167
|
+
},
|
|
168
|
+
unloadSlot,
|
|
169
|
+
unloadSource(kind: CarryoverKind, sourceId: string): CarryoverSlot | undefined {
|
|
170
|
+
const entry = findSlotForSource(kind, sourceId);
|
|
171
|
+
if (!entry) return undefined;
|
|
172
|
+
return unloadSlot(entry.slot);
|
|
173
|
+
},
|
|
174
|
+
queueCheckpointConsume(checkpoint: CheckpointIndexEntry): void {
|
|
175
|
+
pendingCheckpointConsumes.set(checkpoint.id, checkpoint);
|
|
176
|
+
},
|
|
177
|
+
async drainCheckpointConsumes(markConsumed: (checkpoint: CheckpointIndexEntry) => Promise<void>): Promise<void> {
|
|
178
|
+
if (pendingCheckpointConsumes.size === 0) return;
|
|
179
|
+
const pending = [...pendingCheckpointConsumes.values()];
|
|
180
|
+
pendingCheckpointConsumes = new Map();
|
|
181
|
+
await Promise.all(pending.map(async (checkpoint) => {
|
|
182
|
+
try { await markConsumed(checkpoint); }
|
|
183
|
+
catch { /* best-effort */ }
|
|
184
|
+
}));
|
|
185
|
+
},
|
|
186
|
+
toggleChip(artifact: Artifact, mode: ChipMode): ChipToggleResult {
|
|
187
|
+
const idx = chips.findIndex((c) => c.ref === artifact.ref);
|
|
188
|
+
if (idx === -1) {
|
|
189
|
+
chips = [...chips, { displayId: artifact.displayId, ref: artifact.ref, mode, kind: artifact.kind, title: artifact.title }];
|
|
190
|
+
return "added";
|
|
191
|
+
}
|
|
192
|
+
const existing = chips[idx]!;
|
|
193
|
+
if (existing.mode === mode) {
|
|
194
|
+
chips = chips.filter((_, i) => i !== idx);
|
|
195
|
+
return "removed";
|
|
196
|
+
}
|
|
197
|
+
chips = chips.map((c, i) => (i === idx ? { ...c, mode } : c));
|
|
198
|
+
return mode === "full" ? "upgraded" : "downgraded";
|
|
199
|
+
},
|
|
200
|
+
clearChips(): boolean {
|
|
201
|
+
if (chips.length === 0) return false;
|
|
202
|
+
chips = [];
|
|
203
|
+
return true;
|
|
204
|
+
},
|
|
205
|
+
async expandChipsForSubmit(ctx: DocketRuntimeContext, userText: string): Promise<ChipExpansion> {
|
|
206
|
+
if (chips.length === 0) return { text: userText, expanded: 0, missing: [] };
|
|
207
|
+
const config = await (deps.loadConfig ?? loadConfig)(ctx.cwd);
|
|
208
|
+
const catalog = (deps.createCatalog ?? createArtifactCatalog)(ctx, config, carryoverArtifacts());
|
|
209
|
+
const blocks: string[] = [];
|
|
210
|
+
const missing: string[] = [];
|
|
211
|
+
for (const chip of chips) {
|
|
212
|
+
const artifact = catalog.find(chip.ref) ?? catalog.find(chip.displayId);
|
|
213
|
+
if (!artifact) {
|
|
214
|
+
missing.push(chip.displayId);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const body = chip.mode === "full" ? catalog.fullText(artifact) : catalog.reference(artifact);
|
|
218
|
+
blocks.push(renderChipBlock(chip, body));
|
|
219
|
+
}
|
|
220
|
+
if (blocks.length === 0) return { text: userText, expanded: 0, missing };
|
|
221
|
+
const header = `<<docket-context: ${blocks.length} reference${blocks.length === 1 ? "" : "s"}>>`;
|
|
222
|
+
const footer = `<</docket-context>>`;
|
|
223
|
+
const wrapped = `${header}\n${blocks.join("\n\n")}\n${footer}`;
|
|
224
|
+
const text = userText.trim() ? `${wrapped}\n\n${userText}` : wrapped;
|
|
225
|
+
return { text, expanded: blocks.length, missing };
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { Artifact, ArtifactKind } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export type ArtifactSearchDocument = {
|
|
8
|
+
id: string;
|
|
9
|
+
artifact: Artifact;
|
|
10
|
+
title: string;
|
|
11
|
+
body: string;
|
|
12
|
+
rankText: {
|
|
13
|
+
primary: string;
|
|
14
|
+
body: string;
|
|
15
|
+
metadata: string;
|
|
16
|
+
};
|
|
17
|
+
content: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type RipgrepAdapter = (query: string, documents: ArtifactSearchDocument[]) => Promise<Set<string>>;
|
|
21
|
+
|
|
22
|
+
export type ArtifactSearchOptions = {
|
|
23
|
+
runRipgrep?: RipgrepAdapter;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const KIND_WEIGHT: Record<ArtifactKind, number> = {
|
|
27
|
+
error: 700,
|
|
28
|
+
file: 600,
|
|
29
|
+
command: 500,
|
|
30
|
+
code: 400,
|
|
31
|
+
checkpoint: 300,
|
|
32
|
+
prompt: 200,
|
|
33
|
+
response: 100,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function includes(text: string, query: string): boolean {
|
|
37
|
+
return text.toLowerCase().includes(query);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatSearchDocument(artifact: Artifact, metadata: string): string {
|
|
41
|
+
return [
|
|
42
|
+
`# Docket artifact ${artifact.displayId}`,
|
|
43
|
+
`ref: ${artifact.ref}`,
|
|
44
|
+
`kind: ${artifact.kind}`,
|
|
45
|
+
artifact.entryId ? `entry: ${artifact.entryId}` : undefined,
|
|
46
|
+
artifact.title ? `title: ${artifact.title}` : undefined,
|
|
47
|
+
artifact.subtitle ? `subtitle: ${artifact.subtitle}` : undefined,
|
|
48
|
+
"",
|
|
49
|
+
artifact.title,
|
|
50
|
+
"",
|
|
51
|
+
artifact.body,
|
|
52
|
+
"",
|
|
53
|
+
"metadata:",
|
|
54
|
+
metadata,
|
|
55
|
+
].filter((line): line is string => line !== undefined).join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildArtifactSearchDocument(artifact: Artifact): ArtifactSearchDocument {
|
|
59
|
+
const metadata = JSON.stringify(artifact.meta ?? {}, null, 2);
|
|
60
|
+
const primary = [artifact.displayId, artifact.ref, artifact.kind, artifact.title, artifact.subtitle].filter(Boolean).join("\n");
|
|
61
|
+
return {
|
|
62
|
+
id: artifact.displayId,
|
|
63
|
+
artifact,
|
|
64
|
+
title: artifact.title,
|
|
65
|
+
body: artifact.body,
|
|
66
|
+
rankText: { primary, body: artifact.body, metadata },
|
|
67
|
+
content: formatSearchDocument(artifact, metadata),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runCommand(command: string, args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const child = spawn(command, args);
|
|
74
|
+
let stdout = "";
|
|
75
|
+
let stderr = "";
|
|
76
|
+
child.stdout.on("data", (data) => (stdout += data.toString("utf8")));
|
|
77
|
+
child.stderr.on("data", (data) => (stderr += data.toString("utf8")));
|
|
78
|
+
child.on("error", reject);
|
|
79
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
80
|
+
child.stdin.end();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function safeFileName(index: number, document: ArtifactSearchDocument): string {
|
|
85
|
+
const id = document.artifact.displayId.replace(/[^a-zA-Z0-9._-]/g, "_") || "artifact";
|
|
86
|
+
return `${index}-${id}.md`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runRipgrepAdapter(query: string, documents: ArtifactSearchDocument[]): Promise<Set<string>> {
|
|
90
|
+
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "pi-docket-search-"));
|
|
91
|
+
try {
|
|
92
|
+
const byFile = new Map<string, string>();
|
|
93
|
+
for (let i = 0; i < documents.length; i++) {
|
|
94
|
+
const document = documents[i]!;
|
|
95
|
+
const fileName = safeFileName(i, document);
|
|
96
|
+
byFile.set(fileName, document.id);
|
|
97
|
+
await fs.writeFile(path.join(tempDir, fileName), document.content, "utf8");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const result = await runCommand("rg", ["--files-with-matches", "--fixed-strings", "--ignore-case", "-e", query, tempDir]);
|
|
101
|
+
if (result.code === 0) {
|
|
102
|
+
return new Set(result.stdout.split("\n").map((line) => byFile.get(path.basename(line))).filter((id): id is string => Boolean(id)));
|
|
103
|
+
}
|
|
104
|
+
if (result.code === 1) return new Set();
|
|
105
|
+
throw new Error(result.stderr || `rg exited ${result.code}`);
|
|
106
|
+
} finally {
|
|
107
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function scoreDocument(query: string, document: ArtifactSearchDocument): number {
|
|
112
|
+
const primary = includes(document.rankText.primary, query);
|
|
113
|
+
const body = includes(document.rankText.body, query);
|
|
114
|
+
const metadata = includes(document.rankText.metadata, query);
|
|
115
|
+
if (!primary && !body && !metadata) return 0;
|
|
116
|
+
return KIND_WEIGHT[document.artifact.kind] + (primary ? 50 : 0) + (body ? 20 : 0) + (metadata ? 10 : 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rankDocuments(query: string, documents: ArtifactSearchDocument[]): Artifact[] {
|
|
120
|
+
return documents
|
|
121
|
+
.map((document, index) => ({ document, index, score: scoreDocument(query, document) }))
|
|
122
|
+
.filter((result) => result.score > 0)
|
|
123
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
124
|
+
.map((result) => result.document.artifact);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function searchArtifacts(query: string, artifacts: Artifact[], options: ArtifactSearchOptions = {}): Promise<Artifact[]> {
|
|
128
|
+
const needle = query.trim().toLowerCase();
|
|
129
|
+
if (!needle) return [];
|
|
130
|
+
|
|
131
|
+
const documents = artifacts.map(buildArtifactSearchDocument);
|
|
132
|
+
const runRipgrep = options.runRipgrep ?? runRipgrepAdapter;
|
|
133
|
+
try {
|
|
134
|
+
const ids = await runRipgrep(query, documents);
|
|
135
|
+
const matches = documents.filter((document) => ids.has(document.id));
|
|
136
|
+
return rankDocuments(needle, matches);
|
|
137
|
+
} catch {
|
|
138
|
+
return rankDocuments(needle, documents);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type ArtifactKind = "command" | "error" | "file" | "code" | "prompt" | "response" | "checkpoint";
|
|
2
|
+
export type CheckpointMode = "handoff" | "compact" | "debug" | "review";
|
|
3
|
+
|
|
4
|
+
export type GitSnapshot = {
|
|
5
|
+
branch?: string;
|
|
6
|
+
head?: string;
|
|
7
|
+
dirty?: number;
|
|
8
|
+
staged?: number;
|
|
9
|
+
unstaged?: number;
|
|
10
|
+
untracked?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type Artifact = {
|
|
14
|
+
id: string; // displayId alias, kept for command compatibility
|
|
15
|
+
displayId: string;
|
|
16
|
+
ref: string;
|
|
17
|
+
kind: ArtifactKind;
|
|
18
|
+
title: string;
|
|
19
|
+
subtitle: string;
|
|
20
|
+
body: string;
|
|
21
|
+
entryId?: string;
|
|
22
|
+
timestamp?: number;
|
|
23
|
+
meta?: Record<string, unknown>;
|
|
24
|
+
source?: string; // undefined = current session; otherwise carryover slot id (e.g. "c1")
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ArtifactSummary = Pick<Artifact, "displayId" | "ref" | "kind" | "title" | "subtitle" | "timestamp">;
|
|
28
|
+
|
|
29
|
+
export type CheckpointIndexEntry = {
|
|
30
|
+
id: string;
|
|
31
|
+
mode: CheckpointMode;
|
|
32
|
+
file: string;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
sourceSession?: string;
|
|
36
|
+
note?: string;
|
|
37
|
+
consumeOnUse?: boolean;
|
|
38
|
+
consumedAt?: string;
|
|
39
|
+
git?: GitSnapshot;
|
|
40
|
+
};
|