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