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/tui.ts
CHANGED
|
@@ -1,80 +1,16 @@
|
|
|
1
1
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import { AnchorStore,
|
|
2
|
+
import { AnchorStore, isAnchorTargetingWorkspace } from './store.ts';
|
|
3
3
|
import type { Anchor } from './types.ts';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
|
-
export type AnchorQuadrant = 'Today' | 'Upcoming' | 'Habits' | 'Backlog';
|
|
7
|
-
|
|
8
|
-
export interface GroupedAnchors {
|
|
9
|
-
today: Anchor[];
|
|
10
|
-
upcoming: Anchor[];
|
|
11
|
-
habits: Anchor[];
|
|
12
|
-
backlog: Anchor[];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Classify anchor into one of four cognitive quadrants:
|
|
17
|
-
* - 'Today': Due today or overdue
|
|
18
|
-
* - 'Upcoming': Due in the future (tomorrow, in 2d, specific dates)
|
|
19
|
-
* - 'Habits': Daily recurring habits
|
|
20
|
-
* - 'Backlog': Open-ended long-term vision
|
|
21
|
-
*/
|
|
22
|
-
export function classifyAnchor(anchor: Anchor, now: number = Date.now()): AnchorQuadrant {
|
|
23
|
-
if (anchor.recurrence === 'daily') {
|
|
24
|
-
return 'Habits';
|
|
25
|
-
}
|
|
26
|
-
if (!anchor.targetDate) {
|
|
27
|
-
return 'Backlog';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const todayStr = getTodayDateString(now);
|
|
31
|
-
if (anchor.targetDate <= todayStr) {
|
|
32
|
-
return 'Today';
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return 'Upcoming';
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Group active anchors by cognitive quadrant
|
|
40
|
-
*/
|
|
41
|
-
export function groupAnchorsByQuadrant(anchors: Anchor[], now: number = Date.now()): GroupedAnchors {
|
|
42
|
-
const groups: GroupedAnchors = {
|
|
43
|
-
today: [],
|
|
44
|
-
upcoming: [],
|
|
45
|
-
habits: [],
|
|
46
|
-
backlog: []
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
for (const a of anchors) {
|
|
50
|
-
const q = classifyAnchor(a, now);
|
|
51
|
-
if (q === 'Today') groups.today.push(a);
|
|
52
|
-
else if (q === 'Upcoming') groups.upcoming.push(a);
|
|
53
|
-
else if (q === 'Habits') groups.habits.push(a);
|
|
54
|
-
else groups.backlog.push(a);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return groups;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
6
|
const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
61
7
|
|
|
62
|
-
/**
|
|
63
|
-
* Strip ANSI escape codes from string
|
|
64
|
-
*/
|
|
65
8
|
export function stripAnsi(str: string): string {
|
|
66
9
|
return str.replace(ANSI_REGEX, '');
|
|
67
10
|
}
|
|
68
11
|
|
|
69
12
|
/**
|
|
70
|
-
* Calculate
|
|
71
|
-
* - ANSI escape codes (width 0)
|
|
72
|
-
* - CJK ideographs & fullwidth forms (width 2)
|
|
73
|
-
* - Japanese Kana (width 2)
|
|
74
|
-
* - Korean Hangul syllables & Jamo (width 2)
|
|
75
|
-
* - Emojis & Pictographs (width 2)
|
|
76
|
-
* - Zero-width characters & joiners (width 0)
|
|
77
|
-
* - Standard ASCII (width 1)
|
|
13
|
+
* Calculate visual terminal column width (CJK/Kana/Hangul/Emoji = 2 cells, ASCII = 1 cell)
|
|
78
14
|
*/
|
|
79
15
|
export function getDisplayWidth(str: string): number {
|
|
80
16
|
const clean = stripAnsi(str);
|
|
@@ -82,8 +18,6 @@ export function getDisplayWidth(str: string): number {
|
|
|
82
18
|
|
|
83
19
|
for (const char of clean) {
|
|
84
20
|
const code = char.codePointAt(0) || 0;
|
|
85
|
-
|
|
86
|
-
// Zero-width characters (ZWJ, variation selectors, combining marks)
|
|
87
21
|
if (
|
|
88
22
|
code === 0x200d ||
|
|
89
23
|
code === 0xfe0f ||
|
|
@@ -94,7 +28,6 @@ export function getDisplayWidth(str: string): number {
|
|
|
94
28
|
continue;
|
|
95
29
|
}
|
|
96
30
|
|
|
97
|
-
// Double-width characters: CJK, Hangul, Kana, Emojis
|
|
98
31
|
if (
|
|
99
32
|
(code >= 0x4e00 && code <= 0x9fff) ||
|
|
100
33
|
(code >= 0x3400 && code <= 0x4dbf) ||
|
|
@@ -131,9 +64,6 @@ export function hasActiveUI(ctx: ExtensionContext | null | undefined): ctx is Ex
|
|
|
131
64
|
}
|
|
132
65
|
}
|
|
133
66
|
|
|
134
|
-
/**
|
|
135
|
-
* Truncate string to target terminal visual width with ellipsis
|
|
136
|
-
*/
|
|
137
67
|
export function truncateToWidth(str: string, maxWidth: number, ellipsis = '…'): string {
|
|
138
68
|
if (maxWidth <= 0) return '';
|
|
139
69
|
const singleLine = str.replace(/[\r\n\t]+/g, ' ');
|
|
@@ -162,23 +92,17 @@ export function truncateToWidth(str: string, maxWidth: number, ellipsis = '…')
|
|
|
162
92
|
const fullChar = singleLine.slice(i, i + charLen);
|
|
163
93
|
const charWidth = getDisplayWidth(fullChar);
|
|
164
94
|
|
|
165
|
-
if (accumWidth + charWidth > target)
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
95
|
+
if (accumWidth + charWidth > target) break;
|
|
168
96
|
accumulated += fullChar;
|
|
169
97
|
accumWidth += charWidth;
|
|
170
98
|
i += charLen;
|
|
171
99
|
}
|
|
172
100
|
|
|
173
|
-
// Invariant 11:
|
|
174
|
-
accumulated = accumulated.replace(/\s*[\(\[\{(【]
|
|
175
|
-
|
|
101
|
+
// Invariant 11: Strip any trailing unclosed parenthesis/bracket fragment right before ellipsis
|
|
102
|
+
accumulated = accumulated.replace(/\s*[\(\[\{(【][^)\]})】]*$/, '');
|
|
176
103
|
return accumulated + ellipsis + (singleLine.includes('\x1b') ? '\x1b[0m' : '');
|
|
177
104
|
}
|
|
178
105
|
|
|
179
|
-
/**
|
|
180
|
-
* Pad and/or truncate string to exact visual column width, eliminating column tearing
|
|
181
|
-
*/
|
|
182
106
|
export function padToWidth(str: string, targetWidth: number): string {
|
|
183
107
|
const truncated = truncateToWidth(str, targetWidth);
|
|
184
108
|
const current = getDisplayWidth(truncated);
|
|
@@ -187,139 +111,84 @@ export function padToWidth(str: string, targetWidth: number): string {
|
|
|
187
111
|
}
|
|
188
112
|
|
|
189
113
|
/**
|
|
190
|
-
* Format
|
|
191
|
-
* - 'Daily' for daily recurring habits
|
|
192
|
-
* - 'Today' if targetDate is today
|
|
193
|
-
* - 'Tomorrow' if targetDate is tomorrow
|
|
194
|
-
* - 'In 2d' / 'In 3d' for near-term dates
|
|
195
|
-
* - 'MM-DD' for future dates
|
|
196
|
-
* - 'Overdue' if targetDate is past
|
|
197
|
-
* - 'Someday' for open-ended architecture/long-term vision
|
|
114
|
+
* Format relative sticky-note age cleanly: 'just now', '15m ago', '3h ago', '2d ago'
|
|
198
115
|
*/
|
|
199
|
-
export function
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (anchor.targetDate === todayStr) {
|
|
209
|
-
return 'Today';
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const MS_DAY = 24 * 60 * 60 * 1000;
|
|
213
|
-
const tomorrowStr = getTodayDateString(now + MS_DAY);
|
|
214
|
-
if (anchor.targetDate === tomorrowStr) {
|
|
215
|
-
return 'Tomorrow';
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const in2dStr = getTodayDateString(now + 2 * MS_DAY);
|
|
219
|
-
if (anchor.targetDate === in2dStr) {
|
|
220
|
-
return 'In 2d';
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const in3dStr = getTodayDateString(now + 3 * MS_DAY);
|
|
224
|
-
if (anchor.targetDate === in3dStr) {
|
|
225
|
-
return 'In 3d';
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (anchor.targetDate < todayStr) {
|
|
229
|
-
return 'Overdue';
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const parts = anchor.targetDate.split('-');
|
|
233
|
-
if (parts.length === 3) {
|
|
234
|
-
return `${parts[1]}-${parts[2]}`;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return anchor.targetDate;
|
|
116
|
+
export function formatRelativeAge(timestamp: number, now: number = Date.now()): string {
|
|
117
|
+
const diffSec = Math.max(0, Math.floor((now - timestamp) / 1000));
|
|
118
|
+
if (diffSec < 60) return 'just now';
|
|
119
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
120
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
121
|
+
const diffHours = Math.floor(diffMin / 60);
|
|
122
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
123
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
124
|
+
return `${diffDays}d ago`;
|
|
238
125
|
}
|
|
239
126
|
|
|
240
127
|
/**
|
|
241
|
-
* Format
|
|
242
|
-
* - 'Today HH:MM' if created today
|
|
243
|
-
* - 'Yesterday HH:MM' if created yesterday
|
|
244
|
-
* - 'MM-DD HH:MM' if created this year
|
|
245
|
-
* - 'YYYY-MM-DD' if older
|
|
128
|
+
* Format workspace & first file context concisely (e.g. 'anchor · src/auth.ts' or 'College/优化')
|
|
246
129
|
*/
|
|
247
|
-
export function
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const pad = (n: number) => String(n).padStart(2, '0');
|
|
252
|
-
const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
253
|
-
|
|
254
|
-
const todayStr = getTodayDateString(now);
|
|
255
|
-
const createdDayStr = getTodayDateString(timestamp);
|
|
256
|
-
|
|
257
|
-
if (createdDayStr === todayStr) {
|
|
258
|
-
return `Today ${timeStr}`;
|
|
130
|
+
export function formatOrigin(anchor: Anchor | string): string {
|
|
131
|
+
if (typeof anchor === 'string') {
|
|
132
|
+
if (!anchor) return 'global';
|
|
133
|
+
return path.basename(anchor.replace(/\\/g, '/')) || 'global';
|
|
259
134
|
}
|
|
260
135
|
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
136
|
+
let proj = 'global';
|
|
137
|
+
if (anchor.project && anchor.project !== 'global') {
|
|
138
|
+
const normProj = anchor.project.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
139
|
+
if (normProj.includes('/')) {
|
|
140
|
+
const parts = normProj.split('/').filter(Boolean);
|
|
141
|
+
proj = parts.slice(-2).join('/');
|
|
142
|
+
} else {
|
|
143
|
+
proj = normProj;
|
|
144
|
+
}
|
|
145
|
+
} else if (anchor.cwd) {
|
|
146
|
+
proj = path.basename(anchor.cwd.replace(/\\/g, '/')) || 'global';
|
|
264
147
|
}
|
|
265
148
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
149
|
+
if (anchor.files && anchor.files.length > 0) {
|
|
150
|
+
const shortFile = path.basename(anchor.files[0].replace(/\\/g, '/'));
|
|
151
|
+
if (shortFile && !proj.endsWith(shortFile)) {
|
|
152
|
+
return `${proj} · ${shortFile}`;
|
|
153
|
+
}
|
|
269
154
|
}
|
|
270
|
-
|
|
271
|
-
return `${d.getFullYear()}-${mmdd}`;
|
|
155
|
+
return proj;
|
|
272
156
|
}
|
|
273
157
|
|
|
274
158
|
/**
|
|
275
|
-
*
|
|
159
|
+
* Sort sticky notes naturally:
|
|
160
|
+
* 1. Active notes before quiet (>7d untouched) notes
|
|
161
|
+
* 2. Notes belonging to current workspace first
|
|
162
|
+
* 3. Most recently touched first
|
|
276
163
|
*/
|
|
277
|
-
export function
|
|
278
|
-
return
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
* Format origin creation workspace folder (provenance: where the task was born/created)
|
|
290
|
-
*/
|
|
291
|
-
export function formatOrigin(anchor: Anchor | string): string {
|
|
292
|
-
if (typeof anchor === 'object') {
|
|
293
|
-
if (!anchor.cwd) return anchor.project || 'global';
|
|
294
|
-
return path.basename(anchor.cwd) || 'global';
|
|
295
|
-
}
|
|
296
|
-
if (!anchor) return 'global';
|
|
297
|
-
return path.basename(anchor) || 'global';
|
|
164
|
+
export function sortAnchorsForDisplay(anchors: Anchor[], cwd?: string): Anchor[] {
|
|
165
|
+
return [...anchors].sort((a, b) => {
|
|
166
|
+
if (a.status !== b.status) {
|
|
167
|
+
return a.status === 'active' ? -1 : 1;
|
|
168
|
+
}
|
|
169
|
+
if (cwd) {
|
|
170
|
+
const aMatch = isAnchorTargetingWorkspace(a, cwd) ? 0 : 1;
|
|
171
|
+
const bMatch = isAnchorTargetingWorkspace(b, cwd) ? 0 : 1;
|
|
172
|
+
if (aMatch !== bMatch) return aMatch - bMatch;
|
|
173
|
+
}
|
|
174
|
+
return b.lastTouchedAt - a.lastTouchedAt;
|
|
175
|
+
});
|
|
298
176
|
}
|
|
299
177
|
|
|
300
178
|
/**
|
|
301
|
-
* Update
|
|
179
|
+
* Update status bar badge: `⌖ 2` (invisible when 0 active notes)
|
|
302
180
|
*/
|
|
303
181
|
export function updateAnchorStatusBar(ctx: ExtensionContext, store: AnchorStore): void {
|
|
304
182
|
if (!hasActiveUI(ctx)) return;
|
|
305
|
-
|
|
306
183
|
try {
|
|
307
184
|
const active = store.list({ status: 'active', cwd: ctx.cwd });
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
ctx.ui.setStatus('anchor', `⌖ ${active.length}`);
|
|
314
|
-
} catch {
|
|
315
|
-
// Ignore stale context errors during session replacement
|
|
316
|
-
}
|
|
185
|
+
ctx.ui.setStatus('anchor', active.length > 0 ? `⌖ ${active.length}` : undefined);
|
|
186
|
+
} catch {}
|
|
317
187
|
}
|
|
318
188
|
|
|
319
189
|
/**
|
|
320
|
-
* Render
|
|
321
|
-
*
|
|
322
|
-
* Automatically cleared on agent_start (when user sends first message).
|
|
190
|
+
* Render clean sticky-note card above editor on session startup.
|
|
191
|
+
* Automatically disappears as soon as the user sends their first message.
|
|
323
192
|
*/
|
|
324
193
|
export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore): void {
|
|
325
194
|
if (!hasActiveUI(ctx)) return;
|
|
@@ -331,30 +200,34 @@ export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore):
|
|
|
331
200
|
return;
|
|
332
201
|
}
|
|
333
202
|
|
|
334
|
-
const
|
|
335
|
-
const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
|
|
203
|
+
const sorted = sortAnchorsForDisplay(active, ctx.cwd);
|
|
336
204
|
const topItems = sorted.slice(0, 3);
|
|
337
205
|
|
|
338
206
|
const factory = (_tui?: any, theme?: any) => ({
|
|
339
207
|
render: (width: number) => {
|
|
340
|
-
const maxTitleWidth = Math.max(
|
|
208
|
+
const maxTitleWidth = Math.max(14, width - 34);
|
|
341
209
|
const fg = theme?.fg ? theme.fg.bind(theme) : (_c: string, text: string) => text;
|
|
210
|
+
const label = active.length === 1 ? '1 note' : `${active.length} notes`;
|
|
342
211
|
|
|
343
212
|
const lines: string[] = [
|
|
344
|
-
truncateToWidth(fg('accent', `⌖
|
|
213
|
+
truncateToWidth(fg('accent', `⌖ ${label}`), width)
|
|
345
214
|
];
|
|
346
215
|
|
|
347
|
-
for (
|
|
348
|
-
const a = topItems[i];
|
|
216
|
+
for (const a of topItems) {
|
|
349
217
|
const num = a.id.replace(/^anc-/, '').padStart(2, '0');
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
lines.push(
|
|
218
|
+
const origin = formatOrigin(a);
|
|
219
|
+
const age = formatRelativeAge(a.createdAt);
|
|
220
|
+
const titleClipped = truncateToWidth(a.title, maxTitleWidth);
|
|
221
|
+
lines.push(
|
|
222
|
+
truncateToWidth(
|
|
223
|
+
` ${fg('dim', num)} ${titleClipped} ${fg('muted', `· ${origin}`)} ${fg('dim', `(${age})`)}`,
|
|
224
|
+
width
|
|
225
|
+
)
|
|
226
|
+
);
|
|
354
227
|
}
|
|
355
228
|
|
|
356
229
|
if (sorted.length > 3) {
|
|
357
|
-
lines.push(truncateToWidth(fg('dim', `
|
|
230
|
+
lines.push(truncateToWidth(fg('dim', ` +${sorted.length - 3} more · /anchor`), width));
|
|
358
231
|
}
|
|
359
232
|
|
|
360
233
|
return lines;
|
|
@@ -362,102 +235,82 @@ export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore):
|
|
|
362
235
|
invalidate: () => {}
|
|
363
236
|
});
|
|
364
237
|
|
|
365
|
-
// Reactive component factory in real TUI mode, static string[] in mock tests
|
|
366
238
|
const content = (ctx as any).ui?.theme ? factory : factory().render(80);
|
|
367
|
-
|
|
368
239
|
ctx.ui.setWidget('anchor-startup', content as any, { placement: 'aboveEditor' });
|
|
369
|
-
} catch {
|
|
370
|
-
// Ignore stale context errors during session replacement
|
|
371
|
-
}
|
|
240
|
+
} catch {}
|
|
372
241
|
}
|
|
373
242
|
|
|
374
243
|
/**
|
|
375
|
-
*
|
|
244
|
+
* Interactive `/anchor` sticky-note board
|
|
376
245
|
*/
|
|
377
|
-
export async function openAnchorDashboard(
|
|
378
|
-
ctx: ExtensionContext,
|
|
379
|
-
store: AnchorStore
|
|
380
|
-
): Promise<void> {
|
|
246
|
+
export async function openAnchorDashboard(ctx: ExtensionContext, store: AnchorStore): Promise<void> {
|
|
381
247
|
if (!hasActiveUI(ctx)) return;
|
|
382
248
|
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
249
|
+
const allNotes = store
|
|
250
|
+
.list({ cwd: ctx.cwd, all: true })
|
|
251
|
+
.filter(a => a.status === 'active' || a.status === 'sleeping');
|
|
252
|
+
|
|
253
|
+
if (allNotes.length === 0) {
|
|
254
|
+
ctx.ui.notify('⌖ No sticky notes. Use /pin <note> to stick one.', 'info');
|
|
386
255
|
return;
|
|
387
256
|
}
|
|
388
257
|
|
|
389
|
-
const
|
|
390
|
-
const sortedList = [
|
|
391
|
-
...groups.today,
|
|
392
|
-
...groups.upcoming,
|
|
393
|
-
...groups.habits,
|
|
394
|
-
...groups.backlog
|
|
395
|
-
];
|
|
396
|
-
|
|
258
|
+
const sorted = sortAnchorsForDisplay(allNotes, ctx.cwd);
|
|
397
259
|
const optionMap = new Map<string, Anchor>();
|
|
398
260
|
const displayOptions: string[] = [];
|
|
399
261
|
const termCols = process.stdout?.columns || 96;
|
|
400
|
-
const isNarrow = termCols <
|
|
401
|
-
const titleWidth = isNarrow ? Math.max(
|
|
262
|
+
const isNarrow = termCols < 84;
|
|
263
|
+
const titleWidth = isNarrow ? Math.max(20, termCols - 30) : 42;
|
|
402
264
|
|
|
403
|
-
for (
|
|
404
|
-
const a = sortedList[i];
|
|
265
|
+
for (const a of sorted) {
|
|
405
266
|
const num = a.id.replace(/^anc-/, '').padStart(2, '0');
|
|
406
|
-
const category = padToWidth(`[${classifyAnchor(a)}]`, 12);
|
|
407
267
|
const origin = formatOrigin(a);
|
|
408
|
-
const
|
|
409
|
-
|
|
268
|
+
const age = a.status === 'sleeping'
|
|
269
|
+
? `${formatRelativeAge(a.createdAt)} · quiet`
|
|
270
|
+
: formatRelativeAge(a.createdAt);
|
|
410
271
|
|
|
411
|
-
const titleWithFiles = (a.files && a.files.length > 0)
|
|
412
|
-
? `${a.title} [${a.files.slice(0, 1).join(', ')}]`
|
|
413
|
-
: a.title;
|
|
414
|
-
|
|
415
|
-
// Clean tabular column alignment: ID, Category, Title, Project, Target, Created
|
|
416
272
|
const colNum = `${num} `;
|
|
417
|
-
const colTitle = padToWidth(
|
|
418
|
-
const colOrigin = padToWidth(origin,
|
|
419
|
-
const colTarget = padToWidth(target, 12);
|
|
420
|
-
const colCreated = created;
|
|
273
|
+
const colTitle = padToWidth(a.title, titleWidth);
|
|
274
|
+
const colOrigin = padToWidth(origin, 22);
|
|
421
275
|
|
|
422
276
|
const label = isNarrow
|
|
423
|
-
? `${colNum}${
|
|
424
|
-
: `${colNum}${
|
|
277
|
+
? `${colNum}${colTitle} ${age}`.trimEnd()
|
|
278
|
+
: `${colNum}${colTitle} ${colOrigin} ${age}`.trimEnd();
|
|
425
279
|
|
|
426
280
|
optionMap.set(label, a);
|
|
427
281
|
displayOptions.push(label);
|
|
428
282
|
}
|
|
429
283
|
|
|
430
|
-
const selected = await ctx.ui.select('⌖
|
|
284
|
+
const selected = await ctx.ui.select('⌖ Notes (select a note):', displayOptions);
|
|
431
285
|
if (!selected) return;
|
|
432
286
|
|
|
433
287
|
const anchor = optionMap.get(selected);
|
|
434
288
|
if (!anchor) return;
|
|
435
289
|
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
[
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
);
|
|
290
|
+
const actions = [
|
|
291
|
+
'✓ Done (tear off note)',
|
|
292
|
+
...(anchor.status === 'sleeping' ? ['⚡ Wake up (mark active)'] : []),
|
|
293
|
+
'ℹ Read full note',
|
|
294
|
+
'↩ Back'
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
const action = await ctx.ui.select(`⌖ #${anchor.id.replace(/^anc-/, '')}: "${anchor.title}"`, actions);
|
|
444
298
|
if (!action || action.startsWith('↩')) return;
|
|
445
299
|
|
|
446
300
|
if (action.startsWith('✓')) {
|
|
447
|
-
store.settle(anchor.id, { settledBy: 'manual
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
301
|
+
store.settle(anchor.id, { settledBy: 'manual' });
|
|
302
|
+
ctx.ui.notify(`⌖ Torn off #${anchor.id.replace(/^anc-/, '')}: "${anchor.title}" (/anchor undo to put back)`, 'info');
|
|
303
|
+
updateAnchorStatusBar(ctx, store);
|
|
304
|
+
} else if (action.startsWith('⚡')) {
|
|
305
|
+
store.touch(anchor.id);
|
|
306
|
+
ctx.ui.notify(`⌖ Woke up #${anchor.id.replace(/^anc-/, '')}: "${anchor.title}"`, 'info');
|
|
452
307
|
updateAnchorStatusBar(ctx, store);
|
|
453
308
|
} else if (action.startsWith('ℹ')) {
|
|
454
309
|
const details = [
|
|
455
|
-
|
|
456
|
-
anchor.description ? `
|
|
457
|
-
anchor.
|
|
458
|
-
|
|
459
|
-
anchor.verifyCommand ? `Verify Command: ${anchor.verifyCommand}` : null,
|
|
460
|
-
`Created: ${formatCreationTime(anchor.createdAt)} (in ${anchor.project || 'global'})`
|
|
310
|
+
`⌖ Note #${anchor.id.replace(/^anc-/, '')}: ${anchor.title}`,
|
|
311
|
+
anchor.description ? `Details: ${anchor.description}` : null,
|
|
312
|
+
anchor.files.length > 0 ? `Files: ${anchor.files.join(', ')}` : null,
|
|
313
|
+
`Workspace: ${anchor.project || 'global'} · Pinned ${formatRelativeAge(anchor.createdAt)}`
|
|
461
314
|
].filter(Boolean).join('\n');
|
|
462
315
|
ctx.ui.notify(details, 'info');
|
|
463
316
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,67 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Anchor Data Contracts
|
|
3
|
-
*
|
|
2
|
+
* Anchor Data Contracts
|
|
3
|
+
* Minimalist cross-session sticky notes stored in ~/.anchor/state.json
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export type AnchorStatus = 'active' | 'sleeping' | 'settled'
|
|
6
|
+
export type AnchorStatus = 'active' | 'sleeping' | 'settled';
|
|
7
7
|
export type AnchorPriority = 'p0' | 'p1' | 'p2';
|
|
8
|
-
export type AnchorDurability = 'ephemeral' | 'durable';
|
|
9
|
-
|
|
10
|
-
export interface AnchorDecayPolicy {
|
|
11
|
-
/** Days before an untouched active anchor transitions to sleeping */
|
|
12
|
-
activeDays: number;
|
|
13
|
-
/** Days an anchor remains sleeping before dropping to graveyard */
|
|
14
|
-
sleepDays: number;
|
|
15
|
-
/** Total maximum days before permanent graveyard sweep (9999 = never) */
|
|
16
|
-
graveyardDays: number;
|
|
17
|
-
}
|
|
18
8
|
|
|
19
9
|
export interface AnchorEvidence {
|
|
20
10
|
settledAt?: number;
|
|
21
|
-
settledBy?: 'one-tap-settlement' | 'manual-command' | 'verification-test';
|
|
11
|
+
settledBy?: 'commit' | 'manual' | 'one-tap-settlement' | 'manual-command' | 'verification-test';
|
|
22
12
|
commitHash?: string;
|
|
23
13
|
summary?: string;
|
|
24
14
|
touchedFiles?: string[];
|
|
25
15
|
}
|
|
26
16
|
|
|
27
17
|
export interface Anchor {
|
|
28
|
-
/**
|
|
18
|
+
/** Sequential identifier, e.g. 'anc-1' */
|
|
29
19
|
id: string;
|
|
30
|
-
/**
|
|
20
|
+
/** Sticky note text */
|
|
31
21
|
title: string;
|
|
32
|
-
/** Optional
|
|
22
|
+
/** Optional extra context */
|
|
33
23
|
description?: string;
|
|
34
|
-
/**
|
|
35
|
-
priority: AnchorPriority;
|
|
36
|
-
/** Current lifecycle status */
|
|
24
|
+
/** Lifecycle status: 'active' (recent) or 'sleeping' (>7d untouched, 0 prompt tokens) */
|
|
37
25
|
status: AnchorStatus;
|
|
38
|
-
/**
|
|
39
|
-
durability: AnchorDurability;
|
|
40
|
-
/** Canonical project name (e.g. 'PPT', 'X', or 'global') */
|
|
26
|
+
/** Short workspace label (e.g. 'anchor', 'College/优化', or 'global') */
|
|
41
27
|
project: string;
|
|
42
|
-
/**
|
|
28
|
+
/** Workspace directory path where the note applies */
|
|
43
29
|
cwd: string;
|
|
44
|
-
/** Creation timestamp
|
|
30
|
+
/** Creation timestamp (ms) */
|
|
45
31
|
createdAt: number;
|
|
46
|
-
/** Last update timestamp
|
|
32
|
+
/** Last update timestamp (ms) */
|
|
47
33
|
updatedAt: number;
|
|
48
|
-
/** Last
|
|
34
|
+
/** Last activity timestamp (ms) */
|
|
49
35
|
lastTouchedAt: number;
|
|
50
|
-
/** Associated file paths or globs
|
|
36
|
+
/** Associated file paths or globs (explicit or auto-extracted from note text) */
|
|
51
37
|
files: string[];
|
|
52
|
-
/**
|
|
53
|
-
targetDate?: string;
|
|
54
|
-
/** Optional shell command for automated physical verification (e.g. 'npm test') */
|
|
55
|
-
verifyCommand?: string;
|
|
56
|
-
/** Topic or domain tags, e.g. ['auth', 'security'] */
|
|
38
|
+
/** Optional tags */
|
|
57
39
|
tags: string[];
|
|
58
|
-
/** Optional
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
decay: AnchorDecayPolicy;
|
|
64
|
-
/** Settlement evidence metadata upon closure */
|
|
40
|
+
/** Optional due date ('YYYY-MM-DD') */
|
|
41
|
+
targetDate?: string;
|
|
42
|
+
/** Optional priority kept for backward compatibility */
|
|
43
|
+
priority?: AnchorPriority;
|
|
44
|
+
/** Settlement metadata when archived */
|
|
65
45
|
evidence?: AnchorEvidence;
|
|
66
46
|
}
|
|
67
47
|
|
|
@@ -76,30 +56,9 @@ export interface TouchMatchResult {
|
|
|
76
56
|
anchor: Anchor;
|
|
77
57
|
score: number;
|
|
78
58
|
matchedFiles: string[];
|
|
79
|
-
reason: 'exact-file' | 'dir-prefix'
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface SettlementProposal {
|
|
83
|
-
anchor: Anchor;
|
|
84
|
-
matchedFiles: string[];
|
|
85
|
-
reason: string;
|
|
86
|
-
recommendedAction: 'settle' | 'defer';
|
|
59
|
+
reason: 'exact-file' | 'dir-prefix';
|
|
87
60
|
}
|
|
88
61
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
graveyardDays: 2 // 48 hours total before auto-clearing
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
export const DURABLE_DECAY_POLICY: AnchorDecayPolicy = {
|
|
96
|
-
activeDays: 3,
|
|
97
|
-
sleepDays: 30,
|
|
98
|
-
graveyardDays: 9999 // Never permanently auto-dropped; preserved indefinitely
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
export const DEFAULT_DECAY_POLICY: AnchorDecayPolicy = {
|
|
102
|
-
activeDays: 3,
|
|
103
|
-
sleepDays: 7,
|
|
104
|
-
graveyardDays: 14
|
|
105
|
-
};
|
|
62
|
+
/** 7 days without activity moves a note to quiet mode (still visible in UI, 0 prompt tokens in unrelated workspaces) */
|
|
63
|
+
export const QUIET_AFTER_DAYS = 7;
|
|
64
|
+
export const MS_PER_DAY = 86_400_000;
|