pi-anchor 0.1.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/LICENSE +21 -0
- package/README.md +273 -0
- package/README_zh.md +272 -0
- package/bin/anchor.js +163 -0
- package/dist/chunk-NNYUD5O7.js +1295 -0
- package/dist/extension.cjs +1560 -0
- package/dist/extension.d.cts +5 -0
- package/dist/extension.d.ts +5 -0
- package/dist/extension.js +312 -0
- package/dist/index.cjs +1433 -0
- package/dist/index.d.cts +448 -0
- package/dist/index.d.ts +448 -0
- package/dist/index.js +144 -0
- package/package.json +64 -0
- package/src/context_injector.ts +157 -0
- package/src/decay.ts +123 -0
- package/src/extension.ts +363 -0
- package/src/index.ts +9 -0
- package/src/matcher.ts +130 -0
- package/src/observer.ts +141 -0
- package/src/pi-types.d.ts +42 -0
- package/src/protocol.ts +95 -0
- package/src/settlement.ts +72 -0
- package/src/store.ts +682 -0
- package/src/tui.ts +386 -0
- package/src/types.ts +104 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { AnchorStore } from './store.ts';
|
|
2
|
+
import type { Anchor } from './types.ts';
|
|
3
|
+
import { sweepStore } from './decay.ts';
|
|
4
|
+
import { getTodayDateString } from './store.ts';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Universal multi-language comment generator.
|
|
9
|
+
* Maps file extension to safe comment syntax.
|
|
10
|
+
* Formats that do not safely support comments (e.g. .json, .env, binary) return null.
|
|
11
|
+
*/
|
|
12
|
+
export const COMMENT_FORMATS: Record<string, (msg: string) => string> = {
|
|
13
|
+
// Double slash //
|
|
14
|
+
'.ts': m => `// ${m}`,
|
|
15
|
+
'.tsx': m => `// ${m}`,
|
|
16
|
+
'.js': m => `// ${m}`,
|
|
17
|
+
'.jsx': m => `// ${m}`,
|
|
18
|
+
'.mjs': m => `// ${m}`,
|
|
19
|
+
'.cjs': m => `// ${m}`,
|
|
20
|
+
'.java': m => `// ${m}`,
|
|
21
|
+
'.kt': m => `// ${m}`,
|
|
22
|
+
'.swift': m => `// ${m}`,
|
|
23
|
+
'.go': m => `// ${m}`,
|
|
24
|
+
'.rs': m => `// ${m}`,
|
|
25
|
+
'.c': m => `// ${m}`,
|
|
26
|
+
'.cpp': m => `// ${m}`,
|
|
27
|
+
'.h': m => `// ${m}`,
|
|
28
|
+
'.hpp': m => `// ${m}`,
|
|
29
|
+
'.cs': m => `// ${m}`,
|
|
30
|
+
'.dart': m => `// ${m}`,
|
|
31
|
+
'.scala': m => `// ${m}`,
|
|
32
|
+
'.zig': m => `// ${m}`,
|
|
33
|
+
'.proto': m => `// ${m}`,
|
|
34
|
+
|
|
35
|
+
// Hash #
|
|
36
|
+
'.py': m => `# ${m}`,
|
|
37
|
+
'.rb': m => `# ${m}`,
|
|
38
|
+
'.sh': m => `# ${m}`,
|
|
39
|
+
'.bash': m => `# ${m}`,
|
|
40
|
+
'.zsh': m => `# ${m}`,
|
|
41
|
+
'.yaml': m => `# ${m}`,
|
|
42
|
+
'.yml': m => `# ${m}`,
|
|
43
|
+
'.toml': m => `# ${m}`,
|
|
44
|
+
'.conf': m => `# ${m}`,
|
|
45
|
+
'.dockerfile': m => `# ${m}`,
|
|
46
|
+
'.ps1': m => `# ${m}`,
|
|
47
|
+
'.r': m => `# ${m}`,
|
|
48
|
+
|
|
49
|
+
// HTML / XML <!-- -->
|
|
50
|
+
'.html': m => `<!-- ${m} -->`,
|
|
51
|
+
'.xml': m => `<!-- ${m} -->`,
|
|
52
|
+
'.svg': m => `<!-- ${m} -->`,
|
|
53
|
+
'.vue': m => `<!-- ${m} -->`,
|
|
54
|
+
'.svelte': m => `<!-- ${m} -->`,
|
|
55
|
+
|
|
56
|
+
// Block comment /* */
|
|
57
|
+
'.css': m => `/* ${m} */`,
|
|
58
|
+
'.scss': m => `/* ${m} */`,
|
|
59
|
+
'.less': m => `/* ${m} */`,
|
|
60
|
+
|
|
61
|
+
// SQL / Lua / Haskell --
|
|
62
|
+
'.sql': m => `-- ${m}`,
|
|
63
|
+
'.lua': m => `-- ${m}`,
|
|
64
|
+
'.hs': m => `-- ${m}`,
|
|
65
|
+
|
|
66
|
+
// Batch REM
|
|
67
|
+
'.bat': m => `REM ${m}`,
|
|
68
|
+
'.cmd': m => `REM ${m}`,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Format safe task context comment for a specific file.
|
|
73
|
+
* Returns null for formats that do not safely support comments (e.g. .json, .env, .lock)
|
|
74
|
+
*/
|
|
75
|
+
export function makeSafeTaskAnnotation(filePath: string, anchor: Anchor): string | null {
|
|
76
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
77
|
+
const formatter = COMMENT_FORMATS[ext];
|
|
78
|
+
if (!formatter) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return '\n\n' + formatter(`⌖ anchor context: #${anchor.id} ${anchor.title} (${anchor.priority.toUpperCase()})`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Render ultra-compact cold-start context block for session turn 1.
|
|
86
|
+
* Only active anchors for the current project context are injected.
|
|
87
|
+
* From turn 2 onwards, this returns empty string (0 tokens).
|
|
88
|
+
*/
|
|
89
|
+
export function renderColdStartAnchorsContext(
|
|
90
|
+
store: AnchorStore,
|
|
91
|
+
cwdOrNow?: string | number,
|
|
92
|
+
nowArg?: number
|
|
93
|
+
): string {
|
|
94
|
+
let cwd: string | undefined;
|
|
95
|
+
let now = Date.now();
|
|
96
|
+
|
|
97
|
+
if (typeof cwdOrNow === 'number') {
|
|
98
|
+
now = cwdOrNow;
|
|
99
|
+
} else if (typeof cwdOrNow === 'string') {
|
|
100
|
+
cwd = cwdOrNow;
|
|
101
|
+
if (typeof nowArg === 'number') {
|
|
102
|
+
now = nowArg;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 1. Run opportunistic sweep to ensure expired anchors don't leak into context
|
|
107
|
+
sweepStore(store, now);
|
|
108
|
+
|
|
109
|
+
// 2. Fetch only 'active' anchors scoped to the current cwd
|
|
110
|
+
const activeAnchors = store.list({ status: 'active', cwd });
|
|
111
|
+
if (activeAnchors.length === 0) {
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const lines = activeAnchors.map(a => {
|
|
116
|
+
const fileHint = a.files.length > 0 ? ` (${a.files.slice(0, 2).join(', ')})` : '';
|
|
117
|
+
const tagHint = a.tags.length > 0 ? ` [${a.tags.join(', ')}]` : '';
|
|
118
|
+
const projHint = a.cwd ? `[${a.project}]` : '[global]';
|
|
119
|
+
|
|
120
|
+
// Target cadence hint
|
|
121
|
+
let targetHint = '';
|
|
122
|
+
if (a.recurrence === 'daily') {
|
|
123
|
+
targetHint = ' [今日循环·Daily]';
|
|
124
|
+
} else if (a.targetDate) {
|
|
125
|
+
const todayStr = getTodayDateString(now);
|
|
126
|
+
if (a.targetDate === todayStr) {
|
|
127
|
+
targetHint = ' [今日聚焦·Due Today]';
|
|
128
|
+
} else if (a.targetDate < todayStr) {
|
|
129
|
+
targetHint = ' [已逾期·Overdue]';
|
|
130
|
+
} else {
|
|
131
|
+
targetHint = ` [排期:${a.targetDate}]`;
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
targetHint = ' [长期愿景·Someday]';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ageDays = Math.floor((now - a.createdAt) / (24 * 60 * 60 * 1000));
|
|
138
|
+
const ageHint = ageDays > 0 ? ` (${ageDays}d old)` : '';
|
|
139
|
+
|
|
140
|
+
return `• #${a.id} ${projHint}${targetHint} [${a.priority.toUpperCase()}] ${a.title}${ageHint}${tagHint}${fileHint}`;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return [
|
|
144
|
+
`<active-anchors count="${activeAnchors.length}">`,
|
|
145
|
+
`Unfinished cross-session commitments from previous sessions:`,
|
|
146
|
+
lines.join('\n'),
|
|
147
|
+
``,
|
|
148
|
+
`CONVERSATIONAL PROTOCOL (HUMAN PARTNER TONE):`,
|
|
149
|
+
`- If the user opens the session with a casual greeting or continuation ("hi", "在吗", "继续", "早上好"), naturally prioritize items tagged [今日聚焦·Due Today] in a friendly assistant tone (e.g. "嗨!今天规划的【...】现在处理还是看别的?").`,
|
|
150
|
+
`- If the user issues a direct, concrete new instruction (e.g. "帮我查下这个报错"), DO NOT interrupt their flow. Focus directly on their instruction.`,
|
|
151
|
+
`- When a commitment is satisfied during work, automatically call anchor(action="settle", id=...) to clear it.`,
|
|
152
|
+
`</active-anchors>`
|
|
153
|
+
].join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Legacy alias for backwards compatibility */
|
|
157
|
+
export const renderActiveAnchorsContext = renderColdStartAnchorsContext;
|
package/src/decay.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Anchor, AnchorStatus } from './types.ts';
|
|
2
|
+
import type { AnchorStore } from './store.ts';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
const MS_PER_DAY = 86_400_000;
|
|
6
|
+
|
|
7
|
+
export interface DecayEvaluation {
|
|
8
|
+
currentStatus: AnchorStatus;
|
|
9
|
+
nextStatus: AnchorStatus;
|
|
10
|
+
daysUntouched: number;
|
|
11
|
+
remainingActiveDays: number;
|
|
12
|
+
remainingSleepDays: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Calculate precise decay status based on elapsed time since last touch
|
|
17
|
+
*/
|
|
18
|
+
export function evaluateAnchorDecay(anchor: Anchor, now: number = Date.now()): DecayEvaluation {
|
|
19
|
+
if (anchor.status === 'settled' || (anchor.status as string) === 'graveyard') {
|
|
20
|
+
return {
|
|
21
|
+
currentStatus: anchor.status,
|
|
22
|
+
nextStatus: anchor.status,
|
|
23
|
+
daysUntouched: 0,
|
|
24
|
+
remainingActiveDays: 0,
|
|
25
|
+
remainingSleepDays: 0
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const elapsedMs = Math.max(0, now - anchor.lastTouchedAt);
|
|
30
|
+
const daysUntouched = elapsedMs / MS_PER_DAY;
|
|
31
|
+
|
|
32
|
+
const activeDays = anchor.decay.activeDays;
|
|
33
|
+
const graveyardDays = anchor.decay.graveyardDays;
|
|
34
|
+
|
|
35
|
+
let nextStatus: AnchorStatus = anchor.status;
|
|
36
|
+
|
|
37
|
+
if (daysUntouched <= activeDays) {
|
|
38
|
+
nextStatus = 'active';
|
|
39
|
+
} else if (daysUntouched <= graveyardDays) {
|
|
40
|
+
nextStatus = 'sleeping';
|
|
41
|
+
} else {
|
|
42
|
+
nextStatus = 'graveyard';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
currentStatus: anchor.status,
|
|
47
|
+
nextStatus,
|
|
48
|
+
daysUntouched: Math.round(daysUntouched * 10) / 10,
|
|
49
|
+
remainingActiveDays: Math.max(0, Math.round((activeDays - daysUntouched) * 10) / 10),
|
|
50
|
+
remainingSleepDays: Math.max(0, Math.round((graveyardDays - daysUntouched) * 10) / 10)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SweepResult {
|
|
55
|
+
transitionedToSleeping: string[];
|
|
56
|
+
wokenToActive: string[];
|
|
57
|
+
evictedToGraveyard: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run decay sweep across store: transitions statuses and evicts expired anchors
|
|
62
|
+
*/
|
|
63
|
+
export function sweepStore(store: AnchorStore, now: number = Date.now()): SweepResult {
|
|
64
|
+
const state = store.loadState();
|
|
65
|
+
const result: SweepResult = {
|
|
66
|
+
transitionedToSleeping: [],
|
|
67
|
+
wokenToActive: [],
|
|
68
|
+
evictedToGraveyard: []
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const toEvict: Anchor[] = [];
|
|
72
|
+
let stateModified = false;
|
|
73
|
+
|
|
74
|
+
for (const anchor of state.anchors) {
|
|
75
|
+
const evalResult = evaluateAnchorDecay(anchor, now);
|
|
76
|
+
|
|
77
|
+
if (evalResult.nextStatus === 'graveyard') {
|
|
78
|
+
toEvict.push(anchor);
|
|
79
|
+
} else if (evalResult.nextStatus !== anchor.status) {
|
|
80
|
+
if (evalResult.nextStatus === 'sleeping' && anchor.status === 'active') {
|
|
81
|
+
anchor.status = 'sleeping';
|
|
82
|
+
anchor.updatedAt = now;
|
|
83
|
+
result.transitionedToSleeping.push(anchor.id);
|
|
84
|
+
stateModified = true;
|
|
85
|
+
} else if (evalResult.nextStatus === 'active' && anchor.status === 'sleeping') {
|
|
86
|
+
anchor.status = 'active';
|
|
87
|
+
anchor.updatedAt = now;
|
|
88
|
+
result.wokenToActive.push(anchor.id);
|
|
89
|
+
stateModified = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Single-pass batch eviction and state save
|
|
95
|
+
if (toEvict.length > 0) {
|
|
96
|
+
const evictIds = new Set(toEvict.map(e => e.id));
|
|
97
|
+
state.anchors = state.anchors.filter(a => !evictIds.has(a.id));
|
|
98
|
+
stateModified = true;
|
|
99
|
+
|
|
100
|
+
const graveyardLines = toEvict.map(exp => {
|
|
101
|
+
const rec = {
|
|
102
|
+
...exp,
|
|
103
|
+
status: 'graveyard',
|
|
104
|
+
updatedAt: now,
|
|
105
|
+
evictedAt: now,
|
|
106
|
+
evictionReason: `Exceeded decay threshold (${exp.decay.graveyardDays} days untouched)`
|
|
107
|
+
};
|
|
108
|
+
return JSON.stringify(rec);
|
|
109
|
+
}).join('\n') + '\n';
|
|
110
|
+
|
|
111
|
+
fs.appendFileSync(store.graveyardPath, graveyardLines, 'utf-8');
|
|
112
|
+
for (const exp of toEvict) {
|
|
113
|
+
result.evictedToGraveyard.push(exp.id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
state.lastSweepAt = now;
|
|
118
|
+
if (stateModified) {
|
|
119
|
+
store.saveState(state);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
3
|
+
import { AnchorStore } from './store.ts';
|
|
4
|
+
import { SessionTouchObserver } from './observer.ts';
|
|
5
|
+
import { renderColdStartAnchorsContext, renderActiveAnchorsContext, makeSafeTaskAnnotation } from './context_injector.ts';
|
|
6
|
+
import { generateSettlementProposals, runPhysicalVerification } from './settlement.ts';
|
|
7
|
+
import { normalizePath, findMatchedAnchors } from './matcher.ts';
|
|
8
|
+
import { sweepStore } from './decay.ts';
|
|
9
|
+
import { updateAnchorStatusBar, openAnchorDashboard, updateStartupBanner } from './tui.ts';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const MUTATION_TOOLS = new Set(['edit', 'write', 'patch', 'apply_diff', 'create_file', 'modify']);
|
|
14
|
+
|
|
15
|
+
export default function (pi: ExtensionAPI) {
|
|
16
|
+
// Global authoritative store in ~/.anchor/ (0 workspace clutter)
|
|
17
|
+
const store = new AnchorStore();
|
|
18
|
+
const observer = new SessionTouchObserver();
|
|
19
|
+
const annotatedThisSession = new Set<string>();
|
|
20
|
+
|
|
21
|
+
// 1. Session start: sweep stale tasks, update TUI status bar, and render startup banner
|
|
22
|
+
pi.on('session_start', async (event: any, ctx: ExtensionContext) => {
|
|
23
|
+
observer.clear();
|
|
24
|
+
annotatedThisSession.clear();
|
|
25
|
+
const sweep = sweepStore(store);
|
|
26
|
+
|
|
27
|
+
if (sweep.transitionedToSleeping.length > 0) {
|
|
28
|
+
ctx.ui.notify(`Anchor: ${sweep.transitionedToSleeping.length} 个非活跃任务已进入休眠`, 'info');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
updateAnchorStatusBar(ctx, store);
|
|
32
|
+
|
|
33
|
+
// Only render startup banner on initial startup or new session, not on mid-session resume
|
|
34
|
+
if (event.reason !== 'resume') {
|
|
35
|
+
updateStartupBanner(ctx, store);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 2. Auto-clear startup banner when agent starts first turn to keep screen clean
|
|
40
|
+
pi.on('agent_start', async (_event: any, ctx: ExtensionContext) => {
|
|
41
|
+
if (ctx.hasUI && ctx.ui) {
|
|
42
|
+
ctx.ui.setWidget('anchor-startup', undefined);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// 3. JIT Cold-Start Injection: Only fires on Turn 1 of a session. From turn 2 onwards, consumes 0 tokens!
|
|
47
|
+
pi.on('before_agent_start', async (event: any, ctx: ExtensionContext) => {
|
|
48
|
+
const entries = (ctx as any).sessionManager?.getEntries() || [];
|
|
49
|
+
const messageTurns = entries.filter((e: any) => e.type === 'message');
|
|
50
|
+
const isColdStart = messageTurns.length <= 1;
|
|
51
|
+
|
|
52
|
+
if (isColdStart) {
|
|
53
|
+
const contextSnippet = renderColdStartAnchorsContext(store, ctx.cwd);
|
|
54
|
+
if (contextSnippet) {
|
|
55
|
+
return {
|
|
56
|
+
systemPrompt: `${event.systemPrompt}\n\n${contextSnippet}`
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// 4. JIT Path-Triggered Context Alert: When agent successfully touches an intersecting file
|
|
63
|
+
pi.on('tool_result', async (event: any, ctx: ExtensionContext) => {
|
|
64
|
+
// Only verified, non-error tool executions are recorded to prevent false-positive settlement
|
|
65
|
+
if (!event.isError) {
|
|
66
|
+
observer.recordToolCall(event.toolName, event.input || {});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const pathInput = (event.input as any)?.path;
|
|
70
|
+
if (typeof pathInput !== 'string') return;
|
|
71
|
+
|
|
72
|
+
const touchedPath = normalizePath(pathInput);
|
|
73
|
+
const anchors = store.list({ cwd: ctx.cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
|
|
74
|
+
const matches = findMatchedAnchors(anchors, [touchedPath]);
|
|
75
|
+
|
|
76
|
+
if (matches.length > 0) {
|
|
77
|
+
const isMutation = MUTATION_TOOLS.has((event.toolName || '').toLowerCase());
|
|
78
|
+
|
|
79
|
+
// Only physical mutations (edit, write) refresh decay timer and wake sleeping anchors!
|
|
80
|
+
// Read-only inspection (read, grep) provides JIT context without fake lifecycle extension.
|
|
81
|
+
if (isMutation && !event.isError) {
|
|
82
|
+
for (const m of matches) {
|
|
83
|
+
store.touch(m.anchor.id);
|
|
84
|
+
}
|
|
85
|
+
updateAnchorStatusBar(ctx, store);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const a = matches[0].anchor;
|
|
89
|
+
|
|
90
|
+
// Deduplicate annotations and ensure syntax safety (e.g. skip .json/.env)
|
|
91
|
+
if (!annotatedThisSession.has(a.id)) {
|
|
92
|
+
annotatedThisSession.add(a.id);
|
|
93
|
+
|
|
94
|
+
const alert = makeSafeTaskAnnotation(touchedPath, a);
|
|
95
|
+
if (alert) {
|
|
96
|
+
const contents = [...(event.content || [])];
|
|
97
|
+
for (let i = contents.length - 1; i >= 0; i--) {
|
|
98
|
+
const item = contents[i];
|
|
99
|
+
if (item && item.type === 'text') {
|
|
100
|
+
contents[i] = { ...item, text: item.text + alert };
|
|
101
|
+
return { content: contents };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 4. Session shutdown: One-tap settlement check scoped to current workspace
|
|
110
|
+
pi.on('session_shutdown', async (_event: any, ctx: ExtensionContext) => {
|
|
111
|
+
// Opportunistically scan git modified files
|
|
112
|
+
try {
|
|
113
|
+
const gitStatus = execSync('git status --porcelain', {
|
|
114
|
+
cwd: ctx.cwd,
|
|
115
|
+
encoding: 'utf-8',
|
|
116
|
+
timeout: 3000,
|
|
117
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
118
|
+
});
|
|
119
|
+
const changed = gitStatus
|
|
120
|
+
.split('\n')
|
|
121
|
+
.map((l: string) => l.slice(3).trim())
|
|
122
|
+
.filter(Boolean);
|
|
123
|
+
for (const f of changed) {
|
|
124
|
+
observer.addTouchedFile(f);
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
|
|
128
|
+
const modified = observer.getModifiedFiles();
|
|
129
|
+
if (modified.length === 0) return;
|
|
130
|
+
|
|
131
|
+
const proposals = generateSettlementProposals(store, modified, ctx.cwd);
|
|
132
|
+
if (proposals.length === 0) return;
|
|
133
|
+
|
|
134
|
+
// Check proposals for automated verification or one-tap settlement
|
|
135
|
+
for (const prop of proposals) {
|
|
136
|
+
const a = prop.anchor;
|
|
137
|
+
|
|
138
|
+
// 1. Zero-friction Git Commit evidence matching
|
|
139
|
+
const commitMatch = observer.matchesCommit(a);
|
|
140
|
+
if (commitMatch.matched) {
|
|
141
|
+
store.settle(a.id, {
|
|
142
|
+
settledBy: 'verification-test',
|
|
143
|
+
summary: `Auto-settled via Git commit: ${commitMatch.message}`,
|
|
144
|
+
touchedFiles: prop.matchedFiles
|
|
145
|
+
});
|
|
146
|
+
ctx.ui.notify(`⌖ Auto-settled #${a.id} via Git commit (run /anchor undo to revert)`, 'info');
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 2. Automated physical verification command
|
|
151
|
+
if (a.verifyCommand) {
|
|
152
|
+
const verifyRes = runPhysicalVerification(a, ctx.cwd);
|
|
153
|
+
if (verifyRes.success) {
|
|
154
|
+
store.settle(a.id, {
|
|
155
|
+
settledBy: 'verification-test',
|
|
156
|
+
summary: `Automated test passed: ${a.verifyCommand}`,
|
|
157
|
+
touchedFiles: prop.matchedFiles
|
|
158
|
+
});
|
|
159
|
+
ctx.ui.notify(`⌖ Test passed (${a.verifyCommand}), auto-settled #${a.id}`, 'info');
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 3. Fallback: Prompt user for one-tap settlement on exit
|
|
165
|
+
if (!ctx.hasUI || !ctx.ui) continue;
|
|
166
|
+
|
|
167
|
+
const ok = await ctx.ui.confirm(
|
|
168
|
+
'⌖ Settle Anchor Task',
|
|
169
|
+
`Task #${a.id} [${a.title}] touched files (${prop.matchedFiles.slice(0, 2).join(', ')}).\nMark as completed and archive?`
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (ok) {
|
|
173
|
+
store.settle(a.id, {
|
|
174
|
+
settledBy: 'one-tap-settlement',
|
|
175
|
+
touchedFiles: prop.matchedFiles
|
|
176
|
+
});
|
|
177
|
+
ctx.ui.notify(`⌖ Settled #${a.id}: "${a.title}"`, 'info');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// 5. Register LLM Tool: anchor
|
|
183
|
+
pi.registerTool({
|
|
184
|
+
name: 'anchor',
|
|
185
|
+
label: 'Anchor (Cross-session Task Protocol)',
|
|
186
|
+
description:
|
|
187
|
+
'Manage cross-session persistent tasks that survive terminal restarts and auto-evict upon code changes or settlement. Use when the user asks to retain, pin, remember, or track a multi-session goal across sessions, or when an ongoing commitment must not be forgotten. Actions: pin (create new cross-session anchor), list (view active and sleeping anchors), settle (close and archive a completed anchor), touch (refresh activity), sweep (run decay cleanup). Stored in ~/.anchor/ with zero project repository pollution.',
|
|
188
|
+
promptSnippet: 'Anchor cross-session task contracts that survive terminal restarts and auto-evict',
|
|
189
|
+
promptGuidelines: [
|
|
190
|
+
'Use `anchor` when the user asks to retain a goal across sessions or record a reminder for later/tonight/tomorrow (e.g. "晚上清理垃圾", "明天优化X", "保留任务直到完成").',
|
|
191
|
+
'BOUNDARY WITH TODO: `todo` is strictly for intra-session active work breakdown (step 1, step 2, step 3 right now). For future reminders or cross-session goals, ONLY use `anchor`. NEVER duplicate a cross-session reminder into both `todo` and `anchor`.',
|
|
192
|
+
'DO NOT over-engineer or assume automated scheduled tasks unless the user explicitly requests Windows Task Scheduler or cron.',
|
|
193
|
+
'Tasks are automatically scoped to the current project context without cluttering the project git repository.',
|
|
194
|
+
'When code for an anchor is completed and verified, call `anchor` with action "settle" to archive it and free context.',
|
|
195
|
+
'Active anchors are automatically injected into future sessions in an ultra-compact block.'
|
|
196
|
+
],
|
|
197
|
+
parameters: Type.Object({
|
|
198
|
+
action: Type.Union([
|
|
199
|
+
Type.Literal('pin'),
|
|
200
|
+
Type.Literal('list'),
|
|
201
|
+
Type.Literal('settle'),
|
|
202
|
+
Type.Literal('touch'),
|
|
203
|
+
Type.Literal('sweep')
|
|
204
|
+
]),
|
|
205
|
+
title: Type.Optional(Type.String({ description: 'Short imperative task title (for pin)' })),
|
|
206
|
+
description: Type.Optional(Type.String({ description: 'Detailed context, acceptance criteria, or technical notes' })),
|
|
207
|
+
priority: Type.Optional(Type.Union([Type.Literal('p0'), Type.Literal('p1'), Type.Literal('p2')])),
|
|
208
|
+
project: Type.Optional(Type.String({ description: 'Target project name or workspace (defaults to current directory if omitted)' })),
|
|
209
|
+
targetDate: Type.Optional(Type.String({ description: 'Expected completion date (e.g. YYYY-MM-DD, today, tomorrow)' })),
|
|
210
|
+
files: Type.Optional(Type.Array(Type.String(), { description: 'Associated file paths or directory prefixes' })),
|
|
211
|
+
tags: Type.Optional(Type.Array(Type.String(), { description: 'Domain tags' })),
|
|
212
|
+
verifyCommand: Type.Optional(Type.String({ description: 'Optional shell command for automated physical verification' })),
|
|
213
|
+
id: Type.Optional(Type.String({ description: 'Anchor ID, e.g. anc-1 (for settle or touch)' }))
|
|
214
|
+
}),
|
|
215
|
+
async execute(_toolCallId: string, params: any, _signal?: AbortSignal, _onUpdate?: (partial: any) => void, ctx?: ExtensionContext) {
|
|
216
|
+
if (!ctx) return { content: [{ type: 'text', text: 'Error: context required' }], isError: true };
|
|
217
|
+
if (params.action === 'pin') {
|
|
218
|
+
if (!params.title) {
|
|
219
|
+
return { content: [{ type: 'text', text: 'Error: title is required for pin action' }], isError: true };
|
|
220
|
+
}
|
|
221
|
+
const anc = store.create({
|
|
222
|
+
title: params.title,
|
|
223
|
+
description: params.description,
|
|
224
|
+
priority: params.priority || 'p1',
|
|
225
|
+
project: params.project,
|
|
226
|
+
targetDate: params.targetDate,
|
|
227
|
+
cwd: ctx.cwd,
|
|
228
|
+
files: params.files || [],
|
|
229
|
+
tags: params.tags || [],
|
|
230
|
+
verifyCommand: params.verifyCommand
|
|
231
|
+
});
|
|
232
|
+
updateAnchorStatusBar(ctx, store);
|
|
233
|
+
return {
|
|
234
|
+
content: [{
|
|
235
|
+
type: 'text',
|
|
236
|
+
text: `Successfully anchored task #${anc.id}: "${anc.title}" [${anc.project}]. Stored in ~/.anchor/.`
|
|
237
|
+
}],
|
|
238
|
+
isError: false
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (params.action === 'list') {
|
|
243
|
+
const active = store.list({ status: 'active', cwd: ctx.cwd });
|
|
244
|
+
const sleeping = store.list({ status: 'sleeping', cwd: ctx.cwd });
|
|
245
|
+
return {
|
|
246
|
+
content: [{
|
|
247
|
+
type: 'text',
|
|
248
|
+
text: JSON.stringify({ active, sleeping }, null, 2)
|
|
249
|
+
}],
|
|
250
|
+
isError: false
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (params.action === 'settle') {
|
|
255
|
+
if (!params.id) {
|
|
256
|
+
return { content: [{ type: 'text', text: 'Error: id is required for settle action' }], isError: true };
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const settled = store.settle(params.id, { settledBy: 'verification-test' });
|
|
260
|
+
updateAnchorStatusBar(ctx, store);
|
|
261
|
+
return {
|
|
262
|
+
content: [{
|
|
263
|
+
type: 'text',
|
|
264
|
+
text: `Anchor #${settled.id} successfully settled and evicted from active context.`
|
|
265
|
+
}],
|
|
266
|
+
isError: false
|
|
267
|
+
};
|
|
268
|
+
} catch (err: any) {
|
|
269
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (params.action === 'touch') {
|
|
274
|
+
if (!params.id) {
|
|
275
|
+
return { content: [{ type: 'text', text: 'Error: id is required for touch action' }], isError: true };
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const touched = store.touch(params.id);
|
|
279
|
+
updateAnchorStatusBar(ctx, store);
|
|
280
|
+
return {
|
|
281
|
+
content: [{
|
|
282
|
+
type: 'text',
|
|
283
|
+
text: `Anchor #${touched.id} touched and decay timer refreshed.`
|
|
284
|
+
}],
|
|
285
|
+
isError: false
|
|
286
|
+
};
|
|
287
|
+
} catch (err: any) {
|
|
288
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (params.action === 'sweep') {
|
|
293
|
+
const res = sweepStore(store);
|
|
294
|
+
updateAnchorStatusBar(ctx, store);
|
|
295
|
+
return {
|
|
296
|
+
content: [{
|
|
297
|
+
type: 'text',
|
|
298
|
+
text: `Sweep complete: ${res.transitionedToSleeping.length} sleeping, ${res.evictedToGraveyard.length} evicted to graveyard.`
|
|
299
|
+
}],
|
|
300
|
+
isError: false
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { content: [{ type: 'text', text: 'Unknown action' }], isError: true };
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// 6. User Slash Commands: /anchor and /pin
|
|
309
|
+
pi.registerCommand('anchor', {
|
|
310
|
+
description: 'Inspect and settle active anchors',
|
|
311
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
312
|
+
const input = (args || '').trim();
|
|
313
|
+
|
|
314
|
+
// Case 1: View active anchors (clean list, enter to complete)
|
|
315
|
+
if (!input || input === 'list' || input === 'ls') {
|
|
316
|
+
await openAnchorDashboard(ctx, store);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Case 2: Revert settlement
|
|
321
|
+
if (input === 'undo') {
|
|
322
|
+
try {
|
|
323
|
+
const restored = store.undoSettle();
|
|
324
|
+
ctx.ui.notify(`⌖ Restored #${restored.id}: "${restored.title}"`, 'info');
|
|
325
|
+
updateAnchorStatusBar(ctx, store);
|
|
326
|
+
} catch (err: any) {
|
|
327
|
+
ctx.ui.notify(`Revert failed: ${err.message}`, 'error');
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Case 3: Complete directly by ID (e.g. /anchor anc-1)
|
|
333
|
+
const targetId = input.startsWith('#') ? input.slice(1) : input;
|
|
334
|
+
const item = store.get(targetId);
|
|
335
|
+
if (item) {
|
|
336
|
+
store.settle(targetId, { settledBy: 'manual-command' });
|
|
337
|
+
ctx.ui.notify(`⌖ Settled: "${item.title}"`, 'info');
|
|
338
|
+
updateAnchorStatusBar(ctx, store);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Case 4: Freeform text directly creates an anchor
|
|
343
|
+
const anc = store.create({ title: input, cwd: ctx.cwd });
|
|
344
|
+
ctx.ui.notify(`⌖ Pinned #${anc.id}: "${anc.title}"`, 'info');
|
|
345
|
+
updateAnchorStatusBar(ctx, store);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Alias /pin to instant-add
|
|
350
|
+
pi.registerCommand('pin', {
|
|
351
|
+
description: 'Quickly pin a task across sessions',
|
|
352
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
353
|
+
const title = (args || '').trim();
|
|
354
|
+
if (!title) {
|
|
355
|
+
await openAnchorDashboard(ctx, store);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const anc = store.create({ title, cwd: ctx.cwd });
|
|
359
|
+
ctx.ui.notify(`⌖ Pinned #${anc.id}: "${anc.title}"`, 'info');
|
|
360
|
+
updateAnchorStatusBar(ctx, store);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './types.ts';
|
|
2
|
+
export * from './store.ts';
|
|
3
|
+
export * from './decay.ts';
|
|
4
|
+
export * from './matcher.ts';
|
|
5
|
+
export * from './settlement.ts';
|
|
6
|
+
export * from './tui.ts';
|
|
7
|
+
export * from './observer.ts';
|
|
8
|
+
export * from './context_injector.ts';
|
|
9
|
+
export * from './protocol.ts';
|