pi-anchor 0.3.0 → 0.5.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/README.md +38 -174
- package/README_zh.md +36 -184
- package/assets/hero-en.svg +48 -47
- package/assets/hero-zh.svg +48 -47
- package/bin/anchor.js +68 -70
- package/dist/chunk-VXEQDTK4.js +1310 -0
- package/dist/extension-Cq1NH1jT.d.cts +205 -0
- package/dist/extension-Cq1NH1jT.d.ts +205 -0
- package/dist/extension.cjs +617 -1354
- package/dist/extension.d.cts +2 -20
- package/dist/extension.d.ts +2 -20
- package/dist/extension.js +6 -536
- package/dist/index.cjs +452 -1152
- package/dist/index.d.cts +22 -250
- package/dist/index.d.ts +22 -250
- package/dist/index.js +21 -103
- package/package.json +2 -2
- package/src/extension.ts +265 -376
- package/src/index.ts +1 -6
- package/src/pi-types.d.ts +5 -1
- package/src/store.ts +385 -427
- package/src/tui.ts +166 -307
- package/src/types.ts +35 -63
- package/dist/chunk-Q7IILJFA.js +0 -1563
- package/dist/store-t2_TDhJn.d.cts +0 -210
- package/dist/store-t2_TDhJn.d.ts +0 -210
- package/src/context_injector.ts +0 -166
- package/src/decay.ts +0 -125
- package/src/matcher.ts +0 -202
- package/src/observer.ts +0 -141
- package/src/protocol.ts +0 -95
- package/src/settlement.ts +0 -140
|
@@ -0,0 +1,1310 @@
|
|
|
1
|
+
// src/extension.ts
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import fs3 from "fs";
|
|
4
|
+
|
|
5
|
+
// src/store.ts
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import os from "os";
|
|
9
|
+
|
|
10
|
+
// src/types.ts
|
|
11
|
+
var QUIET_AFTER_DAYS = 7;
|
|
12
|
+
var MS_PER_DAY = 864e5;
|
|
13
|
+
|
|
14
|
+
// src/store.ts
|
|
15
|
+
function normalizePath(filePath, baseCwd) {
|
|
16
|
+
if (typeof filePath !== "string") return "";
|
|
17
|
+
let norm = filePath.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
18
|
+
if (baseCwd && (path.isAbsolute(filePath) || norm.startsWith("/") || /^[a-zA-Z]:\//.test(norm))) {
|
|
19
|
+
const normBase = baseCwd.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
20
|
+
const lowerNorm = norm.toLowerCase();
|
|
21
|
+
const lowerBase = normBase.toLowerCase();
|
|
22
|
+
if (lowerNorm === lowerBase) {
|
|
23
|
+
norm = "";
|
|
24
|
+
} else if (lowerNorm.startsWith(lowerBase + "/")) {
|
|
25
|
+
norm = norm.slice(normBase.length + 1).replace(/^\/+/, "");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return norm;
|
|
29
|
+
}
|
|
30
|
+
function isSameOrSubPath(dirA, dirB) {
|
|
31
|
+
if (!dirA || !dirB) return false;
|
|
32
|
+
const normA = dirA.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
33
|
+
const normB = dirB.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
34
|
+
const isWin = process.platform === "win32" || /^[a-zA-Z]:\//.test(normA) || /^[a-zA-Z]:\//.test(normB);
|
|
35
|
+
const a = isWin ? normA.toLowerCase() : normA;
|
|
36
|
+
const b = isWin ? normB.toLowerCase() : normB;
|
|
37
|
+
return a === b || b.startsWith(a + "/") || a.startsWith(b + "/");
|
|
38
|
+
}
|
|
39
|
+
function globToRegExp(pattern) {
|
|
40
|
+
const collapsed = pattern.slice(0, 512).replace(/\/+(\*\*\/+)+/g, "/**/").replace(/\*{3,}/g, "**");
|
|
41
|
+
const escaped = collapsed.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\/\*\*\//g, "___GLOB_DIR_SLASH___").replace(/\*\*/g, "___GLOB_STAR_STAR___").replace(/\*/g, "[^/]*").replace(/___GLOB_DIR_SLASH___/g, "(?:/|/(?:[^/]+/)+)").replace(/___GLOB_STAR_STAR___/g, ".*");
|
|
42
|
+
return new RegExp(`^${escaped}$`);
|
|
43
|
+
}
|
|
44
|
+
function extractPathsFromText(text) {
|
|
45
|
+
if (!text) return [];
|
|
46
|
+
const results = /* @__PURE__ */ new Set();
|
|
47
|
+
const regex = /(?:[a-zA-Z]:[\\/][^\s"'`,。;!?、,;)>\]]+|(?:\b(?:src|lib|dist|tests|test|docs|scripts|packages|extensions|bin|assets)[\\/][^\s"'`,。;!?、,;)>\]]+)|(?:\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|c|cpp|h|cs|md|json|yaml|yml|toml|html|css|sql|sh)\b))/g;
|
|
48
|
+
let match;
|
|
49
|
+
while ((match = regex.exec(text)) !== null) {
|
|
50
|
+
const cleaned = normalizePath(match[0].replace(/[.:;,'")\]}>]+$/, ""));
|
|
51
|
+
if (cleaned && cleaned.length >= 3) {
|
|
52
|
+
results.add(cleaned);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return Array.from(results);
|
|
56
|
+
}
|
|
57
|
+
function extractProgressFromText(rawTitle) {
|
|
58
|
+
if (!rawTitle) return { cleanTitle: "" };
|
|
59
|
+
const stepParts = Array.from(rawTitle.matchAll(/(?:^|\s)(\d+)\.\s*([^\d]+?)(?=\s+\d+\.\s|$)/g));
|
|
60
|
+
if (stepParts.length >= 2) {
|
|
61
|
+
const steps = stepParts.map((m) => m[2].trim().replace(/[;;,,]+$/, "")).filter(Boolean);
|
|
62
|
+
const firstMatchIdx = rawTitle.indexOf(stepParts[0][0]);
|
|
63
|
+
const prefix = rawTitle.slice(0, firstMatchIdx).replace(/[::\-—\s]+$/, "").trim();
|
|
64
|
+
return {
|
|
65
|
+
cleanTitle: prefix || steps.join(" \u2192 "),
|
|
66
|
+
progress: { done: 0, total: steps.length, steps }
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const ratioMatch = rawTitle.match(/(?:\[(\d{1,3})\/(\d{1,3})\]|\((\d{1,3})\/(\d{1,3})\)|(?:^|\s)(\d{1,3})\/(\d{1,3})(?=\s|$))/);
|
|
70
|
+
if (ratioMatch) {
|
|
71
|
+
const done = parseInt(ratioMatch[1] ?? ratioMatch[3] ?? ratioMatch[5], 10);
|
|
72
|
+
const total = parseInt(ratioMatch[2] ?? ratioMatch[4] ?? ratioMatch[6], 10);
|
|
73
|
+
if (!isNaN(done) && !isNaN(total) && total >= 2 && total <= 500 && done >= 0 && done <= total) {
|
|
74
|
+
const stripped = rawTitle.replace(ratioMatch[0], " ").replace(/\s+/g, " ").trim();
|
|
75
|
+
return {
|
|
76
|
+
cleanTitle: stripped || rawTitle.trim(),
|
|
77
|
+
progress: { done, total }
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { cleanTitle: rawTitle.trim() };
|
|
82
|
+
}
|
|
83
|
+
function isAnchorTargetingWorkspace(anchor, targetCwd) {
|
|
84
|
+
if (!targetCwd) return true;
|
|
85
|
+
const normTarget = targetCwd.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
86
|
+
const targetBase = path.basename(normTarget).toLowerCase();
|
|
87
|
+
if (anchor.project && anchor.project !== "global") {
|
|
88
|
+
const normProj = anchor.project.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
89
|
+
if (isSameOrSubPath(normProj, normTarget)) return true;
|
|
90
|
+
if (path.basename(normProj).toLowerCase() === targetBase) return true;
|
|
91
|
+
}
|
|
92
|
+
if (anchor.files && anchor.files.length > 0) {
|
|
93
|
+
for (const f of anchor.files) {
|
|
94
|
+
if (isSameOrSubPath(f, normTarget)) return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (anchor.cwd && isSameOrSubPath(anchor.cwd, normTarget)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
function matchAnchorAgainstTouchedFiles(anchor, touchedFiles) {
|
|
103
|
+
if (!anchor.files || anchor.files.length === 0) return null;
|
|
104
|
+
const normTouched = touchedFiles.map((f) => normalizePath(f, anchor.cwd)).filter(Boolean);
|
|
105
|
+
if (normTouched.length === 0) return null;
|
|
106
|
+
const matchedExact = [];
|
|
107
|
+
const matchedPrefix = [];
|
|
108
|
+
for (const touched of normTouched) {
|
|
109
|
+
for (const pattern of anchor.files) {
|
|
110
|
+
const normPattern = normalizePath(pattern, anchor.cwd);
|
|
111
|
+
if (normPattern === touched) {
|
|
112
|
+
matchedExact.push(touched);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
if (normPattern.includes("*") && globToRegExp(normPattern).test(touched)) {
|
|
116
|
+
matchedExact.push(touched);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
const dirPrefix = normPattern.endsWith("/") ? normPattern : normPattern + "/";
|
|
120
|
+
if (touched.startsWith(dirPrefix)) {
|
|
121
|
+
matchedPrefix.push(touched);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (matchedExact.length > 0) {
|
|
127
|
+
return { anchor, score: 1, matchedFiles: Array.from(new Set(matchedExact)), reason: "exact-file" };
|
|
128
|
+
}
|
|
129
|
+
if (matchedPrefix.length > 0) {
|
|
130
|
+
return { anchor, score: 0.85, matchedFiles: Array.from(new Set(matchedPrefix)), reason: "dir-prefix" };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
function findMatchedAnchors(anchors, touchedFiles) {
|
|
135
|
+
const results = [];
|
|
136
|
+
for (const anchor of anchors) {
|
|
137
|
+
const m = matchAnchorAgainstTouchedFiles(anchor, touchedFiles);
|
|
138
|
+
if (m) results.push(m);
|
|
139
|
+
}
|
|
140
|
+
return results.sort((a, b) => b.score - a.score);
|
|
141
|
+
}
|
|
142
|
+
var COMMENT_FORMATS = {
|
|
143
|
+
".ts": (m) => `// ${m}`,
|
|
144
|
+
".tsx": (m) => `// ${m}`,
|
|
145
|
+
".js": (m) => `// ${m}`,
|
|
146
|
+
".jsx": (m) => `// ${m}`,
|
|
147
|
+
".mjs": (m) => `// ${m}`,
|
|
148
|
+
".cjs": (m) => `// ${m}`,
|
|
149
|
+
".go": (m) => `// ${m}`,
|
|
150
|
+
".rs": (m) => `// ${m}`,
|
|
151
|
+
".c": (m) => `// ${m}`,
|
|
152
|
+
".cpp": (m) => `// ${m}`,
|
|
153
|
+
".h": (m) => `// ${m}`,
|
|
154
|
+
".cs": (m) => `// ${m}`,
|
|
155
|
+
".java": (m) => `// ${m}`,
|
|
156
|
+
".kt": (m) => `// ${m}`,
|
|
157
|
+
".swift": (m) => `// ${m}`,
|
|
158
|
+
".dart": (m) => `// ${m}`,
|
|
159
|
+
".py": (m) => `# ${m}`,
|
|
160
|
+
".rb": (m) => `# ${m}`,
|
|
161
|
+
".sh": (m) => `# ${m}`,
|
|
162
|
+
".bash": (m) => `# ${m}`,
|
|
163
|
+
".yaml": (m) => `# ${m}`,
|
|
164
|
+
".yml": (m) => `# ${m}`,
|
|
165
|
+
".toml": (m) => `# ${m}`,
|
|
166
|
+
".ps1": (m) => `# ${m}`,
|
|
167
|
+
".html": (m) => `<!-- ${m} -->`,
|
|
168
|
+
".xml": (m) => `<!-- ${m} -->`,
|
|
169
|
+
".svg": (m) => `<!-- ${m} -->`,
|
|
170
|
+
".vue": (m) => `<!-- ${m} -->`,
|
|
171
|
+
".svelte": (m) => `<!-- ${m} -->`,
|
|
172
|
+
".css": (m) => `/* ${m} */`,
|
|
173
|
+
".scss": (m) => `/* ${m} */`,
|
|
174
|
+
".sql": (m) => `-- ${m}`,
|
|
175
|
+
".lua": (m) => `-- ${m}`
|
|
176
|
+
};
|
|
177
|
+
function makeSafeTaskAnnotation(filePath, anchor) {
|
|
178
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
179
|
+
const fmt = COMMENT_FORMATS[ext];
|
|
180
|
+
if (!fmt) return null;
|
|
181
|
+
const num = anchor.id.replace(/^anc-/, "");
|
|
182
|
+
return "\n\n" + fmt(`\u2316 Note #${num}: ${anchor.title}`);
|
|
183
|
+
}
|
|
184
|
+
function matchesCommitMessage(anchor, commitMessages) {
|
|
185
|
+
const idLower = anchor.id.toLowerCase();
|
|
186
|
+
const numId = `#${anchor.id.replace(/^anc-/, "")}`;
|
|
187
|
+
const title = anchor.title.toLowerCase();
|
|
188
|
+
let titleTokens = [];
|
|
189
|
+
if (typeof Intl !== "undefined" && Intl.Segmenter) {
|
|
190
|
+
try {
|
|
191
|
+
const seg = new Intl.Segmenter("und", { granularity: "word" });
|
|
192
|
+
titleTokens = Array.from(seg.segment(title)).filter((s) => s.isWordLike).map((s) => s.segment.trim().toLowerCase()).filter((t) => t.length >= 2);
|
|
193
|
+
} catch {
|
|
194
|
+
titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2);
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2);
|
|
198
|
+
}
|
|
199
|
+
for (const msg of commitMessages) {
|
|
200
|
+
const msgLower = msg.toLowerCase();
|
|
201
|
+
if (msgLower.includes(idLower) || msgLower.includes(numId)) {
|
|
202
|
+
return { matched: true, message: msg };
|
|
203
|
+
}
|
|
204
|
+
if (titleTokens.length > 0) {
|
|
205
|
+
const hits = titleTokens.filter((tok) => msgLower.includes(tok)).length;
|
|
206
|
+
if (hits >= Math.min(2, titleTokens.length)) {
|
|
207
|
+
return { matched: true, message: msg };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { matched: false };
|
|
212
|
+
}
|
|
213
|
+
function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
|
|
214
|
+
let cwd;
|
|
215
|
+
let now = Date.now();
|
|
216
|
+
if (typeof cwdOrNow === "number") now = cwdOrNow;
|
|
217
|
+
else if (typeof cwdOrNow === "string") cwd = cwdOrNow;
|
|
218
|
+
if (typeof nowArg === "number") now = nowArg;
|
|
219
|
+
sweepQuietNotes(store, now);
|
|
220
|
+
const activeNotes = store.list({ status: "active", cwd });
|
|
221
|
+
if (activeNotes.length === 0) return "";
|
|
222
|
+
const lines = activeNotes.map((a) => {
|
|
223
|
+
const num = a.id.replace(/^anc-/, "");
|
|
224
|
+
const files = a.files.length > 0 ? ` (${a.files.slice(0, 2).join(", ")})` : "";
|
|
225
|
+
const prog = a.progress ? ` [${a.progress.done}/${a.progress.total}${a.progress.steps && a.progress.steps[a.progress.done] ? ` \xB7 next: ${a.progress.steps[a.progress.done]}` : ""}]` : "";
|
|
226
|
+
const desc = a.description ? ` \u2014 ${a.description}` : "";
|
|
227
|
+
return `\u2022 #${num} [${a.project}] ${a.title}${prog}${files}${desc}`;
|
|
228
|
+
});
|
|
229
|
+
return [
|
|
230
|
+
`<sticky-notes count="${activeNotes.length}">`,
|
|
231
|
+
`Pinned notes from previous sessions:`,
|
|
232
|
+
lines.join("\n"),
|
|
233
|
+
`When a note is completed, call anchor(action="settle", id="...") to tear it off.`,
|
|
234
|
+
`</sticky-notes>`
|
|
235
|
+
].join("\n");
|
|
236
|
+
}
|
|
237
|
+
var renderActiveAnchorsContext = renderColdStartAnchorsContext;
|
|
238
|
+
function sweepQuietNotes(store, now = Date.now()) {
|
|
239
|
+
return store.withLock(() => {
|
|
240
|
+
const state = store.loadState();
|
|
241
|
+
const quieted = [];
|
|
242
|
+
for (const a of state.anchors) {
|
|
243
|
+
if (a.status === "active" && now - a.lastTouchedAt > QUIET_AFTER_DAYS * MS_PER_DAY) {
|
|
244
|
+
a.status = "sleeping";
|
|
245
|
+
a.updatedAt = now;
|
|
246
|
+
quieted.push(a.id);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (quieted.length > 0) {
|
|
250
|
+
state.lastSweepAt = now;
|
|
251
|
+
store.saveState(state);
|
|
252
|
+
}
|
|
253
|
+
return { quieted };
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function getDefaultStorageDir() {
|
|
257
|
+
if (process.env.ANCHOR_DIR) return path.resolve(process.env.ANCHOR_DIR);
|
|
258
|
+
return path.join(os.homedir(), ".anchor");
|
|
259
|
+
}
|
|
260
|
+
function sleepSync(ms) {
|
|
261
|
+
const end = Date.now() + ms;
|
|
262
|
+
while (Date.now() < end) {
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function isProcessAlive(pid) {
|
|
266
|
+
try {
|
|
267
|
+
process.kill(pid, 0);
|
|
268
|
+
return true;
|
|
269
|
+
} catch (err) {
|
|
270
|
+
return err.code === "EPERM";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function acquireSyncLock(lockPath, maxWaitMs = 1500, staleTimeoutMs = 5e3) {
|
|
274
|
+
const start = Date.now();
|
|
275
|
+
let attempt = 0;
|
|
276
|
+
while (Date.now() - start < maxWaitMs) {
|
|
277
|
+
attempt++;
|
|
278
|
+
try {
|
|
279
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
280
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: Date.now() }), "utf-8");
|
|
281
|
+
fs.closeSync(fd);
|
|
282
|
+
return () => {
|
|
283
|
+
try {
|
|
284
|
+
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
|
|
285
|
+
} catch {
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err.code === "EEXIST") {
|
|
290
|
+
try {
|
|
291
|
+
const meta = JSON.parse(fs.readFileSync(lockPath, "utf-8"));
|
|
292
|
+
const isStale = Date.now() - meta.createdAt > staleTimeoutMs;
|
|
293
|
+
const isDead = meta.pid && !isProcessAlive(meta.pid);
|
|
294
|
+
if (isStale || isDead) {
|
|
295
|
+
try {
|
|
296
|
+
fs.unlinkSync(lockPath);
|
|
297
|
+
continue;
|
|
298
|
+
} catch {
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
}
|
|
303
|
+
sleepSync(Math.min(40, Math.floor(attempt * 4 + Math.random() * 8)));
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return () => {
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function atomicRenameWithRetry(tempPath, targetPath, maxRetries = 5) {
|
|
313
|
+
let lastErr;
|
|
314
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
315
|
+
try {
|
|
316
|
+
fs.renameSync(tempPath, targetPath);
|
|
317
|
+
return;
|
|
318
|
+
} catch (err) {
|
|
319
|
+
lastErr = err;
|
|
320
|
+
if (err.code === "EPERM" || err.code === "EACCES" || err.code === "EBUSY") {
|
|
321
|
+
sleepSync(15 * (i + 1));
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
fs.copyFileSync(tempPath, targetPath);
|
|
329
|
+
fs.unlinkSync(tempPath);
|
|
330
|
+
} catch {
|
|
331
|
+
throw lastErr;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
var AnchorStore = class {
|
|
335
|
+
storageDir;
|
|
336
|
+
statePath;
|
|
337
|
+
lockPath;
|
|
338
|
+
archivePath;
|
|
339
|
+
constructor(storageDir) {
|
|
340
|
+
this.storageDir = storageDir ? path.resolve(storageDir) : getDefaultStorageDir();
|
|
341
|
+
this.statePath = path.join(this.storageDir, "state.json");
|
|
342
|
+
this.lockPath = path.join(this.storageDir, "state.lock");
|
|
343
|
+
this.archivePath = path.join(this.storageDir, "archive.jsonl");
|
|
344
|
+
}
|
|
345
|
+
ensureDirs() {
|
|
346
|
+
if (!fs.existsSync(this.storageDir)) {
|
|
347
|
+
fs.mkdirSync(this.storageDir, { recursive: true });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
withLock(fn) {
|
|
351
|
+
this.ensureDirs();
|
|
352
|
+
const release = acquireSyncLock(this.lockPath);
|
|
353
|
+
try {
|
|
354
|
+
return fn();
|
|
355
|
+
} finally {
|
|
356
|
+
release();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
loadState() {
|
|
360
|
+
this.ensureDirs();
|
|
361
|
+
if (!fs.existsSync(this.statePath)) {
|
|
362
|
+
return { version: 1, anchors: [], lastSweepAt: Date.now(), nextId: 1 };
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const parsed = JSON.parse(fs.readFileSync(this.statePath, "utf-8"));
|
|
366
|
+
if (!parsed || !Array.isArray(parsed.anchors)) {
|
|
367
|
+
return { version: 1, anchors: [], lastSweepAt: Date.now(), nextId: 1 };
|
|
368
|
+
}
|
|
369
|
+
return parsed;
|
|
370
|
+
} catch {
|
|
371
|
+
try {
|
|
372
|
+
fs.copyFileSync(this.statePath, path.join(this.storageDir, `state.corrupt.${Date.now()}.json`));
|
|
373
|
+
} catch {
|
|
374
|
+
}
|
|
375
|
+
return { version: 1, anchors: [], lastSweepAt: Date.now(), nextId: 1 };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
saveState(state) {
|
|
379
|
+
this.ensureDirs();
|
|
380
|
+
const tempPath = `${this.statePath}.${process.pid}.${Date.now()}.tmp`;
|
|
381
|
+
fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), "utf-8");
|
|
382
|
+
atomicRenameWithRetry(tempPath, this.statePath);
|
|
383
|
+
}
|
|
384
|
+
generateId(state) {
|
|
385
|
+
const numbers = state.anchors.map((a) => {
|
|
386
|
+
const m = a.id.match(/^anc-(\d+)$/);
|
|
387
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
388
|
+
}).filter((n) => n > 0);
|
|
389
|
+
const maxExisting = numbers.length > 0 ? Math.max(...numbers) : 0;
|
|
390
|
+
const nextNum = Math.max(maxExisting + 1, state.nextId || 1);
|
|
391
|
+
state.nextId = nextNum + 1;
|
|
392
|
+
return `anc-${nextNum}`;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Pin a new sticky note
|
|
396
|
+
*/
|
|
397
|
+
create(input) {
|
|
398
|
+
return this.withLock(() => {
|
|
399
|
+
const state = this.loadState();
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
const id = this.generateId(state);
|
|
402
|
+
const { cleanTitle, progress: parsedProgress } = extractProgressFromText(input.title);
|
|
403
|
+
const explicitFiles = (input.files || []).map((f) => normalizePath(f)).filter(Boolean);
|
|
404
|
+
const inferredFiles = explicitFiles.length > 0 ? explicitFiles : extractPathsFromText(`${cleanTitle} ${input.description || ""}`);
|
|
405
|
+
let cwd = input.cwd ? normalizePath(input.cwd) : "";
|
|
406
|
+
if (input.project && (/^[a-zA-Z]:[\\/]/.test(input.project.trim()) || input.project.trim().startsWith("/"))) {
|
|
407
|
+
cwd = normalizePath(input.project.trim());
|
|
408
|
+
} else if (inferredFiles.length > 0 && (/^[a-zA-Z]:\//.test(inferredFiles[0]) || inferredFiles[0].startsWith("/"))) {
|
|
409
|
+
const firstDir = path.dirname(inferredFiles[0]);
|
|
410
|
+
if (firstDir && firstDir !== ".") cwd = normalizePath(firstDir);
|
|
411
|
+
}
|
|
412
|
+
const projectName = input.project ? input.project.trim() : cwd ? path.basename(cwd) : "global";
|
|
413
|
+
const anchor = {
|
|
414
|
+
id,
|
|
415
|
+
title: cleanTitle,
|
|
416
|
+
description: input.description?.trim(),
|
|
417
|
+
priority: input.priority || "p1",
|
|
418
|
+
status: "active",
|
|
419
|
+
project: projectName,
|
|
420
|
+
cwd,
|
|
421
|
+
createdAt: now,
|
|
422
|
+
updatedAt: now,
|
|
423
|
+
lastTouchedAt: now,
|
|
424
|
+
files: inferredFiles,
|
|
425
|
+
tags: (input.tags || []).map((t) => t.trim()).filter(Boolean),
|
|
426
|
+
...input.sessionFile ? { sessionFile: normalizePath(input.sessionFile) } : {},
|
|
427
|
+
...input.progress || parsedProgress ? { progress: input.progress || parsedProgress } : {}
|
|
428
|
+
};
|
|
429
|
+
state.anchors.push(anchor);
|
|
430
|
+
this.saveState(state);
|
|
431
|
+
return anchor;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
findAnchorIndex(anchors, id) {
|
|
435
|
+
const clean = id.trim().replace(/^#/, "");
|
|
436
|
+
return anchors.findIndex(
|
|
437
|
+
(a) => a.id === clean || a.id === `anc-${clean}` || (/^\d+$/.test(clean) ? a.id === `anc-${parseInt(clean, 10)}` : false)
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
get(id) {
|
|
441
|
+
const state = this.loadState();
|
|
442
|
+
const idx = this.findAnchorIndex(state.anchors, id);
|
|
443
|
+
return idx === -1 ? void 0 : state.anchors[idx];
|
|
444
|
+
}
|
|
445
|
+
list(filter) {
|
|
446
|
+
const state = this.loadState();
|
|
447
|
+
const targetCwd = filter?.cwd ? normalizePath(filter.cwd) : null;
|
|
448
|
+
return state.anchors.filter((a) => {
|
|
449
|
+
if (filter?.status && a.status !== filter.status) return false;
|
|
450
|
+
if (!filter?.all && targetCwd) {
|
|
451
|
+
if (a.files.length > 0 && (a.cwd || a.project) && !isAnchorTargetingWorkspace(a, targetCwd)) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return true;
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
update(id, patch) {
|
|
459
|
+
return this.withLock(() => {
|
|
460
|
+
const state = this.loadState();
|
|
461
|
+
const idx = this.findAnchorIndex(state.anchors, id);
|
|
462
|
+
if (idx === -1) throw new Error(`Note not found: ${id}`);
|
|
463
|
+
const updated = {
|
|
464
|
+
...state.anchors[idx],
|
|
465
|
+
...patch,
|
|
466
|
+
updatedAt: Date.now()
|
|
467
|
+
};
|
|
468
|
+
if (patch.files) {
|
|
469
|
+
updated.files = patch.files.map((f) => normalizePath(f)).filter(Boolean);
|
|
470
|
+
}
|
|
471
|
+
state.anchors[idx] = updated;
|
|
472
|
+
this.saveState(state);
|
|
473
|
+
return updated;
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
touch(id, timestamp = Date.now(), sessionFile) {
|
|
477
|
+
return this.withLock(() => {
|
|
478
|
+
const state = this.loadState();
|
|
479
|
+
const idx = this.findAnchorIndex(state.anchors, id);
|
|
480
|
+
if (idx === -1) throw new Error(`Note not found: ${id}`);
|
|
481
|
+
const current = state.anchors[idx];
|
|
482
|
+
current.lastTouchedAt = timestamp;
|
|
483
|
+
current.updatedAt = timestamp;
|
|
484
|
+
if (current.status === "sleeping") {
|
|
485
|
+
current.status = "active";
|
|
486
|
+
}
|
|
487
|
+
if (sessionFile) {
|
|
488
|
+
current.sessionFile = normalizePath(sessionFile);
|
|
489
|
+
}
|
|
490
|
+
state.anchors[idx] = current;
|
|
491
|
+
this.saveState(state);
|
|
492
|
+
return current;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Advance (or set) progress on a sticky note:
|
|
497
|
+
* - If delta is provided (default +1), increments `done` up to `total`.
|
|
498
|
+
* - If note had no progress yet, initializes a 4-step bar (`1/4`).
|
|
499
|
+
*/
|
|
500
|
+
step(id, delta = 1, explicitTotal) {
|
|
501
|
+
return this.withLock(() => {
|
|
502
|
+
const state = this.loadState();
|
|
503
|
+
const idx = this.findAnchorIndex(state.anchors, id);
|
|
504
|
+
if (idx === -1) throw new Error(`Note not found: ${id}`);
|
|
505
|
+
const current = state.anchors[idx];
|
|
506
|
+
const now = Date.now();
|
|
507
|
+
const total = explicitTotal && explicitTotal >= 2 ? explicitTotal : current.progress?.total || 4;
|
|
508
|
+
const prevDone = current.progress?.done || 0;
|
|
509
|
+
const nextDone = Math.max(0, Math.min(total, prevDone + delta));
|
|
510
|
+
current.progress = {
|
|
511
|
+
...current.progress || {},
|
|
512
|
+
done: nextDone,
|
|
513
|
+
total
|
|
514
|
+
};
|
|
515
|
+
current.lastTouchedAt = now;
|
|
516
|
+
current.updatedAt = now;
|
|
517
|
+
if (current.status === "sleeping") {
|
|
518
|
+
current.status = "active";
|
|
519
|
+
}
|
|
520
|
+
state.anchors[idx] = current;
|
|
521
|
+
this.saveState(state);
|
|
522
|
+
return { anchor: current, completed: nextDone >= total };
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Tear off (settle) a completed sticky note and archive it to archive.jsonl
|
|
527
|
+
*/
|
|
528
|
+
settle(id, evidence = {}, now = Date.now()) {
|
|
529
|
+
return this.withLock(() => {
|
|
530
|
+
const state = this.loadState();
|
|
531
|
+
const idx = this.findAnchorIndex(state.anchors, id);
|
|
532
|
+
if (idx === -1) throw new Error(`Note not found: ${id}`);
|
|
533
|
+
const [settled] = state.anchors.splice(idx, 1);
|
|
534
|
+
settled.status = "settled";
|
|
535
|
+
settled.updatedAt = now;
|
|
536
|
+
settled.evidence = {
|
|
537
|
+
...evidence,
|
|
538
|
+
settledAt: now,
|
|
539
|
+
settledBy: evidence.settledBy || "manual"
|
|
540
|
+
};
|
|
541
|
+
this.ensureDirs();
|
|
542
|
+
fs.appendFileSync(this.archivePath, JSON.stringify(settled) + "\n", "utf-8");
|
|
543
|
+
this.saveState(state);
|
|
544
|
+
return settled;
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Put back (undo) the last torn-off sticky note
|
|
549
|
+
*/
|
|
550
|
+
undoSettle() {
|
|
551
|
+
return this.withLock(() => {
|
|
552
|
+
if (!fs.existsSync(this.archivePath)) {
|
|
553
|
+
throw new Error("No archived notes to restore");
|
|
554
|
+
}
|
|
555
|
+
const raw = fs.readFileSync(this.archivePath, "utf-8");
|
|
556
|
+
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
557
|
+
let anchor = null;
|
|
558
|
+
while (lines.length > 0 && !anchor) {
|
|
559
|
+
const candidate = lines.pop();
|
|
560
|
+
try {
|
|
561
|
+
const parsed = JSON.parse(candidate);
|
|
562
|
+
if (parsed && typeof parsed.id === "string" && typeof parsed.title === "string") {
|
|
563
|
+
anchor = parsed;
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (!anchor) throw new Error("Archive is empty, nothing to undo");
|
|
569
|
+
fs.writeFileSync(this.archivePath, lines.length > 0 ? lines.join("\n") + "\n" : "", "utf-8");
|
|
570
|
+
anchor.status = "active";
|
|
571
|
+
anchor.updatedAt = Date.now();
|
|
572
|
+
anchor.lastTouchedAt = Date.now();
|
|
573
|
+
delete anchor.evidence;
|
|
574
|
+
const state = this.loadState();
|
|
575
|
+
const existingIdx = state.anchors.findIndex((a) => a.id === anchor.id);
|
|
576
|
+
if (existingIdx !== -1) {
|
|
577
|
+
if (state.anchors[existingIdx].title === anchor.title) {
|
|
578
|
+
state.anchors[existingIdx] = anchor;
|
|
579
|
+
} else {
|
|
580
|
+
anchor.id = this.generateId(state);
|
|
581
|
+
state.anchors.push(anchor);
|
|
582
|
+
}
|
|
583
|
+
} else {
|
|
584
|
+
const m = anchor.id.match(/^anc-(\d+)$/);
|
|
585
|
+
if (m) state.nextId = Math.max(state.nextId || 1, parseInt(m[1], 10) + 1);
|
|
586
|
+
state.anchors.push(anchor);
|
|
587
|
+
}
|
|
588
|
+
this.saveState(state);
|
|
589
|
+
return anchor;
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
// src/tui.ts
|
|
595
|
+
import fs2 from "fs";
|
|
596
|
+
import path2 from "path";
|
|
597
|
+
var ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
598
|
+
function stripAnsi(str) {
|
|
599
|
+
return str.replace(ANSI_REGEX, "");
|
|
600
|
+
}
|
|
601
|
+
function getDisplayWidth(str) {
|
|
602
|
+
const clean = stripAnsi(str);
|
|
603
|
+
let width = 0;
|
|
604
|
+
for (const char of clean) {
|
|
605
|
+
const code = char.codePointAt(0) || 0;
|
|
606
|
+
if (code === 8205 || code === 65039 || code === 65038 || code >= 768 && code <= 879 || code >= 8203 && code <= 8207) {
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (code >= 19968 && code <= 40959 || code >= 13312 && code <= 19903 || code >= 131072 && code <= 173791 || code >= 173824 && code <= 177983 || code >= 65281 && code <= 65376 || code >= 12288 && code <= 12351 || code >= 44032 && code <= 55215 || code >= 4352 && code <= 4607 || code >= 12592 && code <= 12687 || code >= 12352 && code <= 12447 || code >= 12448 && code <= 12543 || code >= 127744 && code <= 129535 || code >= 128512 && code <= 128591 || code >= 128640 && code <= 128767 || code >= 9728 && code <= 10175 || code >= 129648 && code <= 129791) {
|
|
610
|
+
width += 2;
|
|
611
|
+
} else {
|
|
612
|
+
width += 1;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return width;
|
|
616
|
+
}
|
|
617
|
+
function hasActiveUI(ctx) {
|
|
618
|
+
if (!ctx) return false;
|
|
619
|
+
try {
|
|
620
|
+
return Boolean(ctx.hasUI && ctx.ui);
|
|
621
|
+
} catch {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
function truncateToWidth(str, maxWidth, ellipsis = "\u2026") {
|
|
626
|
+
if (maxWidth <= 0) return "";
|
|
627
|
+
const singleLine = str.replace(/[\r\n\t]+/g, " ");
|
|
628
|
+
const totalWidth = getDisplayWidth(singleLine);
|
|
629
|
+
if (totalWidth <= maxWidth) return singleLine;
|
|
630
|
+
const ellipsisWidth = getDisplayWidth(ellipsis);
|
|
631
|
+
const target = Math.max(0, maxWidth - ellipsisWidth);
|
|
632
|
+
if (target === 0) return ellipsis.slice(0, maxWidth);
|
|
633
|
+
let accumulated = "";
|
|
634
|
+
let accumWidth = 0;
|
|
635
|
+
let i = 0;
|
|
636
|
+
while (i < singleLine.length) {
|
|
637
|
+
ANSI_REGEX.lastIndex = i;
|
|
638
|
+
const match = ANSI_REGEX.exec(singleLine);
|
|
639
|
+
if (match && match.index === i) {
|
|
640
|
+
accumulated += match[0];
|
|
641
|
+
i += match[0].length;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const codePoint = singleLine.codePointAt(i) || 0;
|
|
645
|
+
const charLen = codePoint > 65535 ? 2 : 1;
|
|
646
|
+
const fullChar = singleLine.slice(i, i + charLen);
|
|
647
|
+
const charWidth = getDisplayWidth(fullChar);
|
|
648
|
+
if (accumWidth + charWidth > target) break;
|
|
649
|
+
accumulated += fullChar;
|
|
650
|
+
accumWidth += charWidth;
|
|
651
|
+
i += charLen;
|
|
652
|
+
}
|
|
653
|
+
accumulated = accumulated.replace(/\s*[\(\[\{(【][^)\]})】]*$/, "");
|
|
654
|
+
return accumulated + ellipsis + (singleLine.includes("\x1B") ? "\x1B[0m" : "");
|
|
655
|
+
}
|
|
656
|
+
function padToWidth(str, targetWidth) {
|
|
657
|
+
const truncated = truncateToWidth(str, targetWidth);
|
|
658
|
+
const current = getDisplayWidth(truncated);
|
|
659
|
+
if (current >= targetWidth) return truncated;
|
|
660
|
+
return truncated + " ".repeat(targetWidth - current);
|
|
661
|
+
}
|
|
662
|
+
function formatProgressBar(progress, cells = 4) {
|
|
663
|
+
if (!progress || !progress.total || progress.total < 1) return "";
|
|
664
|
+
const done = Math.max(0, Math.min(progress.total, progress.done || 0));
|
|
665
|
+
const filled = Math.min(cells, Math.max(done > 0 ? 1 : 0, Math.round(done / progress.total * cells)));
|
|
666
|
+
const finalFilled = done >= progress.total ? cells : done === 0 ? 0 : filled;
|
|
667
|
+
const bar = "\u25A0".repeat(finalFilled) + "\u25A1".repeat(Math.max(0, cells - finalFilled));
|
|
668
|
+
return `[${bar} ${done}/${progress.total}]`;
|
|
669
|
+
}
|
|
670
|
+
function formatRelativeAge(timestamp, now = Date.now()) {
|
|
671
|
+
const diffSec = Math.max(0, Math.floor((now - timestamp) / 1e3));
|
|
672
|
+
if (diffSec < 60) return "just now";
|
|
673
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
674
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
675
|
+
const diffHours = Math.floor(diffMin / 60);
|
|
676
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
677
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
678
|
+
return `${diffDays}d ago`;
|
|
679
|
+
}
|
|
680
|
+
function formatOrigin(anchor) {
|
|
681
|
+
if (typeof anchor === "string") {
|
|
682
|
+
if (!anchor) return "global";
|
|
683
|
+
return path2.basename(anchor.replace(/\\/g, "/")) || "global";
|
|
684
|
+
}
|
|
685
|
+
let proj = "global";
|
|
686
|
+
if (anchor.project && anchor.project !== "global") {
|
|
687
|
+
const normProj = anchor.project.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
688
|
+
if (normProj.includes("/")) {
|
|
689
|
+
const parts = normProj.split("/").filter(Boolean);
|
|
690
|
+
proj = parts.slice(-2).join("/");
|
|
691
|
+
} else {
|
|
692
|
+
proj = normProj;
|
|
693
|
+
}
|
|
694
|
+
} else if (anchor.cwd) {
|
|
695
|
+
proj = path2.basename(anchor.cwd.replace(/\\/g, "/")) || "global";
|
|
696
|
+
}
|
|
697
|
+
if (anchor.files && anchor.files.length > 0) {
|
|
698
|
+
const shortFile = path2.basename(anchor.files[0].replace(/\\/g, "/"));
|
|
699
|
+
if (shortFile && !proj.endsWith(shortFile)) {
|
|
700
|
+
return `${proj} \xB7 ${shortFile}`;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return proj;
|
|
704
|
+
}
|
|
705
|
+
function sortAnchorsForDisplay(anchors, cwd) {
|
|
706
|
+
return [...anchors].sort((a, b) => {
|
|
707
|
+
if (a.status !== b.status) {
|
|
708
|
+
return a.status === "active" ? -1 : 1;
|
|
709
|
+
}
|
|
710
|
+
if (cwd) {
|
|
711
|
+
const aMatch = isAnchorTargetingWorkspace(a, cwd) ? 0 : 1;
|
|
712
|
+
const bMatch = isAnchorTargetingWorkspace(b, cwd) ? 0 : 1;
|
|
713
|
+
if (aMatch !== bMatch) return aMatch - bMatch;
|
|
714
|
+
}
|
|
715
|
+
return b.lastTouchedAt - a.lastTouchedAt;
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
function updateAnchorStatusBar(ctx, store) {
|
|
719
|
+
if (!hasActiveUI(ctx)) return;
|
|
720
|
+
try {
|
|
721
|
+
const active = store.list({ status: "active", cwd: ctx.cwd });
|
|
722
|
+
ctx.ui.setStatus("anchor", active.length > 0 ? `\u2316 ${active.length}` : void 0);
|
|
723
|
+
updateStartupBanner(ctx, store);
|
|
724
|
+
} catch {
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function updateStartupBanner(ctx, store) {
|
|
728
|
+
if (!hasActiveUI(ctx)) return;
|
|
729
|
+
try {
|
|
730
|
+
const active = store.list({ status: "active", cwd: ctx.cwd });
|
|
731
|
+
if (active.length === 0) {
|
|
732
|
+
ctx.ui.setWidget("anchor-startup", void 0, { placement: "belowEditor" });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const sorted = sortAnchorsForDisplay(active, ctx.cwd);
|
|
736
|
+
const factory = (_tui, theme) => ({
|
|
737
|
+
render: (width) => {
|
|
738
|
+
const fg = theme?.fg ? theme.fg.bind(theme) : (_c, text) => text;
|
|
739
|
+
if (sorted.length === 1) {
|
|
740
|
+
const a = sorted[0];
|
|
741
|
+
const num = a.id.replace(/^anc-/, "");
|
|
742
|
+
const bar = a.progress ? `${fg("accent", formatProgressBar(a.progress))} ` : "";
|
|
743
|
+
const nextStep = a.progress?.steps && a.progress.steps[a.progress.done] ? fg("muted", ` \u25B8 ${a.progress.steps[a.progress.done]}`) : "";
|
|
744
|
+
const origin = formatOrigin(a);
|
|
745
|
+
const sessionIcon = a.sessionFile ? " \u21AA" : "";
|
|
746
|
+
const prefix = `${fg("accent", "\u2316")} ${fg("dim", `#${num}`)} ${bar}`;
|
|
747
|
+
const suffix = ` ${fg("muted", `(${origin}${sessionIcon})`)}`;
|
|
748
|
+
const availTitle = Math.max(12, width - getDisplayWidth(prefix) - getDisplayWidth(nextStep) - getDisplayWidth(suffix) - 2);
|
|
749
|
+
const titleClipped = truncateToWidth(a.title, availTitle);
|
|
750
|
+
return [truncateToWidth(`${prefix}${titleClipped}${nextStep}${suffix}`, width)];
|
|
751
|
+
}
|
|
752
|
+
const countTag = fg("accent", `\u2316 ${sorted.length} notes: `);
|
|
753
|
+
const segments = sorted.slice(0, 3).map((a) => {
|
|
754
|
+
const num = a.id.replace(/^anc-/, "");
|
|
755
|
+
const bar = a.progress ? `${formatProgressBar(a.progress)} ` : "";
|
|
756
|
+
const shortTitle = truncateToWidth(a.title, Math.max(14, Math.floor((width - 20) / Math.min(3, sorted.length)) - 12));
|
|
757
|
+
return `${fg("dim", `#${num}`)} ${bar}${shortTitle}`;
|
|
758
|
+
});
|
|
759
|
+
const more = sorted.length > 3 ? fg("dim", ` +${sorted.length - 3}`) : "";
|
|
760
|
+
return [truncateToWidth(`${countTag}${segments.join(fg("dim", " \xB7 "))}${more}`, width)];
|
|
761
|
+
},
|
|
762
|
+
invalidate: () => {
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
const content = ctx.ui?.theme ? factory : factory().render(80);
|
|
766
|
+
ctx.ui.setWidget("anchor-startup", content, { placement: "belowEditor" });
|
|
767
|
+
} catch {
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
async function openAnchorDashboard(ctx, store) {
|
|
771
|
+
if (!hasActiveUI(ctx)) return;
|
|
772
|
+
const allNotes = store.list({ cwd: ctx.cwd, all: true }).filter((a) => a.status === "active" || a.status === "sleeping");
|
|
773
|
+
if (allNotes.length === 0) {
|
|
774
|
+
ctx.ui.notify("\u2316 No sticky notes. Type `pin: <note>` or `/pin <note>` to stick one.", "info");
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const sorted = sortAnchorsForDisplay(allNotes, ctx.cwd);
|
|
778
|
+
const optionMap = /* @__PURE__ */ new Map();
|
|
779
|
+
const displayOptions = [];
|
|
780
|
+
const termCols = process.stdout?.columns || 96;
|
|
781
|
+
const isNarrow = termCols < 84;
|
|
782
|
+
const titleWidth = isNarrow ? Math.max(20, termCols - 30) : 42;
|
|
783
|
+
for (const a of sorted) {
|
|
784
|
+
const num = a.id.replace(/^anc-/, "").padStart(2, "0");
|
|
785
|
+
const bar = a.progress ? `${formatProgressBar(a.progress)} ` : "";
|
|
786
|
+
const origin = formatOrigin(a) + (a.sessionFile ? " \u21AA" : "");
|
|
787
|
+
const age = a.status === "sleeping" ? `${formatRelativeAge(a.createdAt)} \xB7 quiet` : formatRelativeAge(a.createdAt);
|
|
788
|
+
const colNum = `${num} `;
|
|
789
|
+
const colTitle = padToWidth(`${bar}${a.title}`, titleWidth);
|
|
790
|
+
const colOrigin = padToWidth(origin, 22);
|
|
791
|
+
const label = isNarrow ? `${colNum}${colTitle} ${age}`.trimEnd() : `${colNum}${colTitle} ${colOrigin} ${age}`.trimEnd();
|
|
792
|
+
optionMap.set(label, a);
|
|
793
|
+
displayOptions.push(label);
|
|
794
|
+
}
|
|
795
|
+
const selected = await ctx.ui.select("\u2316 Notes (select a note):", displayOptions);
|
|
796
|
+
if (!selected) return;
|
|
797
|
+
const anchor = optionMap.get(selected);
|
|
798
|
+
if (!anchor) return;
|
|
799
|
+
const currentSessionFile = ctx.sessionManager?.getSessionFile?.();
|
|
800
|
+
const canJumpSession = Boolean(
|
|
801
|
+
anchor.sessionFile && fs2.existsSync(anchor.sessionFile) && typeof ctx.switchSession === "function" && (!currentSessionFile || path2.resolve(anchor.sessionFile) !== path2.resolve(currentSessionFile))
|
|
802
|
+
);
|
|
803
|
+
const stepLabel = anchor.progress ? `\u25B6 Advance step (${anchor.progress.done}/${anchor.progress.total} \u2794 ${Math.min(anchor.progress.total, anchor.progress.done + 1)}/${anchor.progress.total})` : "\u25B6 Start progress bar (1/4)";
|
|
804
|
+
const actions = [
|
|
805
|
+
"\u2713 Done (tear off note)",
|
|
806
|
+
stepLabel,
|
|
807
|
+
...canJumpSession ? ["\u21AA Jump to session (resume linked conversation)"] : [],
|
|
808
|
+
...anchor.status === "sleeping" ? ["\u26A1 Wake up (mark active)"] : [],
|
|
809
|
+
"\u2139 Read full note",
|
|
810
|
+
"\u21A9 Back"
|
|
811
|
+
];
|
|
812
|
+
const action = await ctx.ui.select(`\u2316 #${anchor.id.replace(/^anc-/, "")}: "${anchor.title}"`, actions);
|
|
813
|
+
if (!action || action.startsWith("\u21A9")) return;
|
|
814
|
+
if (action.startsWith("\u2713")) {
|
|
815
|
+
store.settle(anchor.id, { settledBy: "manual" });
|
|
816
|
+
ctx.ui.notify(`\u2316 Torn off #${anchor.id.replace(/^anc-/, "")}: "${anchor.title}" (/anchor undo to put back)`, "info");
|
|
817
|
+
updateAnchorStatusBar(ctx, store);
|
|
818
|
+
} else if (action.startsWith("\u25B6")) {
|
|
819
|
+
const { anchor: updated, completed } = store.step(anchor.id, 1);
|
|
820
|
+
updateAnchorStatusBar(ctx, store);
|
|
821
|
+
if (completed) {
|
|
822
|
+
ctx.ui.notify(
|
|
823
|
+
`\u2316 #${updated.id.replace(/^anc-/, "")} reached ${formatProgressBar(updated.progress)}! Run /done ${updated.id.replace(/^anc-/, "")} to tear off.`,
|
|
824
|
+
"info"
|
|
825
|
+
);
|
|
826
|
+
} else {
|
|
827
|
+
ctx.ui.notify(`\u2316 Advanced #${updated.id.replace(/^anc-/, "")} to ${formatProgressBar(updated.progress)}`, "info");
|
|
828
|
+
}
|
|
829
|
+
} else if (action.startsWith("\u21AA") && anchor.sessionFile) {
|
|
830
|
+
try {
|
|
831
|
+
await ctx.switchSession(anchor.sessionFile);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
ctx.ui.notify(`Could not switch session: ${err.message}`, "error");
|
|
834
|
+
}
|
|
835
|
+
} else if (action.startsWith("\u26A1")) {
|
|
836
|
+
store.touch(anchor.id);
|
|
837
|
+
ctx.ui.notify(`\u2316 Woke up #${anchor.id.replace(/^anc-/, "")}: "${anchor.title}"`, "info");
|
|
838
|
+
updateAnchorStatusBar(ctx, store);
|
|
839
|
+
} else if (action.startsWith("\u2139")) {
|
|
840
|
+
const details = [
|
|
841
|
+
`\u2316 Note #${anchor.id.replace(/^anc-/, "")}: ${anchor.title}`,
|
|
842
|
+
anchor.progress ? `Progress: ${formatProgressBar(anchor.progress)}${anchor.progress.steps ? ` (${anchor.progress.steps.join(" \u2192 ")})` : ""}` : null,
|
|
843
|
+
anchor.description ? `Details: ${anchor.description}` : null,
|
|
844
|
+
anchor.files.length > 0 ? `Files: ${anchor.files.join(", ")}` : null,
|
|
845
|
+
anchor.sessionFile ? `Session: ${path2.basename(anchor.sessionFile)}` : null,
|
|
846
|
+
`Workspace: ${anchor.project || "global"} \xB7 Pinned ${formatRelativeAge(anchor.createdAt)}`
|
|
847
|
+
].filter(Boolean).join("\n");
|
|
848
|
+
ctx.ui.notify(details, "info");
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// src/extension.ts
|
|
853
|
+
var MUTATION_TOOLS = /* @__PURE__ */ new Set(["edit", "write", "patch", "apply_diff", "create_file", "modify"]);
|
|
854
|
+
function parseAnchorCommandInput(raw) {
|
|
855
|
+
const trimmed = (raw || "").trim();
|
|
856
|
+
if (!trimmed || trimmed === "list" || trimmed === "ls") {
|
|
857
|
+
return { mode: "dashboard" };
|
|
858
|
+
}
|
|
859
|
+
if (trimmed === "undo") {
|
|
860
|
+
return { mode: "undo" };
|
|
861
|
+
}
|
|
862
|
+
const settleMatch = trimmed.match(/^(?:done|rm|close|settle)\s+(.+)$/i);
|
|
863
|
+
if (settleMatch) {
|
|
864
|
+
return {
|
|
865
|
+
mode: "settle",
|
|
866
|
+
targetId: settleMatch[1].trim().replace(/^#/, "")
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
const stepMatch = trimmed.match(/^(?:step|\+)\s*(\S+)(?:\s+([+-]?\d+))?$/i);
|
|
870
|
+
if (stepMatch) {
|
|
871
|
+
return {
|
|
872
|
+
mode: "step",
|
|
873
|
+
targetId: stepMatch[1].trim().replace(/^#/, ""),
|
|
874
|
+
delta: stepMatch[2] ? parseInt(stepMatch[2], 10) : 1
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
const jumpMatch = trimmed.match(/^(?:jump|resume|session)\s+(.+)$/i);
|
|
878
|
+
if (jumpMatch) {
|
|
879
|
+
return {
|
|
880
|
+
mode: "jump",
|
|
881
|
+
targetId: jumpMatch[1].trim().replace(/^#/, "")
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
return {
|
|
885
|
+
mode: "pin",
|
|
886
|
+
title: trimmed
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
function parseQuickNoteInput(text) {
|
|
890
|
+
const m = (text || "").trim().match(/^(?:pin|note|便签|备忘)\s*[::]\s*(.+)$/i);
|
|
891
|
+
return m && m[1].trim() ? m[1].trim() : null;
|
|
892
|
+
}
|
|
893
|
+
function resolveAnchorTarget(store, idOrIndex, cwd) {
|
|
894
|
+
const clean = idOrIndex.trim().replace(/^#/, "");
|
|
895
|
+
if (/^\d+$/.test(clean)) {
|
|
896
|
+
const seqIndex = parseInt(clean, 10);
|
|
897
|
+
const visible = store.list({ cwd, all: true }).filter((a) => a.status === "active" || a.status === "sleeping");
|
|
898
|
+
const sorted = sortAnchorsForDisplay(visible, cwd);
|
|
899
|
+
if (seqIndex >= 1 && seqIndex <= sorted.length) {
|
|
900
|
+
return sorted[seqIndex - 1].id;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return store.get(clean)?.id;
|
|
904
|
+
}
|
|
905
|
+
function getSessionFileSafe(ctx) {
|
|
906
|
+
try {
|
|
907
|
+
const file = ctx?.sessionManager?.getSessionFile?.();
|
|
908
|
+
return typeof file === "string" && file.trim() ? file.trim() : void 0;
|
|
909
|
+
} catch {
|
|
910
|
+
return void 0;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
function extension_default(pi) {
|
|
914
|
+
const store = new AnchorStore();
|
|
915
|
+
const annotatedThisSession = /* @__PURE__ */ new Set();
|
|
916
|
+
const recentSessionFiles = [];
|
|
917
|
+
function rememberTouchedFile(filePath, cwd) {
|
|
918
|
+
const norm = normalizePath(filePath, cwd);
|
|
919
|
+
if (!norm) return;
|
|
920
|
+
const idx = recentSessionFiles.indexOf(norm);
|
|
921
|
+
if (idx !== -1) recentSessionFiles.splice(idx, 1);
|
|
922
|
+
recentSessionFiles.unshift(norm);
|
|
923
|
+
if (recentSessionFiles.length > 5) recentSessionFiles.pop();
|
|
924
|
+
}
|
|
925
|
+
function createStickyNoteFromUser(title, ctx) {
|
|
926
|
+
const sessionFile = getSessionFileSafe(ctx);
|
|
927
|
+
const anc = store.create({
|
|
928
|
+
title,
|
|
929
|
+
cwd: ctx.cwd,
|
|
930
|
+
sessionFile
|
|
931
|
+
});
|
|
932
|
+
if (anc.files.length === 0 && recentSessionFiles.length > 0) {
|
|
933
|
+
store.update(anc.id, { files: [recentSessionFiles[0]] });
|
|
934
|
+
}
|
|
935
|
+
updateAnchorStatusBar(ctx, store);
|
|
936
|
+
return store.get(anc.id) || anc;
|
|
937
|
+
}
|
|
938
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
939
|
+
annotatedThisSession.clear();
|
|
940
|
+
recentSessionFiles.length = 0;
|
|
941
|
+
sweepQuietNotes(store);
|
|
942
|
+
const quietForHere = store.list({ status: "sleeping", all: true }).filter((a) => (a.files.length > 0 || a.project && a.project !== "global" || a.cwd) && isAnchorTargetingWorkspace(a, ctx.cwd));
|
|
943
|
+
for (const q of quietForHere) {
|
|
944
|
+
store.touch(q.id);
|
|
945
|
+
}
|
|
946
|
+
updateAnchorStatusBar(ctx, store);
|
|
947
|
+
updateStartupBanner(ctx, store);
|
|
948
|
+
});
|
|
949
|
+
pi.on("input", async (event, ctx) => {
|
|
950
|
+
if (event?.source === "extension") return { action: "continue" };
|
|
951
|
+
const quickNote = parseQuickNoteInput(String(event?.text || ""));
|
|
952
|
+
if (!quickNote) return { action: "continue" };
|
|
953
|
+
const anc = createStickyNoteFromUser(quickNote, ctx);
|
|
954
|
+
const bar = anc.progress ? ` ${formatProgressBar(anc.progress)}` : "";
|
|
955
|
+
if (hasActiveUI(ctx)) {
|
|
956
|
+
ctx.ui.notify(`\u2316 Pinned #${anc.id.replace(/^anc-/, "")}${bar}: "${anc.title}"`, "info");
|
|
957
|
+
}
|
|
958
|
+
return { action: "handled" };
|
|
959
|
+
});
|
|
960
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
961
|
+
const entries = ctx.sessionManager?.getEntries() || [];
|
|
962
|
+
const messageTurns = entries.filter((e) => e.type === "message");
|
|
963
|
+
if (messageTurns.length <= 1) {
|
|
964
|
+
const snippet = renderColdStartAnchorsContext(store, ctx.cwd);
|
|
965
|
+
if (snippet) {
|
|
966
|
+
return { systemPrompt: `${event.systemPrompt}
|
|
967
|
+
|
|
968
|
+
${snippet}` };
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
973
|
+
if (event.isError) return;
|
|
974
|
+
const lowerTool = String(event.toolName || "").toLowerCase();
|
|
975
|
+
const currentSessionFile = getSessionFileSafe(ctx);
|
|
976
|
+
if (lowerTool === "bash" || lowerTool === "powershell") {
|
|
977
|
+
const cmd = String(event.input?.command || "");
|
|
978
|
+
if (cmd.includes("git commit")) {
|
|
979
|
+
const msgMatch = cmd.match(/-m\s+["']([^"']+)["']/);
|
|
980
|
+
if (msgMatch && msgMatch[1]) {
|
|
981
|
+
const commitMsg = msgMatch[1];
|
|
982
|
+
const candidates = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active" || a.status === "sleeping");
|
|
983
|
+
for (const a of candidates) {
|
|
984
|
+
if (matchesCommitMessage(a, [commitMsg]).matched && isAnchorTargetingWorkspace(a, ctx.cwd)) {
|
|
985
|
+
store.settle(a.id, { settledBy: "commit", summary: commitMsg });
|
|
986
|
+
updateAnchorStatusBar(ctx, store);
|
|
987
|
+
if (hasActiveUI(ctx)) {
|
|
988
|
+
try {
|
|
989
|
+
ctx.ui.notify(`\u2316 Auto-torn off #${a.id.replace(/^anc-/, "")} ("${a.title}") on git commit`, "info");
|
|
990
|
+
} catch {
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
const pathInput = event.input?.path;
|
|
999
|
+
if (typeof pathInput !== "string") return;
|
|
1000
|
+
const isMutation = MUTATION_TOOLS.has(lowerTool);
|
|
1001
|
+
if (isMutation) {
|
|
1002
|
+
rememberTouchedFile(pathInput, ctx.cwd);
|
|
1003
|
+
}
|
|
1004
|
+
const touchedPath = normalizePath(pathInput, ctx.cwd);
|
|
1005
|
+
const notes = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active" || a.status === "sleeping");
|
|
1006
|
+
const matches = findMatchedAnchors(notes, [touchedPath]);
|
|
1007
|
+
if (matches.length === 0) return;
|
|
1008
|
+
if (isMutation) {
|
|
1009
|
+
for (const m of matches) {
|
|
1010
|
+
store.touch(m.anchor.id, Date.now(), currentSessionFile);
|
|
1011
|
+
}
|
|
1012
|
+
updateAnchorStatusBar(ctx, store);
|
|
1013
|
+
}
|
|
1014
|
+
const targetNote = matches[0].anchor;
|
|
1015
|
+
if (!annotatedThisSession.has(targetNote.id)) {
|
|
1016
|
+
annotatedThisSession.add(targetNote.id);
|
|
1017
|
+
const whisper = makeSafeTaskAnnotation(touchedPath, targetNote);
|
|
1018
|
+
if (whisper) {
|
|
1019
|
+
const contents = [...event.content || []];
|
|
1020
|
+
for (let i = contents.length - 1; i >= 0; i--) {
|
|
1021
|
+
const item = contents[i];
|
|
1022
|
+
if (item && item.type === "text") {
|
|
1023
|
+
contents[i] = { ...item, text: item.text + whisper };
|
|
1024
|
+
return { content: contents };
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
pi.registerTool({
|
|
1031
|
+
name: "anchor",
|
|
1032
|
+
label: "Anchor (Sticky Note)",
|
|
1033
|
+
description: "Pin, list, step, or tear off cross-session sticky notes stored in ~/.anchor/ (zero git repo clutter). Actions: pin (stick a new note), list (view notes), step (advance progress bar), settle (tear off completed note).",
|
|
1034
|
+
promptSnippet: "Pin or tear off cross-session sticky notes that survive terminal restarts",
|
|
1035
|
+
promptGuidelines: [
|
|
1036
|
+
"Use `anchor` when the user explicitly asks to remember something for later/tomorrow or across sessions.",
|
|
1037
|
+
"BOUNDARY WITH TODO: `todo` is for multi-step work right now in this session; `anchor` is a sticky note for future sessions.",
|
|
1038
|
+
'When a pinned note is completed, call `anchor` with action "settle" to tear it off.'
|
|
1039
|
+
],
|
|
1040
|
+
parameters: Type.Object({
|
|
1041
|
+
action: Type.Union([
|
|
1042
|
+
Type.Literal("pin"),
|
|
1043
|
+
Type.Literal("list"),
|
|
1044
|
+
Type.Literal("step"),
|
|
1045
|
+
Type.Literal("settle"),
|
|
1046
|
+
Type.Literal("touch"),
|
|
1047
|
+
Type.Literal("sweep")
|
|
1048
|
+
]),
|
|
1049
|
+
title: Type.Optional(Type.String({ description: "Sticky note text (supports [1/4] or 1. A 2. B progress steps)" })),
|
|
1050
|
+
description: Type.Optional(Type.String({ description: "Optional extra details" })),
|
|
1051
|
+
project: Type.Optional(Type.String({ description: "Optional project name or folder" })),
|
|
1052
|
+
files: Type.Optional(Type.Array(Type.String(), { description: "Optional associated file paths" })),
|
|
1053
|
+
id: Type.Optional(Type.String({ description: "Note ID, e.g. anc-1 or 1 (for step/settle)" }))
|
|
1054
|
+
}),
|
|
1055
|
+
renderCall(args, theme) {
|
|
1056
|
+
const action = args.action || "list";
|
|
1057
|
+
let title = theme.fg("toolTitle", theme.bold("\u2316 anchor ")) + theme.fg("accent", action);
|
|
1058
|
+
if (action === "pin" && args.title) {
|
|
1059
|
+
title += ` "${args.title}"`;
|
|
1060
|
+
} else if ((action === "settle" || action === "touch" || action === "step") && args.id) {
|
|
1061
|
+
title += ` ${theme.fg("dim", `#${String(args.id).replace(/^anc-/, "")}`)}`;
|
|
1062
|
+
}
|
|
1063
|
+
return {
|
|
1064
|
+
render: (width) => [truncateToWidth(title, width)],
|
|
1065
|
+
invalidate: () => {
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
},
|
|
1069
|
+
renderResult(result, _options, theme) {
|
|
1070
|
+
const text = result.content?.[0]?.text || (result.isError ? "Failed" : "OK");
|
|
1071
|
+
const color = result.isError ? "error" : "success";
|
|
1072
|
+
const icon = result.isError ? "\u2717" : "\u2714";
|
|
1073
|
+
return {
|
|
1074
|
+
render: (width) => [truncateToWidth(theme.fg(color, `${icon} ${text}`), width)],
|
|
1075
|
+
invalidate: () => {
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
},
|
|
1079
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1080
|
+
if (!ctx) return { content: [{ type: "text", text: "Error: context required" }], isError: true };
|
|
1081
|
+
if (params.action === "pin") {
|
|
1082
|
+
if (!params.title) {
|
|
1083
|
+
return { content: [{ type: "text", text: "Error: title is required for pin" }], isError: true };
|
|
1084
|
+
}
|
|
1085
|
+
const anc = store.create({
|
|
1086
|
+
title: params.title,
|
|
1087
|
+
description: params.description,
|
|
1088
|
+
project: params.project,
|
|
1089
|
+
cwd: ctx.cwd,
|
|
1090
|
+
files: params.files?.length ? params.files : recentSessionFiles[0] ? [recentSessionFiles[0]] : [],
|
|
1091
|
+
sessionFile: getSessionFileSafe(ctx)
|
|
1092
|
+
});
|
|
1093
|
+
updateAnchorStatusBar(ctx, store);
|
|
1094
|
+
return {
|
|
1095
|
+
content: [{ type: "text", text: `Pinned note #${anc.id.replace(/^anc-/, "")}: "${anc.title}"` }],
|
|
1096
|
+
isError: false
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
if (params.action === "list") {
|
|
1100
|
+
const notes = store.list({ cwd: ctx.cwd, all: true }).filter((a) => a.status === "active" || a.status === "sleeping");
|
|
1101
|
+
return {
|
|
1102
|
+
content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
|
|
1103
|
+
isError: false
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
if (params.action === "step") {
|
|
1107
|
+
const rawId = params.id || "1";
|
|
1108
|
+
try {
|
|
1109
|
+
const targetId = resolveAnchorTarget(store, String(rawId), ctx.cwd) || String(rawId);
|
|
1110
|
+
const { anchor: updated } = store.step(targetId, 1);
|
|
1111
|
+
updateAnchorStatusBar(ctx, store);
|
|
1112
|
+
return {
|
|
1113
|
+
content: [{ type: "text", text: `Advanced #${updated.id.replace(/^anc-/, "")} to ${formatProgressBar(updated.progress)}` }],
|
|
1114
|
+
isError: false
|
|
1115
|
+
};
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
return { content: [{ type: "text", text: err.message }], isError: true };
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (params.action === "settle") {
|
|
1121
|
+
if (!params.id) {
|
|
1122
|
+
return { content: [{ type: "text", text: "Error: id is required for settle" }], isError: true };
|
|
1123
|
+
}
|
|
1124
|
+
try {
|
|
1125
|
+
const targetId = resolveAnchorTarget(store, String(params.id), ctx.cwd) || String(params.id);
|
|
1126
|
+
const settled = store.settle(targetId, { settledBy: "manual" });
|
|
1127
|
+
updateAnchorStatusBar(ctx, store);
|
|
1128
|
+
return {
|
|
1129
|
+
content: [{ type: "text", text: `Torn off note #${settled.id.replace(/^anc-/, "")}: "${settled.title}"` }],
|
|
1130
|
+
isError: false
|
|
1131
|
+
};
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
return { content: [{ type: "text", text: err.message }], isError: true };
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
if (params.action === "touch" && params.id) {
|
|
1137
|
+
try {
|
|
1138
|
+
const touched = store.touch(String(params.id), Date.now(), getSessionFileSafe(ctx));
|
|
1139
|
+
updateAnchorStatusBar(ctx, store);
|
|
1140
|
+
return { content: [{ type: "text", text: `Refreshed note #${touched.id}` }], isError: false };
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
return { content: [{ type: "text", text: err.message }], isError: true };
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (params.action === "sweep") {
|
|
1146
|
+
const res = sweepQuietNotes(store);
|
|
1147
|
+
updateAnchorStatusBar(ctx, store);
|
|
1148
|
+
return { content: [{ type: "text", text: `Quieted ${res.quieted.length} old note(s).` }], isError: false };
|
|
1149
|
+
}
|
|
1150
|
+
return { content: [{ type: "text", text: "Unknown action" }], isError: true };
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
pi.registerCommand("anchor", {
|
|
1154
|
+
description: "View, pin, step, jump to session, or tear off sticky notes (/anchor, /anchor step 1, /anchor jump 1, /anchor done 1)",
|
|
1155
|
+
handler: async (args, ctx) => {
|
|
1156
|
+
const parsed = parseAnchorCommandInput(args);
|
|
1157
|
+
if (parsed.mode === "dashboard") {
|
|
1158
|
+
await openAnchorDashboard(ctx, store);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
if (parsed.mode === "undo") {
|
|
1162
|
+
try {
|
|
1163
|
+
const restored = store.undoSettle();
|
|
1164
|
+
ctx.ui.notify(`\u2316 Put back #${restored.id.replace(/^anc-/, "")}: "${restored.title}"`, "info");
|
|
1165
|
+
updateAnchorStatusBar(ctx, store);
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
ctx.ui.notify(`Undo failed: ${err.message}`, "error");
|
|
1168
|
+
}
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (parsed.mode === "step" && parsed.targetId) {
|
|
1172
|
+
const resolvedId = resolveAnchorTarget(store, parsed.targetId, ctx.cwd);
|
|
1173
|
+
if (!resolvedId) {
|
|
1174
|
+
ctx.ui.notify(`\u2316 Note not found: ${parsed.targetId}`, "error");
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
const { anchor: updated } = store.step(resolvedId, parsed.delta || 1);
|
|
1178
|
+
ctx.ui.notify(`\u2316 Advanced #${updated.id.replace(/^anc-/, "")} to ${formatProgressBar(updated.progress)}`, "info");
|
|
1179
|
+
updateAnchorStatusBar(ctx, store);
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (parsed.mode === "jump" && parsed.targetId) {
|
|
1183
|
+
const resolvedId = resolveAnchorTarget(store, parsed.targetId, ctx.cwd);
|
|
1184
|
+
const note = resolvedId ? store.get(resolvedId) : void 0;
|
|
1185
|
+
if (!note || !note.sessionFile || !fs3.existsSync(note.sessionFile)) {
|
|
1186
|
+
ctx.ui.notify(`\u2316 No saved conversation session found for #${parsed.targetId}`, "error");
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
await ctx.switchSession(note.sessionFile);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (parsed.mode === "settle" && parsed.targetId) {
|
|
1193
|
+
const resolvedId = resolveAnchorTarget(store, parsed.targetId, ctx.cwd);
|
|
1194
|
+
if (!resolvedId) {
|
|
1195
|
+
ctx.ui.notify(`\u2316 Note not found: ${parsed.targetId}`, "error");
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const settled = store.settle(resolvedId, { settledBy: "manual" });
|
|
1199
|
+
ctx.ui.notify(`\u2316 Torn off #${settled.id.replace(/^anc-/, "")}: "${settled.title}" (/anchor undo to put back)`, "info");
|
|
1200
|
+
updateAnchorStatusBar(ctx, store);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
const rawTrimmed = (args || "").trim();
|
|
1204
|
+
if (/^(?:#?anc-\d+|#\d+)$/i.test(rawTrimmed)) {
|
|
1205
|
+
const resolvedId = resolveAnchorTarget(store, rawTrimmed, ctx.cwd);
|
|
1206
|
+
if (resolvedId) {
|
|
1207
|
+
const settled = store.settle(resolvedId, { settledBy: "manual" });
|
|
1208
|
+
ctx.ui.notify(`\u2316 Torn off #${settled.id.replace(/^anc-/, "")}: "${settled.title}" (/anchor undo to put back)`, "info");
|
|
1209
|
+
updateAnchorStatusBar(ctx, store);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
const anc = createStickyNoteFromUser(parsed.title || rawTrimmed, ctx);
|
|
1214
|
+
const bar = anc.progress ? ` ${formatProgressBar(anc.progress)}` : "";
|
|
1215
|
+
ctx.ui.notify(`\u2316 Pinned #${anc.id.replace(/^anc-/, "")}${bar}: "${anc.title}"`, "info");
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
pi.registerCommand("pin", {
|
|
1219
|
+
description: "Stick a quick note across sessions (e.g. /pin Migrate auth [1/3])",
|
|
1220
|
+
handler: async (args, ctx) => {
|
|
1221
|
+
const text = (args || "").trim();
|
|
1222
|
+
if (!text) {
|
|
1223
|
+
await openAnchorDashboard(ctx, store);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const anc = createStickyNoteFromUser(text, ctx);
|
|
1227
|
+
const bar = anc.progress ? ` ${formatProgressBar(anc.progress)}` : "";
|
|
1228
|
+
ctx.ui.notify(`\u2316 Pinned #${anc.id.replace(/^anc-/, "")}${bar}: "${anc.title}"`, "info");
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
pi.registerCommand("step", {
|
|
1232
|
+
description: "Advance progress bar on a sticky note (e.g. /step 1)",
|
|
1233
|
+
handler: async (args, ctx) => {
|
|
1234
|
+
const tokens = (args || "").trim().split(/\s+/).filter(Boolean);
|
|
1235
|
+
const target = tokens[0] || "1";
|
|
1236
|
+
const delta = tokens[1] ? parseInt(tokens[1], 10) : 1;
|
|
1237
|
+
const resolvedId = resolveAnchorTarget(store, target, ctx.cwd);
|
|
1238
|
+
if (!resolvedId) {
|
|
1239
|
+
ctx.ui.notify(`\u2316 Note not found: ${target}`, "error");
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
const { anchor: updated } = store.step(resolvedId, isNaN(delta) ? 1 : delta);
|
|
1243
|
+
ctx.ui.notify(`\u2316 Advanced #${updated.id.replace(/^anc-/, "")} to ${formatProgressBar(updated.progress)}`, "info");
|
|
1244
|
+
updateAnchorStatusBar(ctx, store);
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
pi.registerCommand("done", {
|
|
1248
|
+
description: "Tear off a sticky note by number or ID (e.g. /done 1)",
|
|
1249
|
+
handler: async (args, ctx) => {
|
|
1250
|
+
const target = (args || "").trim();
|
|
1251
|
+
if (!target) {
|
|
1252
|
+
await openAnchorDashboard(ctx, store);
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
const resolvedId = resolveAnchorTarget(store, target, ctx.cwd);
|
|
1256
|
+
if (!resolvedId) {
|
|
1257
|
+
ctx.ui.notify(`\u2316 Note not found: ${target}`, "error");
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const settled = store.settle(resolvedId, { settledBy: "manual" });
|
|
1261
|
+
ctx.ui.notify(`\u2316 Torn off #${settled.id.replace(/^anc-/, "")}: "${settled.title}" (/anchor undo to put back)`, "info");
|
|
1262
|
+
updateAnchorStatusBar(ctx, store);
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
pi.registerCommand("anchors", {
|
|
1266
|
+
description: "View sticky notes",
|
|
1267
|
+
handler: async (_args, ctx) => {
|
|
1268
|
+
await openAnchorDashboard(ctx, store);
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
export {
|
|
1274
|
+
QUIET_AFTER_DAYS,
|
|
1275
|
+
MS_PER_DAY,
|
|
1276
|
+
normalizePath,
|
|
1277
|
+
isSameOrSubPath,
|
|
1278
|
+
globToRegExp,
|
|
1279
|
+
extractPathsFromText,
|
|
1280
|
+
extractProgressFromText,
|
|
1281
|
+
isAnchorTargetingWorkspace,
|
|
1282
|
+
matchAnchorAgainstTouchedFiles,
|
|
1283
|
+
findMatchedAnchors,
|
|
1284
|
+
COMMENT_FORMATS,
|
|
1285
|
+
makeSafeTaskAnnotation,
|
|
1286
|
+
matchesCommitMessage,
|
|
1287
|
+
renderColdStartAnchorsContext,
|
|
1288
|
+
renderActiveAnchorsContext,
|
|
1289
|
+
sweepQuietNotes,
|
|
1290
|
+
getDefaultStorageDir,
|
|
1291
|
+
acquireSyncLock,
|
|
1292
|
+
atomicRenameWithRetry,
|
|
1293
|
+
AnchorStore,
|
|
1294
|
+
stripAnsi,
|
|
1295
|
+
getDisplayWidth,
|
|
1296
|
+
hasActiveUI,
|
|
1297
|
+
truncateToWidth,
|
|
1298
|
+
padToWidth,
|
|
1299
|
+
formatProgressBar,
|
|
1300
|
+
formatRelativeAge,
|
|
1301
|
+
formatOrigin,
|
|
1302
|
+
sortAnchorsForDisplay,
|
|
1303
|
+
updateAnchorStatusBar,
|
|
1304
|
+
updateStartupBanner,
|
|
1305
|
+
openAnchorDashboard,
|
|
1306
|
+
parseAnchorCommandInput,
|
|
1307
|
+
parseQuickNoteInput,
|
|
1308
|
+
resolveAnchorTarget,
|
|
1309
|
+
extension_default
|
|
1310
|
+
};
|