pi-anchor 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -170
- package/README_zh.md +32 -176
- package/assets/hero-en.svg +23 -39
- package/assets/hero-zh.svg +23 -39
- package/bin/anchor.js +36 -69
- package/dist/chunk-LKY6WIKH.js +1100 -0
- package/dist/extension-B1-fzCox.d.cts +167 -0
- package/dist/extension-B1-fzCox.d.ts +167 -0
- package/dist/extension.cjs +369 -1315
- package/dist/extension.d.cts +2 -20
- package/dist/extension.d.ts +2 -20
- package/dist/extension.js +4 -536
- package/dist/index.cjs +317 -1143
- package/dist/index.d.cts +17 -250
- package/dist/index.d.ts +17 -250
- package/dist/index.js +15 -103
- package/package.json +2 -2
- package/src/extension.ts +124 -362
- package/src/index.ts +1 -6
- package/src/store.ts +294 -424
- package/src/tui.ts +102 -297
- package/src/types.ts +24 -65
- package/dist/chunk-Q7IILJFA.js +0 -1563
- package/dist/store-t2_TDhJn.d.cts +0 -210
- package/dist/store-t2_TDhJn.d.ts +0 -210
- package/src/context_injector.ts +0 -166
- package/src/decay.ts +0 -125
- package/src/matcher.ts +0 -202
- package/src/observer.ts +0 -141
- package/src/protocol.ts +0 -95
- package/src/settlement.ts +0 -140
package/src/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
|
|
5
|
-
Anchor,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
*
|
|
16
|
+
* Normalize path separators to forward slashes and strip baseCwd prefix when inside baseCwd
|
|
18
17
|
*/
|
|
19
|
-
export function
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
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
|
|
31
|
+
return norm;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
*
|
|
48
|
+
* Convert simple glob pattern (*.ts, src/**\/*.ts) to safe RegExp
|
|
50
49
|
*/
|
|
51
|
-
export function
|
|
52
|
-
const
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
102
|
+
|
|
103
|
+
if (anchor.cwd && isSameOrSubPath(anchor.cwd, normTarget)) {
|
|
104
|
+
return true;
|
|
66
105
|
}
|
|
67
106
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
155
|
+
return results.sort((a, b) => b.score - a.score);
|
|
119
156
|
}
|
|
120
157
|
|
|
121
158
|
/**
|
|
122
|
-
*
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
*
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
148
|
-
if (
|
|
191
|
+
let titleTokens: string[] = [];
|
|
192
|
+
if (typeof Intl !== 'undefined' && (Intl as any).Segmenter) {
|
|
149
193
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
}
|
|
216
|
+
}
|
|
159
217
|
}
|
|
218
|
+
return { matched: false };
|
|
219
|
+
}
|
|
160
220
|
|
|
161
|
-
|
|
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
|
-
*
|
|
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';
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
if (
|
|
256
|
-
|
|
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
|
-
|
|
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(
|
|
277
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
|
344
|
-
if (!
|
|
345
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
388
|
-
return
|
|
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
|
-
*
|
|
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
|
-
(
|
|
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
|
-
...
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
676
|
-
if (
|
|
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
|
}
|