pi-anchor 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,5 @@
1
+ import { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+
3
+ declare function export_default(pi: ExtensionAPI): void;
4
+
5
+ export { export_default as default };
@@ -0,0 +1,5 @@
1
+ import { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+
3
+ declare function export_default(pi: ExtensionAPI): void;
4
+
5
+ export { export_default as default };
@@ -0,0 +1,312 @@
1
+ import {
2
+ AnchorStore,
3
+ SessionTouchObserver,
4
+ findMatchedAnchors,
5
+ generateSettlementProposals,
6
+ makeSafeTaskAnnotation,
7
+ normalizePath,
8
+ openAnchorDashboard,
9
+ renderColdStartAnchorsContext,
10
+ runPhysicalVerification,
11
+ sweepStore,
12
+ updateAnchorStatusBar,
13
+ updateStartupBanner
14
+ } from "./chunk-NNYUD5O7.js";
15
+
16
+ // src/extension.ts
17
+ import { Type } from "@sinclair/typebox";
18
+ import { execSync } from "child_process";
19
+ var MUTATION_TOOLS = /* @__PURE__ */ new Set(["edit", "write", "patch", "apply_diff", "create_file", "modify"]);
20
+ function extension_default(pi) {
21
+ const store = new AnchorStore();
22
+ const observer = new SessionTouchObserver();
23
+ const annotatedThisSession = /* @__PURE__ */ new Set();
24
+ pi.on("session_start", async (event, ctx) => {
25
+ observer.clear();
26
+ annotatedThisSession.clear();
27
+ const sweep = sweepStore(store);
28
+ if (sweep.transitionedToSleeping.length > 0) {
29
+ ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} \u4E2A\u975E\u6D3B\u8DC3\u4EFB\u52A1\u5DF2\u8FDB\u5165\u4F11\u7720`, "info");
30
+ }
31
+ updateAnchorStatusBar(ctx, store);
32
+ if (event.reason !== "resume") {
33
+ updateStartupBanner(ctx, store);
34
+ }
35
+ });
36
+ pi.on("agent_start", async (_event, ctx) => {
37
+ if (ctx.hasUI && ctx.ui) {
38
+ ctx.ui.setWidget("anchor-startup", void 0);
39
+ }
40
+ });
41
+ pi.on("before_agent_start", async (event, ctx) => {
42
+ const entries = ctx.sessionManager?.getEntries() || [];
43
+ const messageTurns = entries.filter((e) => e.type === "message");
44
+ const isColdStart = messageTurns.length <= 1;
45
+ if (isColdStart) {
46
+ const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd);
47
+ if (contextSnippet) {
48
+ return {
49
+ systemPrompt: `${event.systemPrompt}
50
+
51
+ ${contextSnippet}`
52
+ };
53
+ }
54
+ }
55
+ });
56
+ pi.on("tool_result", async (event, ctx) => {
57
+ if (!event.isError) {
58
+ observer.recordToolCall(event.toolName, event.input || {});
59
+ }
60
+ const pathInput = event.input?.path;
61
+ if (typeof pathInput !== "string") return;
62
+ const touchedPath = normalizePath(pathInput);
63
+ const anchors = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active" || a.status === "sleeping");
64
+ const matches = findMatchedAnchors(anchors, [touchedPath]);
65
+ if (matches.length > 0) {
66
+ const isMutation = MUTATION_TOOLS.has((event.toolName || "").toLowerCase());
67
+ if (isMutation && !event.isError) {
68
+ for (const m of matches) {
69
+ store.touch(m.anchor.id);
70
+ }
71
+ updateAnchorStatusBar(ctx, store);
72
+ }
73
+ const a = matches[0].anchor;
74
+ if (!annotatedThisSession.has(a.id)) {
75
+ annotatedThisSession.add(a.id);
76
+ const alert = makeSafeTaskAnnotation(touchedPath, a);
77
+ if (alert) {
78
+ const contents = [...event.content || []];
79
+ for (let i = contents.length - 1; i >= 0; i--) {
80
+ const item = contents[i];
81
+ if (item && item.type === "text") {
82
+ contents[i] = { ...item, text: item.text + alert };
83
+ return { content: contents };
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ });
90
+ pi.on("session_shutdown", async (_event, ctx) => {
91
+ try {
92
+ const gitStatus = execSync("git status --porcelain", {
93
+ cwd: ctx.cwd,
94
+ encoding: "utf-8",
95
+ timeout: 3e3,
96
+ stdio: ["ignore", "pipe", "ignore"]
97
+ });
98
+ const changed = gitStatus.split("\n").map((l) => l.slice(3).trim()).filter(Boolean);
99
+ for (const f of changed) {
100
+ observer.addTouchedFile(f);
101
+ }
102
+ } catch {
103
+ }
104
+ const modified = observer.getModifiedFiles();
105
+ if (modified.length === 0) return;
106
+ const proposals = generateSettlementProposals(store, modified, ctx.cwd);
107
+ if (proposals.length === 0) return;
108
+ for (const prop of proposals) {
109
+ const a = prop.anchor;
110
+ const commitMatch = observer.matchesCommit(a);
111
+ if (commitMatch.matched) {
112
+ store.settle(a.id, {
113
+ settledBy: "verification-test",
114
+ summary: `Auto-settled via Git commit: ${commitMatch.message}`,
115
+ touchedFiles: prop.matchedFiles
116
+ });
117
+ ctx.ui.notify(`\u2316 Auto-settled #${a.id} via Git commit (run /anchor undo to revert)`, "info");
118
+ continue;
119
+ }
120
+ if (a.verifyCommand) {
121
+ const verifyRes = runPhysicalVerification(a, ctx.cwd);
122
+ if (verifyRes.success) {
123
+ store.settle(a.id, {
124
+ settledBy: "verification-test",
125
+ summary: `Automated test passed: ${a.verifyCommand}`,
126
+ touchedFiles: prop.matchedFiles
127
+ });
128
+ ctx.ui.notify(`\u2316 Test passed (${a.verifyCommand}), auto-settled #${a.id}`, "info");
129
+ continue;
130
+ }
131
+ }
132
+ if (!ctx.hasUI || !ctx.ui) continue;
133
+ const ok = await ctx.ui.confirm(
134
+ "\u2316 Settle Anchor Task",
135
+ `Task #${a.id} [${a.title}] touched files (${prop.matchedFiles.slice(0, 2).join(", ")}).
136
+ Mark as completed and archive?`
137
+ );
138
+ if (ok) {
139
+ store.settle(a.id, {
140
+ settledBy: "one-tap-settlement",
141
+ touchedFiles: prop.matchedFiles
142
+ });
143
+ ctx.ui.notify(`\u2316 Settled #${a.id}: "${a.title}"`, "info");
144
+ }
145
+ }
146
+ });
147
+ pi.registerTool({
148
+ name: "anchor",
149
+ label: "Anchor (Cross-session Task Protocol)",
150
+ description: "Manage cross-session persistent tasks that survive terminal restarts and auto-evict upon code changes or settlement. Use when the user asks to retain, pin, remember, or track a multi-session goal across sessions, or when an ongoing commitment must not be forgotten. Actions: pin (create new cross-session anchor), list (view active and sleeping anchors), settle (close and archive a completed anchor), touch (refresh activity), sweep (run decay cleanup). Stored in ~/.anchor/ with zero project repository pollution.",
151
+ promptSnippet: "Anchor cross-session task contracts that survive terminal restarts and auto-evict",
152
+ promptGuidelines: [
153
+ 'Use `anchor` when the user asks to retain a goal across sessions or record a reminder for later/tonight/tomorrow (e.g. "\u665A\u4E0A\u6E05\u7406\u5783\u573E", "\u660E\u5929\u4F18\u5316X", "\u4FDD\u7559\u4EFB\u52A1\u76F4\u5230\u5B8C\u6210").',
154
+ "BOUNDARY WITH TODO: `todo` is strictly for intra-session active work breakdown (step 1, step 2, step 3 right now). For future reminders or cross-session goals, ONLY use `anchor`. NEVER duplicate a cross-session reminder into both `todo` and `anchor`.",
155
+ "DO NOT over-engineer or assume automated scheduled tasks unless the user explicitly requests Windows Task Scheduler or cron.",
156
+ "Tasks are automatically scoped to the current project context without cluttering the project git repository.",
157
+ 'When code for an anchor is completed and verified, call `anchor` with action "settle" to archive it and free context.',
158
+ "Active anchors are automatically injected into future sessions in an ultra-compact block."
159
+ ],
160
+ parameters: Type.Object({
161
+ action: Type.Union([
162
+ Type.Literal("pin"),
163
+ Type.Literal("list"),
164
+ Type.Literal("settle"),
165
+ Type.Literal("touch"),
166
+ Type.Literal("sweep")
167
+ ]),
168
+ title: Type.Optional(Type.String({ description: "Short imperative task title (for pin)" })),
169
+ description: Type.Optional(Type.String({ description: "Detailed context, acceptance criteria, or technical notes" })),
170
+ priority: Type.Optional(Type.Union([Type.Literal("p0"), Type.Literal("p1"), Type.Literal("p2")])),
171
+ project: Type.Optional(Type.String({ description: "Target project name or workspace (defaults to current directory if omitted)" })),
172
+ targetDate: Type.Optional(Type.String({ description: "Expected completion date (e.g. YYYY-MM-DD, today, tomorrow)" })),
173
+ files: Type.Optional(Type.Array(Type.String(), { description: "Associated file paths or directory prefixes" })),
174
+ tags: Type.Optional(Type.Array(Type.String(), { description: "Domain tags" })),
175
+ verifyCommand: Type.Optional(Type.String({ description: "Optional shell command for automated physical verification" })),
176
+ id: Type.Optional(Type.String({ description: "Anchor ID, e.g. anc-1 (for settle or touch)" }))
177
+ }),
178
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
179
+ if (!ctx) return { content: [{ type: "text", text: "Error: context required" }], isError: true };
180
+ if (params.action === "pin") {
181
+ if (!params.title) {
182
+ return { content: [{ type: "text", text: "Error: title is required for pin action" }], isError: true };
183
+ }
184
+ const anc = store.create({
185
+ title: params.title,
186
+ description: params.description,
187
+ priority: params.priority || "p1",
188
+ project: params.project,
189
+ targetDate: params.targetDate,
190
+ cwd: ctx.cwd,
191
+ files: params.files || [],
192
+ tags: params.tags || [],
193
+ verifyCommand: params.verifyCommand
194
+ });
195
+ updateAnchorStatusBar(ctx, store);
196
+ return {
197
+ content: [{
198
+ type: "text",
199
+ text: `Successfully anchored task #${anc.id}: "${anc.title}" [${anc.project}]. Stored in ~/.anchor/.`
200
+ }],
201
+ isError: false
202
+ };
203
+ }
204
+ if (params.action === "list") {
205
+ const active = store.list({ status: "active", cwd: ctx.cwd });
206
+ const sleeping = store.list({ status: "sleeping", cwd: ctx.cwd });
207
+ return {
208
+ content: [{
209
+ type: "text",
210
+ text: JSON.stringify({ active, sleeping }, null, 2)
211
+ }],
212
+ isError: false
213
+ };
214
+ }
215
+ if (params.action === "settle") {
216
+ if (!params.id) {
217
+ return { content: [{ type: "text", text: "Error: id is required for settle action" }], isError: true };
218
+ }
219
+ try {
220
+ const settled = store.settle(params.id, { settledBy: "verification-test" });
221
+ updateAnchorStatusBar(ctx, store);
222
+ return {
223
+ content: [{
224
+ type: "text",
225
+ text: `Anchor #${settled.id} successfully settled and evicted from active context.`
226
+ }],
227
+ isError: false
228
+ };
229
+ } catch (err) {
230
+ return { content: [{ type: "text", text: err.message }], isError: true };
231
+ }
232
+ }
233
+ if (params.action === "touch") {
234
+ if (!params.id) {
235
+ return { content: [{ type: "text", text: "Error: id is required for touch action" }], isError: true };
236
+ }
237
+ try {
238
+ const touched = store.touch(params.id);
239
+ updateAnchorStatusBar(ctx, store);
240
+ return {
241
+ content: [{
242
+ type: "text",
243
+ text: `Anchor #${touched.id} touched and decay timer refreshed.`
244
+ }],
245
+ isError: false
246
+ };
247
+ } catch (err) {
248
+ return { content: [{ type: "text", text: err.message }], isError: true };
249
+ }
250
+ }
251
+ if (params.action === "sweep") {
252
+ const res = sweepStore(store);
253
+ updateAnchorStatusBar(ctx, store);
254
+ return {
255
+ content: [{
256
+ type: "text",
257
+ text: `Sweep complete: ${res.transitionedToSleeping.length} sleeping, ${res.evictedToGraveyard.length} evicted to graveyard.`
258
+ }],
259
+ isError: false
260
+ };
261
+ }
262
+ return { content: [{ type: "text", text: "Unknown action" }], isError: true };
263
+ }
264
+ });
265
+ pi.registerCommand("anchor", {
266
+ description: "Inspect and settle active anchors",
267
+ handler: async (args, ctx) => {
268
+ const input = (args || "").trim();
269
+ if (!input || input === "list" || input === "ls") {
270
+ await openAnchorDashboard(ctx, store);
271
+ return;
272
+ }
273
+ if (input === "undo") {
274
+ try {
275
+ const restored = store.undoSettle();
276
+ ctx.ui.notify(`\u2316 Restored #${restored.id}: "${restored.title}"`, "info");
277
+ updateAnchorStatusBar(ctx, store);
278
+ } catch (err) {
279
+ ctx.ui.notify(`Revert failed: ${err.message}`, "error");
280
+ }
281
+ return;
282
+ }
283
+ const targetId = input.startsWith("#") ? input.slice(1) : input;
284
+ const item = store.get(targetId);
285
+ if (item) {
286
+ store.settle(targetId, { settledBy: "manual-command" });
287
+ ctx.ui.notify(`\u2316 Settled: "${item.title}"`, "info");
288
+ updateAnchorStatusBar(ctx, store);
289
+ return;
290
+ }
291
+ const anc = store.create({ title: input, cwd: ctx.cwd });
292
+ ctx.ui.notify(`\u2316 Pinned #${anc.id}: "${anc.title}"`, "info");
293
+ updateAnchorStatusBar(ctx, store);
294
+ }
295
+ });
296
+ pi.registerCommand("pin", {
297
+ description: "Quickly pin a task across sessions",
298
+ handler: async (args, ctx) => {
299
+ const title = (args || "").trim();
300
+ if (!title) {
301
+ await openAnchorDashboard(ctx, store);
302
+ return;
303
+ }
304
+ const anc = store.create({ title, cwd: ctx.cwd });
305
+ ctx.ui.notify(`\u2316 Pinned #${anc.id}: "${anc.title}"`, "info");
306
+ updateAnchorStatusBar(ctx, store);
307
+ }
308
+ });
309
+ }
310
+ export {
311
+ extension_default as default
312
+ };