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