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