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
package/src/extension.ts
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { Type } from '@sinclair/typebox';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
import {
|
|
4
|
+
AnchorStore,
|
|
5
|
+
findMatchedAnchors,
|
|
6
|
+
isAnchorTargetingWorkspace,
|
|
7
|
+
makeSafeTaskAnnotation,
|
|
8
|
+
matchesCommitMessage,
|
|
9
|
+
normalizePath,
|
|
10
|
+
renderColdStartAnchorsContext,
|
|
11
|
+
sweepQuietNotes
|
|
12
|
+
} from './store.ts';
|
|
13
|
+
import {
|
|
14
|
+
hasActiveUI,
|
|
15
|
+
openAnchorDashboard,
|
|
16
|
+
sortAnchorsForDisplay,
|
|
17
|
+
truncateToWidth,
|
|
18
|
+
updateAnchorStatusBar,
|
|
19
|
+
updateStartupBanner
|
|
20
|
+
} from './tui.ts';
|
|
13
21
|
|
|
14
22
|
const MUTATION_TOOLS = new Set(['edit', 'write', 'patch', 'apply_diff', 'create_file', 'modify']);
|
|
15
23
|
|
|
16
|
-
|
|
17
|
-
* Parse inline `--due <date>` / `-d <date>` flags and `done`/`rm`/`close`/`settle` subcommands
|
|
18
|
-
* for `/anchor` and `/pin` slash commands.
|
|
19
|
-
*/
|
|
20
|
-
export function parseAnchorCommandInput(raw: string, now: number = Date.now()): {
|
|
24
|
+
export function parseAnchorCommandInput(raw: string): {
|
|
21
25
|
mode: 'dashboard' | 'undo' | 'settle' | 'pin';
|
|
22
26
|
targetId?: string;
|
|
23
27
|
title?: string;
|
|
24
|
-
targetDate?: string;
|
|
25
28
|
} {
|
|
26
29
|
const trimmed = (raw || '').trim();
|
|
27
30
|
if (!trimmed || trimmed === 'list' || trimmed === 'ls') {
|
|
@@ -31,7 +34,6 @@ export function parseAnchorCommandInput(raw: string, now: number = Date.now()):
|
|
|
31
34
|
return { mode: 'undo' };
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
// Match explicit settle subcommands: `/anchor done 1`, `/anchor rm anc-2`, `/anchor settle #1`
|
|
35
37
|
const settleMatch = trimmed.match(/^(?:done|rm|close|settle)\s+(.+)$/i);
|
|
36
38
|
if (settleMatch) {
|
|
37
39
|
return {
|
|
@@ -40,30 +42,14 @@ export function parseAnchorCommandInput(raw: string, now: number = Date.now()):
|
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
// Extract optional `--due <val>` or `-d <val>` flag
|
|
44
|
-
let targetDate: string | undefined;
|
|
45
|
-
const tokens = trimmed.split(/\s+/);
|
|
46
|
-
const dueIdx = tokens.findIndex(t => t === '--due' || t === '-d');
|
|
47
|
-
if (dueIdx !== -1 && tokens[dueIdx + 1]) {
|
|
48
|
-
const rawDue = tokens[dueIdx + 1];
|
|
49
|
-
targetDate = detectTargetDate(rawDue, now) || rawDue;
|
|
50
|
-
tokens.splice(dueIdx, 2);
|
|
51
|
-
}
|
|
52
|
-
const cleanTitle = tokens.join(' ').trim();
|
|
53
|
-
|
|
54
45
|
return {
|
|
55
46
|
mode: 'pin',
|
|
56
|
-
title:
|
|
57
|
-
targetDate
|
|
47
|
+
title: trimmed
|
|
58
48
|
};
|
|
59
49
|
}
|
|
60
50
|
|
|
61
|
-
/**
|
|
62
|
-
* Resolve either an explicit anchor ID ('anc-2', '2') or a 1-based visual dashboard index ('01', '1')
|
|
63
|
-
*/
|
|
64
51
|
export function resolveAnchorTarget(store: AnchorStore, idOrIndex: string, cwd?: string): string | undefined {
|
|
65
52
|
const clean = idOrIndex.trim().replace(/^#/, '');
|
|
66
|
-
// 1. Check 1-based sequential index from the sorted display list first when user passes a plain number
|
|
67
53
|
if (/^\d+$/.test(clean)) {
|
|
68
54
|
const seqIndex = parseInt(clean, 10);
|
|
69
55
|
const visible = store
|
|
@@ -74,76 +60,28 @@ export function resolveAnchorTarget(store: AnchorStore, idOrIndex: string, cwd?:
|
|
|
74
60
|
return sorted[seqIndex - 1].id;
|
|
75
61
|
}
|
|
76
62
|
}
|
|
77
|
-
|
|
78
|
-
const direct = store.get(clean);
|
|
79
|
-
return direct?.id;
|
|
63
|
+
return store.get(clean)?.id;
|
|
80
64
|
}
|
|
81
65
|
|
|
82
66
|
export default function (pi: ExtensionAPI) {
|
|
83
|
-
// Global authoritative store in ~/.anchor/ (0 workspace clutter)
|
|
84
67
|
const store = new AnchorStore();
|
|
85
|
-
const observer = new SessionTouchObserver();
|
|
86
68
|
const annotatedThisSession = new Set<string>();
|
|
87
|
-
const preflightFailures = new Map<string, string>();
|
|
88
69
|
|
|
89
|
-
// 1. Session start:
|
|
70
|
+
// 1. Session start: quiet old notes (>7d), wake notes belonging to this workspace, show sticky note banner
|
|
90
71
|
pi.on('session_start', async (event: any, ctx: ExtensionContext) => {
|
|
91
|
-
observer.clear();
|
|
92
72
|
annotatedThisSession.clear();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const sweep = sweepStore(store);
|
|
73
|
+
sweepQuietNotes(store);
|
|
96
74
|
|
|
97
|
-
//
|
|
98
|
-
const
|
|
75
|
+
// Clever Touch: If a quiet note belongs to the workspace we just opened, wake it up automatically
|
|
76
|
+
const quietForHere = store
|
|
99
77
|
.list({ status: 'sleeping', all: true })
|
|
100
78
|
.filter(a => (a.files.length > 0 || (a.project && a.project !== 'global') || a.cwd) && isAnchorTargetingWorkspace(a, ctx.cwd));
|
|
101
|
-
for (const
|
|
102
|
-
store.touch(
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (sweep.transitionedToSleeping.length > 0 && sleepingForWorkspace.length === 0) {
|
|
106
|
-
ctx.ui.notify(`⌖ Anchor: ${sweep.transitionedToSleeping.length} inactive task(s) moved to quiet mode (/anchor to view)`, 'info');
|
|
79
|
+
for (const q of quietForHere) {
|
|
80
|
+
store.touch(q.id);
|
|
107
81
|
}
|
|
108
82
|
|
|
109
|
-
// Physical Pre-flight Verification: auto-settle passing tasks or record failing output (scoped strictly to current cwd)
|
|
110
|
-
const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
|
|
111
|
-
for (const a of activeAnchors) {
|
|
112
|
-
if (a.verifyCommand && (!a.cwd || isSameOrSubPath(a.cwd, ctx.cwd))) {
|
|
113
|
-
try {
|
|
114
|
-
const res = runPhysicalVerification(a, ctx.cwd);
|
|
115
|
-
if (res.success) {
|
|
116
|
-
store.settle(a.id, {
|
|
117
|
-
settledBy: 'verification-test',
|
|
118
|
-
summary: `Physical pre-flight test passed: ${a.verifyCommand}`
|
|
119
|
-
});
|
|
120
|
-
ctx.ui.notify(`⌖ Verification passed! Auto-settled #${a.id}: "${a.title}"`, 'info');
|
|
121
|
-
} else {
|
|
122
|
-
preflightFailures.set(a.id, res.output.slice(-250));
|
|
123
|
-
}
|
|
124
|
-
} catch (_) {}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Exit Guard notification check
|
|
129
|
-
try {
|
|
130
|
-
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
131
|
-
const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
|
|
132
|
-
if (fs.existsSync(pendingPath)) {
|
|
133
|
-
const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
|
|
134
|
-
const isExpired = Date.now() - pending.timestamp >= 24 * 60 * 60 * 1000;
|
|
135
|
-
if (!isExpired && isSameOrSubPath(pending.cwd, ctx.cwd)) {
|
|
136
|
-
ctx.ui.notify(`⌖ [Exit Guard] Detected ${pending.modifiedCount} uncommitted changes from previous session exit. Run /pin to record.`, 'info');
|
|
137
|
-
fs.unlinkSync(pendingPath);
|
|
138
|
-
} else if (isExpired) {
|
|
139
|
-
fs.unlinkSync(pendingPath);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
} catch (_) {}
|
|
143
|
-
|
|
144
83
|
updateAnchorStatusBar(ctx, store);
|
|
145
84
|
|
|
146
|
-
// Only render startup banner on initial startup or new session, not on mid-session resume
|
|
147
85
|
if (event.reason !== 'resume') {
|
|
148
86
|
updateStartupBanner(ctx, store);
|
|
149
87
|
} else if (hasActiveUI(ctx)) {
|
|
@@ -153,7 +91,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
153
91
|
}
|
|
154
92
|
});
|
|
155
93
|
|
|
156
|
-
// 2.
|
|
94
|
+
// 2. Clear sticky note banner as soon as the user sends the first prompt
|
|
157
95
|
pi.on('agent_start', async (_event: any, ctx: ExtensionContext) => {
|
|
158
96
|
if (hasActiveUI(ctx)) {
|
|
159
97
|
try {
|
|
@@ -162,41 +100,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
162
100
|
}
|
|
163
101
|
});
|
|
164
102
|
|
|
165
|
-
// 3.
|
|
103
|
+
// 3. Turn 1 Cold-Start Brief (0 tokens on Turn 2+)
|
|
166
104
|
pi.on('before_agent_start', async (event: any, ctx: ExtensionContext) => {
|
|
167
105
|
const entries = (ctx as any).sessionManager?.getEntries() || [];
|
|
168
106
|
const messageTurns = entries.filter((e: any) => e.type === 'message');
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (contextSnippet) {
|
|
174
|
-
return {
|
|
175
|
-
systemPrompt: `${event.systemPrompt}\n\n${contextSnippet}`
|
|
176
|
-
};
|
|
107
|
+
if (messageTurns.length <= 1) {
|
|
108
|
+
const snippet = renderColdStartAnchorsContext(store, ctx.cwd);
|
|
109
|
+
if (snippet) {
|
|
110
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${snippet}` };
|
|
177
111
|
}
|
|
178
112
|
}
|
|
179
113
|
});
|
|
180
114
|
|
|
181
|
-
// 4.
|
|
115
|
+
// 4. Clever Touches on Tool Execution:
|
|
116
|
+
// (a) Auto-tear off sticky notes on matching `git commit`
|
|
117
|
+
// (b) Slip a 1-line comment whisper when the AI reads/edits a matching file
|
|
182
118
|
pi.on('tool_result', async (event: any, ctx: ExtensionContext) => {
|
|
119
|
+
if (event.isError) return;
|
|
120
|
+
|
|
183
121
|
const lowerTool = String(event.toolName || '').toLowerCase();
|
|
184
122
|
|
|
185
|
-
//
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
123
|
+
// (a) Real-time auto-tear on `git commit -m "..."`
|
|
124
|
+
if (lowerTool === 'bash' || lowerTool === 'powershell') {
|
|
125
|
+
const cmd = String((event.input as any)?.command || '');
|
|
126
|
+
if (cmd.includes('git commit')) {
|
|
127
|
+
const msgMatch = cmd.match(/-m\s+["']([^"']+)["']/);
|
|
128
|
+
if (msgMatch && msgMatch[1]) {
|
|
129
|
+
const commitMsg = msgMatch[1];
|
|
130
|
+
const candidates = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
|
|
131
|
+
for (const a of candidates) {
|
|
132
|
+
if (matchesCommitMessage(a, [commitMsg]).matched && isAnchorTargetingWorkspace(a, ctx.cwd)) {
|
|
133
|
+
store.settle(a.id, { settledBy: 'commit', summary: commitMsg });
|
|
134
|
+
updateAnchorStatusBar(ctx, store);
|
|
135
|
+
if (hasActiveUI(ctx)) {
|
|
198
136
|
try {
|
|
199
|
-
ctx.ui.notify(`⌖ Auto-
|
|
137
|
+
ctx.ui.notify(`⌖ Auto-torn off #${a.id.replace(/^anc-/, '')} ("${a.title}") on git commit`, 'info');
|
|
200
138
|
} catch {}
|
|
201
139
|
}
|
|
202
140
|
}
|
|
@@ -205,161 +143,48 @@ export default function (pi: ExtensionAPI) {
|
|
|
205
143
|
}
|
|
206
144
|
}
|
|
207
145
|
|
|
146
|
+
// (b) 1-line syntax-safe file whisper
|
|
208
147
|
const pathInput = (event.input as any)?.path;
|
|
209
148
|
if (typeof pathInput !== 'string') return;
|
|
210
149
|
|
|
211
150
|
const touchedPath = normalizePath(pathInput, ctx.cwd);
|
|
212
|
-
const
|
|
213
|
-
const matches = findMatchedAnchors(
|
|
214
|
-
|
|
215
|
-
if (matches.length > 0) {
|
|
216
|
-
const isMutation = MUTATION_TOOLS.has((event.toolName || '').toLowerCase());
|
|
217
|
-
|
|
218
|
-
// Only physical mutations (edit, write) refresh decay timer and wake sleeping anchors!
|
|
219
|
-
// Read-only inspection (read, grep) provides JIT context without fake lifecycle extension.
|
|
220
|
-
if (isMutation && !event.isError) {
|
|
221
|
-
for (const m of matches) {
|
|
222
|
-
store.touch(m.anchor.id);
|
|
223
|
-
}
|
|
224
|
-
updateAnchorStatusBar(ctx, store);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const a = matches[0].anchor;
|
|
228
|
-
|
|
229
|
-
// Deduplicate annotations and ensure syntax safety (e.g. skip .json/.env)
|
|
230
|
-
if (!annotatedThisSession.has(a.id)) {
|
|
231
|
-
annotatedThisSession.add(a.id);
|
|
232
|
-
|
|
233
|
-
const alert = makeSafeTaskAnnotation(touchedPath, a);
|
|
234
|
-
if (alert) {
|
|
235
|
-
const contents = [...(event.content || [])];
|
|
236
|
-
for (let i = contents.length - 1; i >= 0; i--) {
|
|
237
|
-
const item = contents[i];
|
|
238
|
-
if (item && item.type === 'text') {
|
|
239
|
-
contents[i] = { ...item, text: item.text + alert };
|
|
240
|
-
return { content: contents };
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
// 4. Session shutdown: Single-pass git inspection, one-tap settlement check & Exit Guard
|
|
249
|
-
pi.on('session_shutdown', async (event: any, ctx: ExtensionContext) => {
|
|
250
|
-
let gitStatus = '';
|
|
251
|
-
try {
|
|
252
|
-
gitStatus = execSync('git status --porcelain', {
|
|
253
|
-
cwd: ctx.cwd,
|
|
254
|
-
encoding: 'utf-8',
|
|
255
|
-
timeout: 2000,
|
|
256
|
-
stdio: ['ignore', 'pipe', 'ignore']
|
|
257
|
-
}).replace(/[\r\n]+$/, '');
|
|
258
|
-
const changed = gitStatus
|
|
259
|
-
.split(/\r?\n/)
|
|
260
|
-
.filter(Boolean)
|
|
261
|
-
.map((l: string) => l.slice(3).replace(/^"(.*)"$/, '$1').trim())
|
|
262
|
-
.filter(Boolean);
|
|
263
|
-
for (const f of changed) {
|
|
264
|
-
observer.addTouchedFile(f);
|
|
265
|
-
}
|
|
266
|
-
} catch {}
|
|
151
|
+
const notes = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
|
|
152
|
+
const matches = findMatchedAnchors(notes, [touchedPath]);
|
|
153
|
+
if (matches.length === 0) return;
|
|
267
154
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const activeAnchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active');
|
|
272
|
-
if (activeAnchors.length === 0) {
|
|
273
|
-
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
274
|
-
const pendingPath = path.join(home, '.anchor', 'pending_exit.json');
|
|
275
|
-
const data = {
|
|
276
|
-
cwd: ctx.cwd,
|
|
277
|
-
timestamp: Date.now(),
|
|
278
|
-
modifiedCount: gitStatus.split('\n').filter(Boolean).length
|
|
279
|
-
};
|
|
280
|
-
fs.writeFileSync(pendingPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
281
|
-
}
|
|
282
|
-
} catch {}
|
|
155
|
+
if (MUTATION_TOOLS.has(lowerTool)) {
|
|
156
|
+
for (const m of matches) store.touch(m.anchor.id);
|
|
157
|
+
updateAnchorStatusBar(ctx, store);
|
|
283
158
|
}
|
|
284
159
|
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const commitMatch = observer.matchesCommit(a);
|
|
297
|
-
if (commitMatch.matched) {
|
|
298
|
-
store.settle(a.id, {
|
|
299
|
-
settledBy: 'verification-test',
|
|
300
|
-
summary: `Auto-settled via Git commit: ${commitMatch.message}`,
|
|
301
|
-
touchedFiles: prop.matchedFiles
|
|
302
|
-
});
|
|
303
|
-
if (hasActiveUI(ctx)) {
|
|
304
|
-
try {
|
|
305
|
-
ctx.ui.notify(`⌖ Auto-settled #${a.id} via Git commit (run /anchor undo to revert)`, 'info');
|
|
306
|
-
} catch {}
|
|
307
|
-
}
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// 2. Automated physical verification command
|
|
312
|
-
if (a.verifyCommand) {
|
|
313
|
-
const verifyRes = runPhysicalVerification(a, ctx.cwd);
|
|
314
|
-
if (verifyRes.success) {
|
|
315
|
-
store.settle(a.id, {
|
|
316
|
-
settledBy: 'verification-test',
|
|
317
|
-
summary: `Automated test passed: ${a.verifyCommand}`,
|
|
318
|
-
touchedFiles: prop.matchedFiles
|
|
319
|
-
});
|
|
320
|
-
if (hasActiveUI(ctx)) {
|
|
321
|
-
try {
|
|
322
|
-
ctx.ui.notify(`⌖ Test passed (${a.verifyCommand}), auto-settled #${a.id}`, 'info');
|
|
323
|
-
} catch {}
|
|
160
|
+
const targetNote = matches[0].anchor;
|
|
161
|
+
if (!annotatedThisSession.has(targetNote.id)) {
|
|
162
|
+
annotatedThisSession.add(targetNote.id);
|
|
163
|
+
const whisper = makeSafeTaskAnnotation(touchedPath, targetNote);
|
|
164
|
+
if (whisper) {
|
|
165
|
+
const contents = [...(event.content || [])];
|
|
166
|
+
for (let i = contents.length - 1; i >= 0; i--) {
|
|
167
|
+
const item = contents[i];
|
|
168
|
+
if (item && item.type === 'text') {
|
|
169
|
+
contents[i] = { ...item, text: item.text + whisper };
|
|
170
|
+
return { content: contents };
|
|
324
171
|
}
|
|
325
|
-
continue;
|
|
326
172
|
}
|
|
327
173
|
}
|
|
328
|
-
|
|
329
|
-
// 3. Fallback: Prompt user for one-tap settlement on exit
|
|
330
|
-
if (!hasActiveUI(ctx)) continue;
|
|
331
|
-
|
|
332
|
-
try {
|
|
333
|
-
const ok = await ctx.ui.confirm(
|
|
334
|
-
'⌖ Settle Anchor Task',
|
|
335
|
-
`Task #${a.id} [${a.title}] touched files (${prop.matchedFiles.slice(0, 2).join(', ')}).\nMark as completed and archive?`
|
|
336
|
-
);
|
|
337
|
-
|
|
338
|
-
if (ok) {
|
|
339
|
-
store.settle(a.id, {
|
|
340
|
-
settledBy: 'one-tap-settlement',
|
|
341
|
-
touchedFiles: prop.matchedFiles
|
|
342
|
-
});
|
|
343
|
-
ctx.ui.notify(`⌖ Settled #${a.id}: "${a.title}"`, 'info');
|
|
344
|
-
}
|
|
345
|
-
} catch {}
|
|
346
174
|
}
|
|
347
175
|
});
|
|
348
176
|
|
|
349
177
|
// 5. Register LLM Tool: anchor
|
|
350
178
|
pi.registerTool({
|
|
351
179
|
name: 'anchor',
|
|
352
|
-
label: 'Anchor (
|
|
180
|
+
label: 'Anchor (Sticky Note)',
|
|
353
181
|
description:
|
|
354
|
-
'
|
|
355
|
-
promptSnippet: '
|
|
182
|
+
'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).',
|
|
183
|
+
promptSnippet: 'Pin or tear off cross-session sticky notes that survive terminal restarts',
|
|
356
184
|
promptGuidelines: [
|
|
357
|
-
'Use `anchor` when the user asks to
|
|
358
|
-
'BOUNDARY WITH TODO: `todo` is
|
|
359
|
-
'
|
|
360
|
-
'Tasks are automatically scoped to the current project context without cluttering the project git repository.',
|
|
361
|
-
'When code for an anchor is completed and verified, call `anchor` with action "settle" to archive it and free context.',
|
|
362
|
-
'Active anchors are automatically injected into future sessions in an ultra-compact block.'
|
|
185
|
+
'Use `anchor` when the user asks to remember something for later/tomorrow or across sessions (e.g. "记一下明天重构鉴权", "晚上清理缓存").',
|
|
186
|
+
'BOUNDARY WITH TODO: `todo` is for multi-step work right now in this session; `anchor` is a sticky note for future sessions.',
|
|
187
|
+
'When a pinned note is completed, call `anchor` with action "settle" to tear it off.'
|
|
363
188
|
],
|
|
364
189
|
parameters: Type.Object({
|
|
365
190
|
action: Type.Union([
|
|
@@ -369,105 +194,73 @@ export default function (pi: ExtensionAPI) {
|
|
|
369
194
|
Type.Literal('touch'),
|
|
370
195
|
Type.Literal('sweep')
|
|
371
196
|
]),
|
|
372
|
-
title: Type.Optional(Type.String({ description: '
|
|
373
|
-
description: Type.Optional(Type.String({ description: '
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
files: Type.Optional(Type.Array(Type.String(), { description: 'Associated file paths or directory prefixes' })),
|
|
378
|
-
tags: Type.Optional(Type.Array(Type.String(), { description: 'Domain tags' })),
|
|
379
|
-
verifyCommand: Type.Optional(Type.String({ description: 'Optional shell command for automated physical verification' })),
|
|
380
|
-
id: Type.Optional(Type.String({ description: 'Anchor ID, e.g. anc-1 (for settle or touch)' }))
|
|
197
|
+
title: Type.Optional(Type.String({ description: 'Sticky note text (for pin)' })),
|
|
198
|
+
description: Type.Optional(Type.String({ description: 'Optional extra details' })),
|
|
199
|
+
project: Type.Optional(Type.String({ description: 'Optional project name or folder' })),
|
|
200
|
+
files: Type.Optional(Type.Array(Type.String(), { description: 'Optional associated file paths' })),
|
|
201
|
+
id: Type.Optional(Type.String({ description: 'Note ID, e.g. anc-1 or 1 (for settle)' }))
|
|
381
202
|
}),
|
|
382
203
|
renderCall(args: any, theme: any) {
|
|
383
204
|
const action = args.action || 'list';
|
|
384
205
|
let title = theme.fg('toolTitle', theme.bold('⌖ anchor ')) + theme.fg('accent', action);
|
|
385
|
-
|
|
386
206
|
if (action === 'pin' && args.title) {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
: '';
|
|
391
|
-
title += ` "${args.title}"${due}${files}`;
|
|
392
|
-
} else if (action === 'settle' && args.id) {
|
|
393
|
-
title += ` ${theme.fg('dim', `#${args.id}`)}`;
|
|
394
|
-
} else if (action === 'touch' && args.id) {
|
|
395
|
-
title += ` ${theme.fg('dim', `#${args.id}`)}`;
|
|
396
|
-
} else if (action === 'list') {
|
|
397
|
-
if (args.priority) title += ` ${theme.fg('muted', `[${args.priority}]`)}`;
|
|
207
|
+
title += ` "${args.title}"`;
|
|
208
|
+
} else if ((action === 'settle' || action === 'touch') && args.id) {
|
|
209
|
+
title += ` ${theme.fg('dim', `#${String(args.id).replace(/^anc-/, '')}`)}`;
|
|
398
210
|
}
|
|
399
|
-
|
|
400
211
|
return {
|
|
401
212
|
render: (width: number) => [truncateToWidth(title, width)],
|
|
402
213
|
invalidate: () => {}
|
|
403
214
|
};
|
|
404
215
|
},
|
|
405
216
|
renderResult(result: any, _options: any, theme: any) {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
render: (width: number) => [truncateToWidth(theme.fg('error', `✗ ${errText}`), width)],
|
|
410
|
-
invalidate: () => {}
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const text = result.content?.[0]?.text || 'OK';
|
|
217
|
+
const text = result.content?.[0]?.text || (result.isError ? 'Failed' : 'OK');
|
|
218
|
+
const color = result.isError ? 'error' : 'success';
|
|
219
|
+
const icon = result.isError ? '✗' : '✔';
|
|
415
220
|
return {
|
|
416
|
-
render: (width: number) => [truncateToWidth(theme.fg(
|
|
221
|
+
render: (width: number) => [truncateToWidth(theme.fg(color, `${icon} ${text}`), width)],
|
|
417
222
|
invalidate: () => {}
|
|
418
223
|
};
|
|
419
224
|
},
|
|
420
225
|
async execute(_toolCallId: string, params: any, _signal?: AbortSignal, _onUpdate?: (partial: any) => void, ctx?: ExtensionContext) {
|
|
421
226
|
if (!ctx) return { content: [{ type: 'text', text: 'Error: context required' }], isError: true };
|
|
227
|
+
|
|
422
228
|
if (params.action === 'pin') {
|
|
423
229
|
if (!params.title) {
|
|
424
|
-
return { content: [{ type: 'text', text: 'Error: title is required for pin
|
|
230
|
+
return { content: [{ type: 'text', text: 'Error: title is required for pin' }], isError: true };
|
|
425
231
|
}
|
|
426
232
|
const anc = store.create({
|
|
427
233
|
title: params.title,
|
|
428
234
|
description: params.description,
|
|
429
|
-
priority: params.priority || 'p1',
|
|
430
235
|
project: params.project,
|
|
431
|
-
targetDate: params.targetDate,
|
|
432
236
|
cwd: ctx.cwd,
|
|
433
|
-
files: params.files || []
|
|
434
|
-
tags: params.tags || [],
|
|
435
|
-
verifyCommand: params.verifyCommand
|
|
237
|
+
files: params.files || []
|
|
436
238
|
});
|
|
437
239
|
updateAnchorStatusBar(ctx, store);
|
|
438
240
|
return {
|
|
439
|
-
content: [{
|
|
440
|
-
type: 'text',
|
|
441
|
-
text: `Successfully anchored task #${anc.id}: "${anc.title}" [${anc.project}]. Stored in ~/.anchor/.`
|
|
442
|
-
}],
|
|
241
|
+
content: [{ type: 'text', text: `Pinned note #${anc.id.replace(/^anc-/, '')}: "${anc.title}"` }],
|
|
443
242
|
isError: false
|
|
444
243
|
};
|
|
445
244
|
}
|
|
446
245
|
|
|
447
246
|
if (params.action === 'list') {
|
|
448
|
-
const
|
|
449
|
-
const sleeping = store.list({ status: 'sleeping', cwd: ctx.cwd });
|
|
247
|
+
const notes = store.list({ cwd: ctx.cwd, all: true }).filter(a => a.status === 'active' || a.status === 'sleeping');
|
|
450
248
|
return {
|
|
451
|
-
content: [{
|
|
452
|
-
type: 'text',
|
|
453
|
-
text: JSON.stringify({ active, sleeping }, null, 2)
|
|
454
|
-
}],
|
|
249
|
+
content: [{ type: 'text', text: JSON.stringify(notes, null, 2) }],
|
|
455
250
|
isError: false
|
|
456
251
|
};
|
|
457
252
|
}
|
|
458
253
|
|
|
459
254
|
if (params.action === 'settle') {
|
|
460
255
|
if (!params.id) {
|
|
461
|
-
return { content: [{ type: 'text', text: 'Error: id is required for settle
|
|
256
|
+
return { content: [{ type: 'text', text: 'Error: id is required for settle' }], isError: true };
|
|
462
257
|
}
|
|
463
258
|
try {
|
|
464
|
-
const
|
|
259
|
+
const targetId = resolveAnchorTarget(store, String(params.id), ctx.cwd) || String(params.id);
|
|
260
|
+
const settled = store.settle(targetId, { settledBy: 'manual' });
|
|
465
261
|
updateAnchorStatusBar(ctx, store);
|
|
466
262
|
return {
|
|
467
|
-
content: [{
|
|
468
|
-
type: 'text',
|
|
469
|
-
text: `Anchor #${settled.id} successfully settled and evicted from active context.`
|
|
470
|
-
}],
|
|
263
|
+
content: [{ type: 'text', text: `Torn off note #${settled.id.replace(/^anc-/, '')}: "${settled.title}"` }],
|
|
471
264
|
isError: false
|
|
472
265
|
};
|
|
473
266
|
} catch (err: any) {
|
|
@@ -475,123 +268,93 @@ export default function (pi: ExtensionAPI) {
|
|
|
475
268
|
}
|
|
476
269
|
}
|
|
477
270
|
|
|
478
|
-
if (params.action === 'touch') {
|
|
479
|
-
if (!params.id) {
|
|
480
|
-
return { content: [{ type: 'text', text: 'Error: id is required for touch action' }], isError: true };
|
|
481
|
-
}
|
|
271
|
+
if (params.action === 'touch' && params.id) {
|
|
482
272
|
try {
|
|
483
|
-
const touched = store.touch(params.id);
|
|
273
|
+
const touched = store.touch(String(params.id));
|
|
484
274
|
updateAnchorStatusBar(ctx, store);
|
|
485
|
-
return {
|
|
486
|
-
content: [{
|
|
487
|
-
type: 'text',
|
|
488
|
-
text: `Anchor #${touched.id} touched and decay timer refreshed.`
|
|
489
|
-
}],
|
|
490
|
-
isError: false
|
|
491
|
-
};
|
|
275
|
+
return { content: [{ type: 'text', text: `Refreshed note #${touched.id}` }], isError: false };
|
|
492
276
|
} catch (err: any) {
|
|
493
277
|
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
494
278
|
}
|
|
495
279
|
}
|
|
496
280
|
|
|
497
281
|
if (params.action === 'sweep') {
|
|
498
|
-
const res =
|
|
282
|
+
const res = sweepQuietNotes(store);
|
|
499
283
|
updateAnchorStatusBar(ctx, store);
|
|
500
|
-
return {
|
|
501
|
-
content: [{
|
|
502
|
-
type: 'text',
|
|
503
|
-
text: `Sweep complete: ${res.transitionedToSleeping.length} sleeping, ${res.evictedToGraveyard.length} evicted to graveyard.`
|
|
504
|
-
}],
|
|
505
|
-
isError: false
|
|
506
|
-
};
|
|
284
|
+
return { content: [{ type: 'text', text: `Quieted ${res.quieted.length} old note(s).` }], isError: false };
|
|
507
285
|
}
|
|
508
286
|
|
|
509
287
|
return { content: [{ type: 'text', text: 'Unknown action' }], isError: true };
|
|
510
288
|
}
|
|
511
289
|
});
|
|
512
290
|
|
|
513
|
-
// 6.
|
|
291
|
+
// 6. Slash Commands: /anchor, /pin, /done
|
|
514
292
|
pi.registerCommand('anchor', {
|
|
515
|
-
description: '
|
|
293
|
+
description: 'View, pin, or tear off sticky notes (/anchor, /anchor done 1, /anchor undo)',
|
|
516
294
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
517
295
|
const parsed = parseAnchorCommandInput(args);
|
|
518
296
|
|
|
519
|
-
// Case 1: View active anchors (clean list, enter to complete)
|
|
520
297
|
if (parsed.mode === 'dashboard') {
|
|
521
298
|
await openAnchorDashboard(ctx, store);
|
|
522
299
|
return;
|
|
523
300
|
}
|
|
524
301
|
|
|
525
|
-
// Case 2: Revert settlement
|
|
526
302
|
if (parsed.mode === 'undo') {
|
|
527
303
|
try {
|
|
528
304
|
const restored = store.undoSettle();
|
|
529
|
-
ctx.ui.notify(`⌖
|
|
305
|
+
ctx.ui.notify(`⌖ Put back #${restored.id.replace(/^anc-/, '')}: "${restored.title}"`, 'info');
|
|
530
306
|
updateAnchorStatusBar(ctx, store);
|
|
531
307
|
} catch (err: any) {
|
|
532
|
-
ctx.ui.notify(`
|
|
308
|
+
ctx.ui.notify(`Undo failed: ${err.message}`, 'error');
|
|
533
309
|
}
|
|
534
310
|
return;
|
|
535
311
|
}
|
|
536
312
|
|
|
537
|
-
// Case 3: Explicit settle subcommand (`/anchor done 1` or `/anchor done anc-1`)
|
|
538
313
|
if (parsed.mode === 'settle' && parsed.targetId) {
|
|
539
314
|
const resolvedId = resolveAnchorTarget(store, parsed.targetId, ctx.cwd);
|
|
540
315
|
if (!resolvedId) {
|
|
541
|
-
ctx.ui.notify(`⌖
|
|
316
|
+
ctx.ui.notify(`⌖ Note not found: ${parsed.targetId}`, 'error');
|
|
542
317
|
return;
|
|
543
318
|
}
|
|
544
|
-
const settled = store.settle(resolvedId, { settledBy: 'manual
|
|
545
|
-
ctx.ui.notify(`⌖
|
|
319
|
+
const settled = store.settle(resolvedId, { settledBy: 'manual' });
|
|
320
|
+
ctx.ui.notify(`⌖ Torn off #${settled.id.replace(/^anc-/, '')}: "${settled.title}" (/anchor undo to put back)`, 'info');
|
|
546
321
|
updateAnchorStatusBar(ctx, store);
|
|
547
322
|
return;
|
|
548
323
|
}
|
|
549
324
|
|
|
550
|
-
// Case 4: Direct ID completion if input matches an existing ID (e.g. `/anchor anc-1` or `/anchor #1`)
|
|
551
325
|
const rawTrimmed = (args || '').trim();
|
|
552
326
|
if (/^(?:#?anc-\d+|#\d+)$/i.test(rawTrimmed)) {
|
|
553
327
|
const resolvedId = resolveAnchorTarget(store, rawTrimmed, ctx.cwd);
|
|
554
328
|
if (resolvedId) {
|
|
555
|
-
const settled = store.settle(resolvedId, { settledBy: 'manual
|
|
556
|
-
ctx.ui.notify(`⌖
|
|
329
|
+
const settled = store.settle(resolvedId, { settledBy: 'manual' });
|
|
330
|
+
ctx.ui.notify(`⌖ Torn off #${settled.id.replace(/^anc-/, '')}: "${settled.title}" (/anchor undo to put back)`, 'info');
|
|
557
331
|
updateAnchorStatusBar(ctx, store);
|
|
558
332
|
return;
|
|
559
333
|
}
|
|
560
334
|
}
|
|
561
335
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
title: parsed.title || rawTrimmed,
|
|
565
|
-
targetDate: parsed.targetDate,
|
|
566
|
-
cwd: ctx.cwd
|
|
567
|
-
});
|
|
568
|
-
ctx.ui.notify(`⌖ Pinned #${anc.id}: "${anc.title}"${anc.targetDate ? ` (due ${anc.targetDate})` : ''}`, 'info');
|
|
336
|
+
const anc = store.create({ title: parsed.title || rawTrimmed, cwd: ctx.cwd });
|
|
337
|
+
ctx.ui.notify(`⌖ Pinned #${anc.id.replace(/^anc-/, '')}: "${anc.title}"`, 'info');
|
|
569
338
|
updateAnchorStatusBar(ctx, store);
|
|
570
339
|
}
|
|
571
340
|
});
|
|
572
341
|
|
|
573
|
-
// Alias /pin to instant-add (supports `--due <date>`)
|
|
574
342
|
pi.registerCommand('pin', {
|
|
575
|
-
description: '
|
|
343
|
+
description: 'Stick a quick note across sessions (e.g. /pin Check auth token expiry)',
|
|
576
344
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
577
|
-
const
|
|
578
|
-
if (
|
|
345
|
+
const text = (args || '').trim();
|
|
346
|
+
if (!text) {
|
|
579
347
|
await openAnchorDashboard(ctx, store);
|
|
580
348
|
return;
|
|
581
349
|
}
|
|
582
|
-
const anc = store.create({
|
|
583
|
-
|
|
584
|
-
targetDate: parsed.targetDate,
|
|
585
|
-
cwd: ctx.cwd
|
|
586
|
-
});
|
|
587
|
-
ctx.ui.notify(`⌖ Pinned #${anc.id}: "${anc.title}"${anc.targetDate ? ` (due ${anc.targetDate})` : ''}`, 'info');
|
|
350
|
+
const anc = store.create({ title: text, cwd: ctx.cwd });
|
|
351
|
+
ctx.ui.notify(`⌖ Pinned #${anc.id.replace(/^anc-/, '')}: "${anc.title}"`, 'info');
|
|
588
352
|
updateAnchorStatusBar(ctx, store);
|
|
589
353
|
}
|
|
590
354
|
});
|
|
591
355
|
|
|
592
|
-
// Alias /done for 1-step completion by display number or ID (e.g. `/done 1`)
|
|
593
356
|
pi.registerCommand('done', {
|
|
594
|
-
description: '
|
|
357
|
+
description: 'Tear off a sticky note by number or ID (e.g. /done 1)',
|
|
595
358
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
596
359
|
const target = (args || '').trim();
|
|
597
360
|
if (!target) {
|
|
@@ -600,18 +363,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
600
363
|
}
|
|
601
364
|
const resolvedId = resolveAnchorTarget(store, target, ctx.cwd);
|
|
602
365
|
if (!resolvedId) {
|
|
603
|
-
ctx.ui.notify(`⌖
|
|
366
|
+
ctx.ui.notify(`⌖ Note not found: ${target}`, 'error');
|
|
604
367
|
return;
|
|
605
368
|
}
|
|
606
|
-
const settled = store.settle(resolvedId, { settledBy: 'manual
|
|
607
|
-
ctx.ui.notify(`⌖
|
|
369
|
+
const settled = store.settle(resolvedId, { settledBy: 'manual' });
|
|
370
|
+
ctx.ui.notify(`⌖ Torn off #${settled.id.replace(/^anc-/, '')}: "${settled.title}" (/anchor undo to put back)`, 'info');
|
|
608
371
|
updateAnchorStatusBar(ctx, store);
|
|
609
372
|
}
|
|
610
373
|
});
|
|
611
374
|
|
|
612
|
-
// Alias /anchors to open dashboard
|
|
613
375
|
pi.registerCommand('anchors', {
|
|
614
|
-
description: '
|
|
376
|
+
description: 'View sticky notes',
|
|
615
377
|
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
616
378
|
await openAnchorDashboard(ctx, store);
|
|
617
379
|
}
|