pi-anchor 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -166
- package/README_zh.md +32 -172
- package/assets/hero-en.svg +33 -70
- package/assets/hero-zh.svg +33 -70
- package/bin/anchor.js +49 -95
- package/dist/chunk-LKY6WIKH.js +1100 -0
- package/dist/extension-B1-fzCox.d.cts +167 -0
- package/dist/extension-B1-fzCox.d.ts +167 -0
- package/dist/extension.cjs +428 -1240
- package/dist/extension.d.cts +2 -20
- package/dist/extension.d.ts +2 -20
- package/dist/extension.js +4 -518
- package/dist/index.cjs +370 -1070
- package/dist/index.d.cts +17 -223
- package/dist/index.d.ts +17 -223
- package/dist/index.js +21 -101
- package/package.json +2 -2
- package/src/extension.ts +142 -356
- package/src/index.ts +1 -6
- package/src/store.ts +308 -422
- package/src/tui.ts +112 -259
- package/src/types.ts +24 -65
- package/dist/chunk-G72UHPBN.js +0 -1441
- package/dist/store-BNKjzhS_.d.cts +0 -209
- package/dist/store-BNKjzhS_.d.ts +0 -209
- package/src/context_injector.ts +0 -166
- package/src/decay.ts +0 -125
- package/src/matcher.ts +0 -151
- package/src/observer.ts +0 -141
- package/src/protocol.ts +0 -95
- package/src/settlement.ts +0 -74
package/src/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
|
|
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 } 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
|
+
* Safe boundary-aware directory comparison (case-insensitive on Windows)
|
|
35
36
|
*/
|
|
36
|
-
export function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
*
|
|
48
|
+
* Convert simple glob pattern (*.ts, src/**\/*.ts) to safe RegExp
|
|
49
49
|
*/
|
|
50
|
-
export function
|
|
51
|
-
const
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
102
|
+
|
|
103
|
+
if (anchor.cwd && isSameOrSubPath(anchor.cwd, normTarget)) {
|
|
104
|
+
return true;
|
|
65
105
|
}
|
|
66
106
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
155
|
+
return results.sort((a, b) => b.score - a.score);
|
|
118
156
|
}
|
|
119
157
|
|
|
120
158
|
/**
|
|
121
|
-
*
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
*
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
147
|
-
if (
|
|
191
|
+
let titleTokens: string[] = [];
|
|
192
|
+
if (typeof Intl !== 'undefined' && (Intl as any).Segmenter) {
|
|
148
193
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
}
|
|
216
|
+
}
|
|
158
217
|
}
|
|
218
|
+
return { matched: false };
|
|
219
|
+
}
|
|
159
220
|
|
|
160
|
-
|
|
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
|
-
*
|
|
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';
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
if (
|
|
255
|
-
|
|
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
|
-
|
|
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(
|
|
276
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
|
343
|
-
if (!
|
|
344
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
387
|
-
return
|
|
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
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
(
|
|
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 && !
|
|
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
|
-
...
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
660
|
-
if (
|
|
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
|
}
|