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/src/store.ts CHANGED
@@ -1,198 +1,303 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import type {
5
- Anchor,
6
- AnchorDecayPolicy,
7
- AnchorDurability,
8
- AnchorEvidence,
9
- AnchorPriority,
10
- AnchorStatus,
11
- AnchorStoreState
4
+ import {
5
+ type Anchor,
6
+ type AnchorEvidence,
7
+ type AnchorPriority,
8
+ type AnchorStatus,
9
+ type AnchorStoreState,
10
+ type TouchMatchResult,
11
+ QUIET_AFTER_DAYS,
12
+ MS_PER_DAY
12
13
  } from './types.ts';
13
- import { DEFAULT_DECAY_POLICY, DURABLE_DECAY_POLICY, EPHEMERAL_DECAY_POLICY } from './types.ts';
14
- import { normalizePath, isSameOrSubPath, extractPathsFromText, isAnchorTargetingWorkspace } from './matcher.ts';
15
14
 
16
15
  /**
17
- * Detect whether a task is a short-term ephemeral reminder or a long-term architectural vision
16
+ * Normalize path separators to forward slashes and strip baseCwd prefix when inside baseCwd
18
17
  */
19
- export function detectDurability(title: string): AnchorDurability {
20
- const temporalKeywords = [
21
- '今天', '今日', '晚上', '今晚', '明天', '后天', '大后天', '稍后', '待会', '下午', '上午', '临时', '一会儿', '等等',
22
- 'today', 'tonight', 'tomorrow', 'later', 'temp', 'soon'
23
- ];
24
- const lower = title.toLowerCase();
25
- for (const kw of temporalKeywords) {
26
- if (lower.includes(kw)) {
27
- return 'ephemeral';
18
+ export function normalizePath(filePath: string, baseCwd?: string): string {
19
+ if (typeof filePath !== 'string') return '';
20
+ let norm = filePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
21
+ if (baseCwd && (path.isAbsolute(filePath) || norm.startsWith('/') || /^[a-zA-Z]:\//.test(norm))) {
22
+ const normBase = baseCwd.trim().replace(/\\/g, '/').replace(/\/+$/, '');
23
+ const lowerNorm = norm.toLowerCase();
24
+ const lowerBase = normBase.toLowerCase();
25
+ if (lowerNorm === lowerBase) {
26
+ norm = '';
27
+ } else if (lowerNorm.startsWith(lowerBase + '/')) {
28
+ norm = norm.slice(normBase.length + 1).replace(/^\/+/, '');
28
29
  }
29
30
  }
30
- return 'durable';
31
+ return norm;
31
32
  }
32
33
 
33
34
  /**
34
- * Get custom ephemeral decay policy based on temporal span.
35
- * Note: graveyardDays is always 9999 so unfinished tasks are NEVER silently deleted.
35
+ * Safe boundary-aware directory comparison (case-insensitive on Windows)
36
36
  */
37
- export function getEphemeralDecayPolicy(title: string): AnchorDecayPolicy {
38
- const lower = title.toLowerCase();
39
- if (lower.includes('大后天')) {
40
- return { activeDays: 7, sleepDays: 30, graveyardDays: 9999 };
41
- }
42
- if (lower.includes('后天')) {
43
- return { activeDays: 7, sleepDays: 30, graveyardDays: 9999 };
44
- }
45
- return { ...EPHEMERAL_DECAY_POLICY };
37
+ export function isSameOrSubPath(dirA?: string, dirB?: string): boolean {
38
+ if (!dirA || !dirB) return false;
39
+ const normA = dirA.trim().replace(/\\/g, '/').replace(/\/+$/, '');
40
+ const normB = dirB.trim().replace(/\\/g, '/').replace(/\/+$/, '');
41
+ const isWin = process.platform === 'win32' || /^[a-zA-Z]:\//.test(normA) || /^[a-zA-Z]:\//.test(normB);
42
+ const a = isWin ? normA.toLowerCase() : normA;
43
+ const b = isWin ? normB.toLowerCase() : normB;
44
+ return a === b || b.startsWith(a + '/') || a.startsWith(b + '/');
46
45
  }
47
46
 
48
47
  /**
49
- * Detect expected completion date from title keywords or explicit date string, returning 'YYYY-MM-DD'
48
+ * Convert simple glob pattern (*.ts, src/**\/*.ts) to safe RegExp
50
49
  */
51
- export function detectTargetDate(titleOrDate: string, now: number = Date.now()): string | undefined {
52
- const lower = titleOrDate.toLowerCase().trim();
53
- const MS_DAY = 24 * 60 * 60 * 1000;
50
+ export function globToRegExp(pattern: string): RegExp {
51
+ const collapsed = pattern
52
+ .slice(0, 512)
53
+ .replace(/\/+(\*\*\/+)+/g, '/**/')
54
+ .replace(/\*{3,}/g, '**');
55
+ const escaped = collapsed
56
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
57
+ .replace(/\/\*\*\//g, '___GLOB_DIR_SLASH___')
58
+ .replace(/\*\*/g, '___GLOB_STAR_STAR___')
59
+ .replace(/\*/g, '[^/]*')
60
+ .replace(/___GLOB_DIR_SLASH___/g, '(?:/|/(?:[^/]+/)+)')
61
+ .replace(/___GLOB_STAR_STAR___/g, '.*');
62
+ return new RegExp(`^${escaped}$`);
63
+ }
54
64
 
55
- if (lower.includes('今天') || lower.includes('今日') || lower.includes('今晚') || lower === 'today' || lower === 'tonight') {
56
- return getTodayDateString(now);
65
+ /**
66
+ * Clever Touch 1: Automatically spot file or directory paths mentioned inside a sticky note
67
+ * (e.g. "src/auth.ts", "D:/College/优化/notes.md") so the user never has to fill out forms.
68
+ */
69
+ export function extractPathsFromText(text?: string): string[] {
70
+ if (!text) return [];
71
+ const results = new Set<string>();
72
+ const regex = /(?:[a-zA-Z]:[\\/][^\s"'`,。;!?、,;)>\]]+|(?:\b(?:src|lib|dist|tests|test|docs|scripts|packages|extensions|bin|assets)[\\/][^\s"'`,。;!?、,;)>\]]+)|(?:\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|c|cpp|h|cs|md|json|yaml|yml|toml|html|css|sql|sh)\b))/g;
73
+ let match: RegExpExecArray | null;
74
+ while ((match = regex.exec(text)) !== null) {
75
+ const cleaned = normalizePath(match[0].replace(/[.:;,'")\]}>]+$/, ''));
76
+ if (cleaned && cleaned.length >= 3) {
77
+ results.add(cleaned);
78
+ }
57
79
  }
58
- if (lower.includes('明天') || lower === 'tomorrow') {
59
- return getTodayDateString(now + MS_DAY);
80
+ return Array.from(results);
81
+ }
82
+
83
+ /**
84
+ * Check if a sticky note belongs to or targets the given workspace directory
85
+ */
86
+ export function isAnchorTargetingWorkspace(anchor: Anchor, targetCwd?: string): boolean {
87
+ if (!targetCwd) return true;
88
+ const normTarget = targetCwd.trim().replace(/\\/g, '/').replace(/\/+$/, '');
89
+ const targetBase = path.basename(normTarget).toLowerCase();
90
+
91
+ if (anchor.project && anchor.project !== 'global') {
92
+ const normProj = anchor.project.trim().replace(/\\/g, '/').replace(/\/+$/, '');
93
+ if (isSameOrSubPath(normProj, normTarget)) return true;
94
+ if (path.basename(normProj).toLowerCase() === targetBase) return true;
60
95
  }
61
- if (lower.includes('后天')) {
62
- return getTodayDateString(now + 2 * MS_DAY);
96
+
97
+ if (anchor.files && anchor.files.length > 0) {
98
+ for (const f of anchor.files) {
99
+ if (isSameOrSubPath(f, normTarget)) return true;
100
+ }
63
101
  }
64
- if (lower.includes('大后天')) {
65
- return getTodayDateString(now + 3 * MS_DAY);
102
+
103
+ if (anchor.cwd && isSameOrSubPath(anchor.cwd, normTarget)) {
104
+ return true;
66
105
  }
67
106
 
68
- // Weekdays (e.g. 周五, 星期五, 下周一, friday)
69
- const weekdayMap: Record<string, number> = {
70
- '周日': 0, '星期日': 0, 'sunday': 0, 'sun': 0,
71
- '周一': 1, '星期一': 1, 'monday': 1, 'mon': 1,
72
- '周二': 2, '星期二': 2, 'tuesday': 2, 'tue': 2,
73
- '周三': 3, '星期三': 3, 'wednesday': 3, 'wed': 3,
74
- '周四': 4, '星期四': 4, 'thursday': 4, 'thu': 4,
75
- '周五': 5, '星期五': 5, 'friday': 5, 'fri': 5,
76
- '周六': 6, '星期六': 6, 'saturday': 6, 'sat': 6,
77
- };
78
-
79
- const isNextWeek = lower.includes('下周') || lower.includes('next');
80
- for (const [name, targetDay] of Object.entries(weekdayMap)) {
81
- if (lower.includes(name)) {
82
- const curDay = new Date(now).getDay();
83
- let diff = targetDay - curDay;
84
- if (diff <= 0 || isNextWeek) {
85
- diff += 7;
107
+ return false;
108
+ }
109
+
110
+ /**
111
+ * Match touched files against an anchor's file patterns
112
+ */
113
+ export function matchAnchorAgainstTouchedFiles(anchor: Anchor, touchedFiles: string[]): TouchMatchResult | null {
114
+ if (!anchor.files || anchor.files.length === 0) return null;
115
+ const normTouched = touchedFiles.map(f => normalizePath(f, anchor.cwd)).filter(Boolean);
116
+ if (normTouched.length === 0) return null;
117
+
118
+ const matchedExact: string[] = [];
119
+ const matchedPrefix: string[] = [];
120
+
121
+ for (const touched of normTouched) {
122
+ for (const pattern of anchor.files) {
123
+ const normPattern = normalizePath(pattern, anchor.cwd);
124
+ if (normPattern === touched) {
125
+ matchedExact.push(touched);
126
+ break;
127
+ }
128
+ if (normPattern.includes('*') && globToRegExp(normPattern).test(touched)) {
129
+ matchedExact.push(touched);
130
+ break;
131
+ }
132
+ const dirPrefix = normPattern.endsWith('/') ? normPattern : normPattern + '/';
133
+ if (touched.startsWith(dirPrefix)) {
134
+ matchedPrefix.push(touched);
135
+ break;
86
136
  }
87
- return getTodayDateString(now + diff * MS_DAY);
88
137
  }
89
138
  }
90
139
 
91
- const fullDateMatch = titleOrDate.match(/\b(20\d\d)-(\d{1,2})-(\d{1,2})\b/);
92
- if (fullDateMatch) {
93
- const y = fullDateMatch[1];
94
- const m = fullDateMatch[2].padStart(2, '0');
95
- const d = fullDateMatch[3].padStart(2, '0');
96
- return `${y}-${m}-${d}`;
140
+ if (matchedExact.length > 0) {
141
+ return { anchor, score: 1.0, matchedFiles: Array.from(new Set(matchedExact)), reason: 'exact-file' };
97
142
  }
98
-
99
- const mmddMatch = titleOrDate.match(/\b(\d{1,2})-(\d{1,2})\b/);
100
- if (mmddMatch) {
101
- const curYear = new Date(now).getFullYear();
102
- const m = mmddMatch[1].padStart(2, '0');
103
- const d = mmddMatch[2].padStart(2, '0');
104
- return `${curYear}-${m}-${d}`;
143
+ if (matchedPrefix.length > 0) {
144
+ return { anchor, score: 0.85, matchedFiles: Array.from(new Set(matchedPrefix)), reason: 'dir-prefix' };
105
145
  }
106
-
107
- return undefined;
146
+ return null;
108
147
  }
109
148
 
110
- /**
111
- * Detect recurring patterns, e.g. daily habits
112
- */
113
- export function detectRecurrence(title: string): 'daily' | undefined {
114
- const lower = title.toLowerCase();
115
- if (lower.includes('每天') || lower.includes('每日') || lower.includes('daily') || lower.includes('every day')) {
116
- return 'daily';
149
+ export function findMatchedAnchors(anchors: Anchor[], touchedFiles: string[]): TouchMatchResult[] {
150
+ const results: TouchMatchResult[] = [];
151
+ for (const anchor of anchors) {
152
+ const m = matchAnchorAgainstTouchedFiles(anchor, touchedFiles);
153
+ if (m) results.push(m);
117
154
  }
118
- return undefined;
155
+ return results.sort((a, b) => b.score - a.score);
119
156
  }
120
157
 
121
158
  /**
122
- * Get current date string 'YYYY-MM-DD' in local timezone
159
+ * Clever Touch 2: Syntax-safe 1-line comment whisper when the AI reads/edits a matching file.
160
+ * Strictly skips JSON, ENV, lockfiles, and binaries so parsers are never broken.
123
161
  */
124
- export function getTodayDateString(timestamp: number = Date.now()): string {
125
- const d = new Date(timestamp);
126
- const year = d.getFullYear();
127
- const month = String(d.getMonth() + 1).padStart(2, '0');
128
- const day = String(d.getDate()).padStart(2, '0');
129
- return `${year}-${month}-${day}`;
162
+ export const COMMENT_FORMATS: Record<string, (msg: string) => string> = {
163
+ '.ts': m => `// ${m}`, '.tsx': m => `// ${m}`, '.js': m => `// ${m}`, '.jsx': m => `// ${m}`,
164
+ '.mjs': m => `// ${m}`, '.cjs': m => `// ${m}`, '.go': m => `// ${m}`, '.rs': m => `// ${m}`,
165
+ '.c': m => `// ${m}`, '.cpp': m => `// ${m}`, '.h': m => `// ${m}`, '.cs': m => `// ${m}`,
166
+ '.java': m => `// ${m}`, '.kt': m => `// ${m}`, '.swift': m => `// ${m}`, '.dart': m => `// ${m}`,
167
+ '.py': m => `# ${m}`, '.rb': m => `# ${m}`, '.sh': m => `# ${m}`, '.bash': m => `# ${m}`,
168
+ '.yaml': m => `# ${m}`, '.yml': m => `# ${m}`, '.toml': m => `# ${m}`, '.ps1': m => `# ${m}`,
169
+ '.html': m => `<!-- ${m} -->`, '.xml': m => `<!-- ${m} -->`, '.svg': m => `<!-- ${m} -->`,
170
+ '.vue': m => `<!-- ${m} -->`, '.svelte': m => `<!-- ${m} -->`,
171
+ '.css': m => `/* ${m} */`, '.scss': m => `/* ${m} */`,
172
+ '.sql': m => `-- ${m}`, '.lua': m => `-- ${m}`
173
+ };
174
+
175
+ export function makeSafeTaskAnnotation(filePath: string, anchor: Anchor): string | null {
176
+ const ext = path.extname(filePath).toLowerCase();
177
+ const fmt = COMMENT_FORMATS[ext];
178
+ if (!fmt) return null;
179
+ const num = anchor.id.replace(/^anc-/, '');
180
+ return '\n\n' + fmt(`⌖ Note #${num}: ${anchor.title}`);
130
181
  }
131
182
 
132
183
  /**
133
- * Resolve the default storage directory for Anchor (~/.anchor).
134
- * Hierarchy:
135
- * 1. Process environment: ANCHOR_DIR
136
- * 2. Canonical user-global store: ~/.anchor/
137
- * 3. Legacy Pi harness fallback: ~/.pi/agent/anchors/ (migrates automatically if present)
184
+ * Clever Touch 3: Match a `git commit -m "..."` message against a sticky note (supports CJK & English)
138
185
  */
139
- export function getDefaultStorageDir(): string {
140
- if (process.env.ANCHOR_DIR) {
141
- return path.resolve(process.env.ANCHOR_DIR);
142
- }
143
-
144
- const primaryDir = path.join(os.homedir(), '.anchor');
145
- const legacyPiDir = path.join(os.homedir(), '.pi', 'agent', 'anchors');
186
+ export function matchesCommitMessage(anchor: Anchor, commitMessages: string[]): { matched: boolean; message?: string } {
187
+ const idLower = anchor.id.toLowerCase();
188
+ const numId = `#${anchor.id.replace(/^anc-/, '')}`;
189
+ const title = anchor.title.toLowerCase();
146
190
 
147
- // Automatic seamless migration from legacy Pi directory if present
148
- if (!fs.existsSync(primaryDir) && fs.existsSync(legacyPiDir)) {
191
+ let titleTokens: string[] = [];
192
+ if (typeof Intl !== 'undefined' && (Intl as any).Segmenter) {
149
193
  try {
150
- fs.mkdirSync(primaryDir, { recursive: true });
151
- for (const file of ['state.json', 'archive.jsonl', 'graveyard.jsonl']) {
152
- const src = path.join(legacyPiDir, file);
153
- const dest = path.join(primaryDir, file);
154
- if (fs.existsSync(src) && !fs.existsSync(dest)) {
155
- fs.copyFileSync(src, dest);
156
- }
194
+ const seg = new (Intl as any).Segmenter('und', { granularity: 'word' });
195
+ titleTokens = Array.from(seg.segment(title))
196
+ .filter((s: any) => s.isWordLike)
197
+ .map((s: any) => s.segment.trim().toLowerCase())
198
+ .filter((t: string) => t.length >= 2);
199
+ } catch {
200
+ titleTokens = title.split(/[\s,._\-\/]+/).filter(t => t.length >= 2);
201
+ }
202
+ } else {
203
+ titleTokens = title.split(/[\s,._\-\/]+/).filter(t => t.length >= 2);
204
+ }
205
+
206
+ for (const msg of commitMessages) {
207
+ const msgLower = msg.toLowerCase();
208
+ if (msgLower.includes(idLower) || msgLower.includes(numId)) {
209
+ return { matched: true, message: msg };
210
+ }
211
+ if (titleTokens.length > 0) {
212
+ const hits = titleTokens.filter(tok => msgLower.includes(tok)).length;
213
+ if (hits >= Math.min(2, titleTokens.length)) {
214
+ return { matched: true, message: msg };
157
215
  }
158
- } catch {}
216
+ }
159
217
  }
218
+ return { matched: false };
219
+ }
160
220
 
161
- return primaryDir;
221
+ /**
222
+ * Render ultra-compact Turn-1 sticky note context for the AI.
223
+ * Turn 2+ consumes 0 tokens.
224
+ */
225
+ export function renderColdStartAnchorsContext(
226
+ store: AnchorStore,
227
+ cwdOrNow?: string | number,
228
+ nowArg?: number
229
+ ): string {
230
+ let cwd: string | undefined;
231
+ let now = Date.now();
232
+ if (typeof cwdOrNow === 'number') now = cwdOrNow;
233
+ else if (typeof cwdOrNow === 'string') cwd = cwdOrNow;
234
+ if (typeof nowArg === 'number') now = nowArg;
235
+
236
+ sweepQuietNotes(store, now);
237
+
238
+ const activeNotes = store.list({ status: 'active', cwd });
239
+ if (activeNotes.length === 0) return '';
240
+
241
+ const lines = activeNotes.map(a => {
242
+ const num = a.id.replace(/^anc-/, '');
243
+ const files = a.files.length > 0 ? ` (${a.files.slice(0, 2).join(', ')})` : '';
244
+ const desc = a.description ? ` — ${a.description}` : '';
245
+ return `• #${num} [${a.project}] ${a.title}${files}${desc}`;
246
+ });
247
+
248
+ return [
249
+ `<sticky-notes count="${activeNotes.length}">`,
250
+ `Pinned notes from previous sessions:`,
251
+ lines.join('\n'),
252
+ `When a note is completed, call anchor(action="settle", id="...") to tear it off.`,
253
+ `</sticky-notes>`
254
+ ].join('\n');
162
255
  }
163
256
 
257
+ export const renderActiveAnchorsContext = renderColdStartAnchorsContext;
258
+
164
259
  /**
165
- * Synchronous sleep for spin-retry delay
260
+ * Transition notes untouched for >7 days to 'sleeping' (quiet mode) so they don't burn Turn-1 tokens
261
+ * in unrelated sessions, while NEVER deleting them.
166
262
  */
263
+ export function sweepQuietNotes(store: AnchorStore, now: number = Date.now()): { quieted: string[] } {
264
+ return store.withLock(() => {
265
+ const state = store.loadState();
266
+ const quieted: string[] = [];
267
+ for (const a of state.anchors) {
268
+ if (a.status === 'active' && (now - a.lastTouchedAt) > QUIET_AFTER_DAYS * MS_PER_DAY) {
269
+ a.status = 'sleeping';
270
+ a.updatedAt = now;
271
+ quieted.push(a.id);
272
+ }
273
+ }
274
+ if (quieted.length > 0) {
275
+ state.lastSweepAt = now;
276
+ store.saveState(state);
277
+ }
278
+ return { quieted };
279
+ });
280
+ }
281
+
282
+ export function getDefaultStorageDir(): string {
283
+ if (process.env.ANCHOR_DIR) return path.resolve(process.env.ANCHOR_DIR);
284
+ return path.join(os.homedir(), '.anchor');
285
+ }
286
+
167
287
  function sleepSync(ms: number): void {
168
288
  const end = Date.now() + ms;
169
289
  while (Date.now() < end) {}
170
290
  }
171
291
 
172
- interface LockMetadata {
173
- pid: number;
174
- createdAt: number;
175
- }
176
-
177
- /**
178
- * Check if a process is still alive on Windows/POSIX
179
- */
180
292
  function isProcessAlive(pid: number): boolean {
181
293
  try {
182
294
  process.kill(pid, 0);
183
295
  return true;
184
296
  } catch (err: any) {
185
- return err.code === 'EPERM'; // Alive but permission denied
297
+ return err.code === 'EPERM';
186
298
  }
187
299
  }
188
300
 
189
- /**
190
- * Acquire exclusive lockfile using atomic 'wx' file creation (zero external dependencies).
191
- * Features:
192
- * - 0 async infection (100% synchronous)
193
- * - Automatic stale lock eviction (>5000ms or dead PID)
194
- * - Exponential backoff with jitter
195
- */
196
301
  export function acquireSyncLock(lockPath: string, maxWaitMs = 1500, staleTimeoutMs = 5000): () => void {
197
302
  const start = Date.now();
198
303
  let attempt = 0;
@@ -201,98 +306,76 @@ export function acquireSyncLock(lockPath: string, maxWaitMs = 1500, staleTimeout
201
306
  attempt++;
202
307
  try {
203
308
  const fd = fs.openSync(lockPath, 'wx');
204
- const meta: LockMetadata = { pid: process.pid, createdAt: Date.now() };
205
- fs.writeFileSync(fd, JSON.stringify(meta), 'utf-8');
309
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: Date.now() }), 'utf-8');
206
310
  fs.closeSync(fd);
207
-
208
311
  return () => {
209
312
  try {
210
- if (fs.existsSync(lockPath)) {
211
- fs.unlinkSync(lockPath);
212
- }
313
+ if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
213
314
  } catch {}
214
315
  };
215
316
  } catch (err: any) {
216
317
  if (err.code === 'EEXIST') {
217
318
  try {
218
- const content = fs.readFileSync(lockPath, 'utf-8');
219
- const meta: LockMetadata = JSON.parse(content);
319
+ const meta = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
220
320
  const isStale = (Date.now() - meta.createdAt) > staleTimeoutMs;
221
321
  const isDead = meta.pid && !isProcessAlive(meta.pid);
222
-
223
322
  if (isStale || isDead) {
224
- try {
225
- fs.unlinkSync(lockPath);
226
- continue;
227
- } catch {}
323
+ try { fs.unlinkSync(lockPath); continue; } catch {}
228
324
  }
229
- } catch {
230
- // If corrupt or in-flight write, wait for next attempt
231
- }
232
-
233
- const delay = Math.min(40, Math.floor(attempt * 4 + Math.random() * 8));
234
- sleepSync(delay);
325
+ } catch {}
326
+ sleepSync(Math.min(40, Math.floor(attempt * 4 + Math.random() * 8)));
235
327
  continue;
236
328
  }
237
329
  break;
238
330
  }
239
331
  }
240
-
241
- // Fallback release if lock acquisition timed out
242
332
  return () => {};
243
333
  }
244
334
 
245
- /**
246
- * Windows-tolerant atomic rename with exponential spin-retry to combat NTFS EBUSY/EPERM file locks
247
- */
248
- export function atomicRenameWithRetry(tempPath: string, targetPath: string, maxAttempts = 6): void {
249
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
335
+ export function atomicRenameWithRetry(tempPath: string, targetPath: string, maxRetries = 5): void {
336
+ let lastErr: any;
337
+ for (let i = 0; i < maxRetries; i++) {
250
338
  try {
251
339
  fs.renameSync(tempPath, targetPath);
252
340
  return;
253
341
  } catch (err: any) {
254
- const code = err?.code;
255
- if ((code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') && attempt < maxAttempts) {
256
- const delay = Math.floor(attempt * 20 + Math.random() * 15);
257
- sleepSync(delay);
342
+ lastErr = err;
343
+ if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY') {
344
+ sleepSync(15 * (i + 1));
258
345
  continue;
259
346
  }
260
- // On exhaustion, preserve crash dump rather than wiping uncommitted state
261
- const crashDump = `${tempPath}.failed`;
262
- try {
263
- if (fs.existsSync(tempPath)) fs.renameSync(tempPath, crashDump);
264
- } catch {}
265
- throw err;
347
+ break;
266
348
  }
267
349
  }
350
+ try {
351
+ fs.copyFileSync(tempPath, targetPath);
352
+ fs.unlinkSync(tempPath);
353
+ } catch {
354
+ throw lastErr;
355
+ }
268
356
  }
269
357
 
270
358
  export class AnchorStore {
271
359
  public readonly storageDir: string;
272
360
  public readonly statePath: string;
361
+ public readonly lockPath: string;
273
362
  public readonly archivePath: string;
274
- public readonly graveyardPath: string;
275
363
 
276
- constructor(customStorageDir?: string) {
277
- if (customStorageDir) {
278
- this.storageDir = path.resolve(customStorageDir);
279
- } else {
280
- this.storageDir = getDefaultStorageDir();
281
- }
364
+ constructor(storageDir?: string) {
365
+ this.storageDir = storageDir ? path.resolve(storageDir) : getDefaultStorageDir();
282
366
  this.statePath = path.join(this.storageDir, 'state.json');
367
+ this.lockPath = path.join(this.storageDir, 'state.lock');
283
368
  this.archivePath = path.join(this.storageDir, 'archive.jsonl');
284
- this.graveyardPath = path.join(this.storageDir, 'graveyard.jsonl');
285
- this.ensureDirs();
286
369
  }
287
370
 
288
- public get lockPath(): string {
289
- return path.join(this.storageDir, 'state.lock');
371
+ private ensureDirs(): void {
372
+ if (!fs.existsSync(this.storageDir)) {
373
+ fs.mkdirSync(this.storageDir, { recursive: true });
374
+ }
290
375
  }
291
376
 
292
- /**
293
- * Execute mutation within a cross-process exclusive lock
294
- */
295
377
  public withLock<T>(fn: () => T): T {
378
+ this.ensureDirs();
296
379
  const release = acquireSyncLock(this.lockPath);
297
380
  try {
298
381
  return fn();
@@ -301,82 +384,28 @@ export class AnchorStore {
301
384
  }
302
385
  }
303
386
 
304
- private ensureDirs(): void {
305
- if (!fs.existsSync(this.storageDir)) {
306
- try {
307
- fs.mkdirSync(this.storageDir, { recursive: true });
308
- } catch {}
309
- }
310
- }
311
-
312
- /**
313
- * Load store state. If corrupt or missing, returns safe default.
314
- */
315
387
  public loadState(): AnchorStoreState {
316
388
  this.ensureDirs();
317
389
  if (!fs.existsSync(this.statePath)) {
318
- return {
319
- version: 1,
320
- anchors: [],
321
- lastSweepAt: Date.now()
322
- };
323
- }
324
-
325
- let raw: string | null = null;
326
- for (let attempt = 1; attempt <= 3; attempt++) {
327
- try {
328
- raw = fs.readFileSync(this.statePath, 'utf-8');
329
- break;
330
- } catch (err: any) {
331
- if ((err?.code === 'EBUSY' || err?.code === 'EPERM') && attempt < 3) {
332
- sleepSync(15);
333
- continue;
334
- }
335
- }
336
- }
337
-
338
- if (raw === null) {
339
- return { version: 1, anchors: [], lastSweepAt: Date.now() };
390
+ return { version: 1, anchors: [], lastSweepAt: Date.now(), nextId: 1 };
340
391
  }
341
-
342
392
  try {
343
- const data = JSON.parse(raw);
344
- if (!data || typeof data !== 'object' || !Array.isArray(data.anchors)) {
345
- throw new Error('Invalid state: anchors must be array');
393
+ const parsed = JSON.parse(fs.readFileSync(this.statePath, 'utf-8'));
394
+ if (!parsed || !Array.isArray(parsed.anchors)) {
395
+ return { version: 1, anchors: [], lastSweepAt: Date.now(), nextId: 1 };
346
396
  }
347
- data.anchors = data.anchors
348
- .filter((a: any) => a && typeof a === 'object' && typeof a.id === 'string' && typeof a.title === 'string')
349
- .map((a: any) => ({
350
- ...a,
351
- files: Array.isArray(a.files) ? a.files.filter((f: unknown) => typeof f === 'string') : [],
352
- tags: Array.isArray(a.tags) ? a.tags.filter((t: unknown) => typeof t === 'string') : [],
353
- decay: {
354
- activeDays: Number.isFinite(a.decay?.activeDays) && a.decay.activeDays > 0 ? a.decay.activeDays : DURABLE_DECAY_POLICY.activeDays,
355
- sleepDays: Number.isFinite(a.decay?.sleepDays) && a.decay.sleepDays > 0 ? a.decay.sleepDays : DURABLE_DECAY_POLICY.sleepDays,
356
- graveyardDays: Number.isFinite(a.decay?.graveyardDays) && a.decay.graveyardDays > 0 ? a.decay.graveyardDays : DURABLE_DECAY_POLICY.graveyardDays,
357
- },
358
- }));
359
- return data as AnchorStoreState;
397
+ return parsed;
360
398
  } catch {
361
- const backupPath = path.join(this.storageDir, `state.corrupt.${Date.now()}.json`);
362
399
  try {
363
- fs.renameSync(this.statePath, backupPath);
400
+ fs.copyFileSync(this.statePath, path.join(this.storageDir, `state.corrupt.${Date.now()}.json`));
364
401
  } catch {}
365
- return {
366
- version: 1,
367
- anchors: [],
368
- lastSweepAt: Date.now()
369
- };
402
+ return { version: 1, anchors: [], lastSweepAt: Date.now(), nextId: 1 };
370
403
  }
371
404
  }
372
405
 
373
- /**
374
- * Atomically save store state via temp file + atomic rename with Windows NTFS spin-retry
375
- */
376
406
  public saveState(state: AnchorStoreState): void {
377
407
  this.ensureDirs();
378
- const rand = Math.random().toString(36).slice(2, 7);
379
- const tempPath = path.join(this.storageDir, `state.tmp.${process.pid}.${Date.now()}.${rand}`);
408
+ const tempPath = `${this.statePath}.${process.pid}.${Date.now()}.tmp`;
380
409
  fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), 'utf-8');
381
410
  atomicRenameWithRetry(tempPath, this.statePath);
382
411
  }
@@ -384,8 +413,8 @@ export class AnchorStore {
384
413
  private generateId(state: AnchorStoreState): string {
385
414
  const numbers = state.anchors
386
415
  .map(a => {
387
- const match = a.id.match(/^anc-(\d+)$/);
388
- return match ? parseInt(match[1], 10) : 0;
416
+ const m = a.id.match(/^anc-(\d+)$/);
417
+ return m ? parseInt(m[1], 10) : 0;
389
418
  })
390
419
  .filter(n => n > 0);
391
420
  const maxExisting = numbers.length > 0 ? Math.max(...numbers) : 0;
@@ -395,21 +424,16 @@ export class AnchorStore {
395
424
  }
396
425
 
397
426
  /**
398
- * Create a new Anchor with automatic project and cwd tagging
427
+ * Pin a new sticky note
399
428
  */
400
429
  public create(input: {
401
430
  title: string;
402
431
  description?: string;
403
432
  priority?: AnchorPriority;
404
- durability?: AnchorDurability;
405
- recurrence?: 'daily';
406
433
  cwd?: string;
407
434
  project?: string;
408
- targetDate?: string;
409
435
  files?: string[];
410
436
  tags?: string[];
411
- verifyCommand?: string;
412
- decay?: AnchorDecayPolicy;
413
437
  }): Anchor {
414
438
  return this.withLock(() => {
415
439
  const state = this.loadState();
@@ -422,46 +446,30 @@ export class AnchorStore {
422
446
  : extractPathsFromText(`${input.title} ${input.description || ''}`);
423
447
 
424
448
  let cwd = input.cwd ? normalizePath(input.cwd) : '';
425
- // If project is an explicit absolute path (e.g. "D:/College/优化"), align cwd to it so workspace matching is never split
426
449
  if (input.project && (/^[a-zA-Z]:[\\/]/.test(input.project.trim()) || input.project.trim().startsWith('/'))) {
427
450
  cwd = normalizePath(input.project.trim());
428
451
  } else if (inferredFiles.length > 0 && (/^[a-zA-Z]:\//.test(inferredFiles[0]) || inferredFiles[0].startsWith('/'))) {
429
452
  const firstDir = path.dirname(inferredFiles[0]);
430
- if (firstDir && firstDir !== '.') {
431
- cwd = normalizePath(firstDir);
432
- }
453
+ if (firstDir && firstDir !== '.') cwd = normalizePath(firstDir);
433
454
  }
434
455
 
435
456
  const projectName = input.project
436
457
  ? input.project.trim()
437
458
  : (cwd ? path.basename(cwd) : 'global');
438
459
 
439
- const recurrence = input.recurrence || detectRecurrence(input.title);
440
- const targetDate = input.targetDate || detectTargetDate(input.title, now);
441
- // Recurring tasks are always durable
442
- const durability = recurrence ? 'durable' : (input.durability || detectDurability(input.title));
443
- const decayPolicy = durability === 'ephemeral'
444
- ? getEphemeralDecayPolicy(input.title)
445
- : DURABLE_DECAY_POLICY;
446
-
447
460
  const anchor: Anchor = {
448
461
  id,
449
462
  title: input.title.trim(),
450
463
  description: input.description?.trim(),
451
464
  priority: input.priority || 'p1',
452
465
  status: 'active',
453
- durability,
454
- recurrence,
455
- targetDate,
456
466
  project: projectName,
457
467
  cwd,
458
468
  createdAt: now,
459
469
  updatedAt: now,
460
470
  lastTouchedAt: now,
461
471
  files: inferredFiles,
462
- tags: (input.tags || []).map(t => t.trim()).filter(Boolean),
463
- verifyCommand: input.verifyCommand?.trim() || undefined,
464
- decay: input.decay ? { ...input.decay } : { ...decayPolicy }
472
+ tags: (input.tags || []).map(t => t.trim()).filter(Boolean)
465
473
  };
466
474
 
467
475
  state.anchors.push(anchor);
@@ -475,94 +483,60 @@ export class AnchorStore {
475
483
  return anchors.findIndex(a =>
476
484
  a.id === clean ||
477
485
  a.id === `anc-${clean}` ||
478
- (clean.match(/^\d+$/) ? a.id === `anc-${parseInt(clean, 10)}` : false)
486
+ (/^\d+$/.test(clean) ? a.id === `anc-${parseInt(clean, 10)}` : false)
479
487
  );
480
488
  }
481
489
 
482
- /**
483
- * Get an anchor by ID (supports 'anc-1', '#anc-1', '1', '01')
484
- */
485
490
  public get(id: string): Anchor | undefined {
486
491
  const state = this.loadState();
487
492
  const idx = this.findAnchorIndex(state.anchors, id);
488
493
  return idx === -1 ? undefined : state.anchors[idx];
489
494
  }
490
495
 
491
- /**
492
- * List anchors. By default, if cwd is provided, only returns anchors
493
- * belonging to this cwd or marked as global (''). Pass all: true for global view.
494
- */
495
496
  public list(filter?: {
496
497
  status?: AnchorStatus;
497
- priority?: AnchorPriority;
498
498
  cwd?: string;
499
499
  all?: boolean;
500
- now?: number;
501
500
  }): Anchor[] {
502
501
  const state = this.loadState();
503
502
  const targetCwd = filter?.cwd ? normalizePath(filter.cwd) : null;
504
- const today = getTodayDateString(filter?.now);
505
503
 
506
504
  return state.anchors.filter(a => {
507
- // Recurring task completed today is hidden from active list
508
- if (!filter?.all && filter?.status === 'active' && a.recurrence === 'daily' && a.lastCompletedDate === today) {
509
- return false;
510
- }
511
-
512
505
  if (filter?.status && a.status !== filter.status) return false;
513
- if (filter?.priority && a.priority !== filter.priority) return false;
514
-
515
- // Project context filtering:
516
- // If an anchor has specific code files attached, match against cwd, project path/name, or file paths.
517
- // If an anchor has NO code files attached (general tasks, memos), it is universal and visible everywhere!
518
506
  if (!filter?.all && targetCwd) {
519
507
  if (a.files.length > 0 && (a.cwd || a.project) && !isAnchorTargetingWorkspace(a, targetCwd)) {
520
508
  return false;
521
509
  }
522
510
  }
523
-
524
511
  return true;
525
512
  });
526
513
  }
527
514
 
528
- /**
529
- * Update an existing anchor
530
- */
531
515
  public update(id: string, patch: Partial<Omit<Anchor, 'id' | 'createdAt'>>): Anchor {
532
516
  return this.withLock(() => {
533
517
  const state = this.loadState();
534
518
  const idx = this.findAnchorIndex(state.anchors, id);
535
- if (idx === -1) {
536
- throw new Error(`Anchor not found: ${id}`);
537
- }
519
+ if (idx === -1) throw new Error(`Note not found: ${id}`);
538
520
 
539
- const current = state.anchors[idx];
540
521
  const updated: Anchor = {
541
- ...current,
522
+ ...state.anchors[idx],
542
523
  ...patch,
543
524
  updatedAt: Date.now()
544
525
  };
545
-
546
526
  if (patch.files) {
547
527
  updated.files = patch.files.map(f => normalizePath(f)).filter(Boolean);
548
528
  }
549
-
550
529
  state.anchors[idx] = updated;
551
530
  this.saveState(state);
552
531
  return updated;
553
532
  });
554
533
  }
555
534
 
556
- /**
557
- * Touch an anchor to refresh its decay window and reactivate sleeping state
558
- */
559
535
  public touch(id: string, timestamp: number = Date.now()): Anchor {
560
536
  return this.withLock(() => {
561
537
  const state = this.loadState();
562
538
  const idx = this.findAnchorIndex(state.anchors, id);
563
- if (idx === -1) {
564
- throw new Error(`Anchor not found: ${id}`);
565
- }
539
+ if (idx === -1) throw new Error(`Note not found: ${id}`);
566
540
 
567
541
  const current = state.anchors[idx];
568
542
  current.lastTouchedAt = timestamp;
@@ -570,7 +544,6 @@ export class AnchorStore {
570
544
  if (current.status === 'sleeping') {
571
545
  current.status = 'active';
572
546
  }
573
-
574
547
  state.anchors[idx] = current;
575
548
  this.saveState(state);
576
549
  return current;
@@ -578,44 +551,21 @@ export class AnchorStore {
578
551
  }
579
552
 
580
553
  /**
581
- * Settle an anchor (recurring daily habits complete for today and wake up tomorrow)
554
+ * Tear off (settle) a completed sticky note and archive it to archive.jsonl
582
555
  */
583
556
  public settle(id: string, evidence: AnchorEvidence = {}, now: number = Date.now()): Anchor {
584
557
  return this.withLock(() => {
585
558
  const state = this.loadState();
586
559
  const idx = this.findAnchorIndex(state.anchors, id);
587
- if (idx === -1) {
588
- throw new Error(`Anchor not found: ${id}`);
589
- }
590
-
591
- const anchor = state.anchors[idx];
592
- const today = getTodayDateString(now);
593
-
594
- // If it's a recurring daily habit:
595
- if (anchor.recurrence === 'daily') {
596
- anchor.lastCompletedDate = today;
597
- anchor.lastTouchedAt = now;
598
- anchor.updatedAt = now;
599
- anchor.evidence = {
600
- ...evidence,
601
- settledAt: now,
602
- settledBy: evidence.settledBy || 'manual-command'
603
- };
604
-
605
- this.ensureDirs();
606
- fs.appendFileSync(this.archivePath, JSON.stringify({ ...anchor, settledForDate: today }) + '\n', 'utf-8');
607
- this.saveState(state);
608
- return anchor;
609
- }
560
+ if (idx === -1) throw new Error(`Note not found: ${id}`);
610
561
 
611
- // Normal one-off task: remove from state and archive
612
562
  const [settled] = state.anchors.splice(idx, 1);
613
563
  settled.status = 'settled';
614
564
  settled.updatedAt = now;
615
565
  settled.evidence = {
616
566
  ...evidence,
617
567
  settledAt: now,
618
- settledBy: evidence.settledBy || 'manual-command'
568
+ settledBy: evidence.settledBy || 'manual'
619
569
  };
620
570
 
621
571
  this.ensureDirs();
@@ -626,14 +576,13 @@ export class AnchorStore {
626
576
  }
627
577
 
628
578
  /**
629
- * Reverse/undo the last settled anchor, popping it from archive.jsonl back into state.json
579
+ * Put back (undo) the last torn-off sticky note
630
580
  */
631
581
  public undoSettle(): Anchor {
632
582
  return this.withLock(() => {
633
583
  if (!fs.existsSync(this.archivePath)) {
634
- throw new Error('No archived anchors to undo');
584
+ throw new Error('No archived notes to restore');
635
585
  }
636
-
637
586
  const raw = fs.readFileSync(this.archivePath, 'utf-8');
638
587
  const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
639
588
  let anchor: Anchor | null = null;
@@ -641,29 +590,22 @@ export class AnchorStore {
641
590
  const candidate = lines.pop()!;
642
591
  try {
643
592
  const parsed = JSON.parse(candidate);
644
- if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string' && typeof parsed.title === 'string') {
593
+ if (parsed && typeof parsed.id === 'string' && typeof parsed.title === 'string') {
645
594
  anchor = parsed;
646
595
  }
647
596
  } catch {}
648
597
  }
649
- if (!anchor) {
650
- throw new Error('Archive is empty, nothing to undo');
651
- }
598
+ if (!anchor) throw new Error('Archive is empty, nothing to undo');
652
599
 
653
- // 1. Re-write archive without the last line
654
600
  fs.writeFileSync(this.archivePath, lines.length > 0 ? lines.join('\n') + '\n' : '', 'utf-8');
655
601
 
656
- // 2. Restore to active state
657
602
  anchor.status = 'active';
658
603
  anchor.updatedAt = Date.now();
604
+ anchor.lastTouchedAt = Date.now();
659
605
  delete anchor.evidence;
660
- delete (anchor as any).settledForDate;
661
- if (anchor.recurrence === 'daily') {
662
- delete anchor.lastCompletedDate;
663
- }
664
606
 
665
607
  const state = this.loadState();
666
- const existingIdx = state.anchors.findIndex(a => a.id === anchor.id);
608
+ const existingIdx = state.anchors.findIndex(a => a.id === anchor!.id);
667
609
  if (existingIdx !== -1) {
668
610
  if (state.anchors[existingIdx].title === anchor.title) {
669
611
  state.anchors[existingIdx] = anchor;
@@ -672,84 +614,12 @@ export class AnchorStore {
672
614
  state.anchors.push(anchor);
673
615
  }
674
616
  } else {
675
- const match = anchor.id.match(/^anc-(\d+)$/);
676
- if (match) {
677
- const restoredNum = parseInt(match[1], 10);
678
- state.nextId = Math.max(state.nextId || 1, restoredNum + 1);
679
- }
617
+ const m = anchor.id.match(/^anc-(\d+)$/);
618
+ if (m) state.nextId = Math.max(state.nextId || 1, parseInt(m[1], 10) + 1);
680
619
  state.anchors.push(anchor);
681
620
  }
682
621
  this.saveState(state);
683
-
684
- return anchor;
685
- });
686
- }
687
-
688
- /**
689
- * Evict anchor to graveyard.jsonl upon total decay expiry
690
- */
691
- public dropToGraveyard(id: string, reason: string): Anchor {
692
- return this.withLock(() => {
693
- const state = this.loadState();
694
- const idx = state.anchors.findIndex(a => a.id === id);
695
- if (idx === -1) {
696
- throw new Error(`Anchor not found: ${id}`);
697
- }
698
-
699
- const [anchor] = state.anchors.splice(idx, 1);
700
- anchor.status = 'graveyard';
701
- anchor.updatedAt = Date.now();
702
- anchor.evidence = {
703
- summary: reason,
704
- settledAt: Date.now()
705
- };
706
-
707
- this.ensureDirs();
708
- fs.appendFileSync(this.graveyardPath, JSON.stringify(anchor) + '\n', 'utf-8');
709
- this.saveState(state);
710
622
  return anchor;
711
623
  });
712
624
  }
713
-
714
- public getArchive(filter?: { cwd?: string; all?: boolean }): Anchor[] {
715
- if (!fs.existsSync(this.archivePath)) return [];
716
- try {
717
- const raw = fs.readFileSync(this.archivePath, 'utf-8');
718
- const all: Anchor[] = [];
719
- for (const l of raw.split(/\r?\n/)) {
720
- const trimmed = l.trim();
721
- if (!trimmed) continue;
722
- try {
723
- const parsed = JSON.parse(trimmed);
724
- if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') all.push(parsed);
725
- } catch {}
726
- }
727
- if (filter?.all || !filter?.cwd) return all;
728
- const targetCwd = normalizePath(filter.cwd);
729
- return all.filter(a => !a.cwd || isSameOrSubPath(a.cwd, targetCwd));
730
- } catch {
731
- return [];
732
- }
733
- }
734
-
735
- public getGraveyard(filter?: { cwd?: string; all?: boolean }): Anchor[] {
736
- if (!fs.existsSync(this.graveyardPath)) return [];
737
- try {
738
- const raw = fs.readFileSync(this.graveyardPath, 'utf-8');
739
- const all: Anchor[] = [];
740
- for (const l of raw.split(/\r?\n/)) {
741
- const trimmed = l.trim();
742
- if (!trimmed) continue;
743
- try {
744
- const parsed = JSON.parse(trimmed);
745
- if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') all.push(parsed);
746
- } catch {}
747
- }
748
- if (filter?.all || !filter?.cwd) return all;
749
- const targetCwd = normalizePath(filter.cwd);
750
- return all.filter(a => !a.cwd || isSameOrSubPath(a.cwd, targetCwd));
751
- } catch {
752
- return [];
753
- }
754
- }
755
625
  }