pi-anchor 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/decay.ts DELETED
@@ -1,125 +0,0 @@
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
- return store.withLock(() => {
65
- const state = store.loadState();
66
- const result: SweepResult = {
67
- transitionedToSleeping: [],
68
- wokenToActive: [],
69
- evictedToGraveyard: []
70
- };
71
-
72
- const toEvict: Anchor[] = [];
73
- let stateModified = false;
74
-
75
- for (const anchor of state.anchors) {
76
- const evalResult = evaluateAnchorDecay(anchor, now);
77
-
78
- if (evalResult.nextStatus === 'graveyard') {
79
- toEvict.push(anchor);
80
- } else if (evalResult.nextStatus !== anchor.status) {
81
- if (evalResult.nextStatus === 'sleeping' && anchor.status === 'active') {
82
- anchor.status = 'sleeping';
83
- anchor.updatedAt = now;
84
- result.transitionedToSleeping.push(anchor.id);
85
- stateModified = true;
86
- } else if (evalResult.nextStatus === 'active' && anchor.status === 'sleeping') {
87
- anchor.status = 'active';
88
- anchor.updatedAt = now;
89
- result.wokenToActive.push(anchor.id);
90
- stateModified = true;
91
- }
92
- }
93
- }
94
-
95
- // Single-pass batch eviction and state save
96
- if (toEvict.length > 0) {
97
- const evictIds = new Set(toEvict.map(e => e.id));
98
- state.anchors = state.anchors.filter(a => !evictIds.has(a.id));
99
- stateModified = true;
100
-
101
- const graveyardLines = toEvict.map(exp => {
102
- const rec = {
103
- ...exp,
104
- status: 'graveyard',
105
- updatedAt: now,
106
- evictedAt: now,
107
- evictionReason: `Exceeded decay threshold (${exp.decay.graveyardDays} days untouched)`
108
- };
109
- return JSON.stringify(rec);
110
- }).join('\n') + '\n';
111
-
112
- fs.appendFileSync(store.graveyardPath, graveyardLines, 'utf-8');
113
- for (const exp of toEvict) {
114
- result.evictedToGraveyard.push(exp.id);
115
- }
116
- }
117
-
118
- state.lastSweepAt = now;
119
- if (stateModified) {
120
- store.saveState(state);
121
- }
122
-
123
- return result;
124
- });
125
- }
package/src/matcher.ts DELETED
@@ -1,151 +0,0 @@
1
- import path from 'node:path';
2
- import type { Anchor, TouchMatchResult } from './types.ts';
3
-
4
- /**
5
- * Convert simple glob pattern (e.g. *.ts, src/**\/*.ts, src/auth/*.ts) to RegExp
6
- */
7
- export function globToRegExp(pattern: string): RegExp {
8
- const collapsed = pattern
9
- .slice(0, 512)
10
- .replace(/\/+(\*\*\/+)+/g, '/**/')
11
- .replace(/\*{3,}/g, '**');
12
- const escaped = collapsed
13
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
14
- .replace(/\/\*\*\//g, '___GLOB_DIR_SLASH___')
15
- .replace(/\*\*/g, '___GLOB_STAR_STAR___')
16
- .replace(/\*/g, '[^/]*')
17
- .replace(/___GLOB_DIR_SLASH___/g, '(?:/|/(?:[^/]+/)+)')
18
- .replace(/___GLOB_STAR_STAR___/g, '.*');
19
- return new RegExp(`^${escaped}$`);
20
- }
21
-
22
- /**
23
- * Normalize path separators to POSIX forward slashes and optionally relative to baseCwd
24
- */
25
- export function normalizePath(filePath: string, baseCwd?: string): string {
26
- if (typeof filePath !== 'string') return '';
27
- let norm = filePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
28
- if (baseCwd && (path.isAbsolute(filePath) || norm.startsWith('/') || /^[a-zA-Z]:\//.test(norm))) {
29
- const normBase = baseCwd.trim().replace(/\\/g, '/').replace(/\/+$/, '');
30
- const lowerNorm = norm.toLowerCase();
31
- const lowerBase = normBase.toLowerCase();
32
- if (lowerNorm === lowerBase) {
33
- norm = '';
34
- } else if (lowerNorm.startsWith(lowerBase + '/')) {
35
- norm = norm.slice(normBase.length + 1).replace(/^\/+/, '');
36
- }
37
- }
38
- return norm;
39
- }
40
-
41
- /**
42
- * Compare two workspace CWDs safely (case-insensitive on Windows, supports subdirectory scoping)
43
- */
44
- export function isSameOrSubPath(anchorCwd?: string, targetCwd?: string): boolean {
45
- if (!anchorCwd || !targetCwd) return false;
46
- const normA = anchorCwd.trim().replace(/\\/g, '/').replace(/\/+$/, '');
47
- const normT = targetCwd.trim().replace(/\\/g, '/').replace(/\/+$/, '');
48
- const isWinStyle = process.platform === 'win32' || /^[a-zA-Z]:\//.test(normA) || /^[a-zA-Z]:\//.test(normT);
49
- const cmpA = isWinStyle ? normA.toLowerCase() : normA;
50
- const cmpT = isWinStyle ? normT.toLowerCase() : normT;
51
- return cmpA === cmpT || cmpT.startsWith(cmpA + '/') || cmpA.startsWith(cmpT + '/');
52
- }
53
-
54
- /**
55
- * Match touched files against an anchor's registered physical patterns
56
- * Only anchors with explicit files/globs/prefixes are matched against file touches.
57
- * Anchors with files: [] (pure mental notes or macro tasks) NEVER trigger on file operations.
58
- */
59
- export function matchAnchorAgainstTouchedFiles(
60
- anchor: Anchor,
61
- touchedFiles: string[]
62
- ): TouchMatchResult | null {
63
- if (!anchor.files || anchor.files.length === 0) return null;
64
-
65
- const normTouched = touchedFiles.map(f => normalizePath(f, anchor.cwd)).filter(Boolean);
66
- if (normTouched.length === 0) return null;
67
-
68
- const matchedExact: string[] = [];
69
- const matchedPrefix: string[] = [];
70
-
71
- for (const touched of normTouched) {
72
- // Exact match or configured glob match
73
- for (const pattern of anchor.files) {
74
- const normPattern = normalizePath(pattern, anchor.cwd);
75
- if (normPattern === touched) {
76
- matchedExact.push(touched);
77
- break;
78
- }
79
-
80
- // Glob pattern matching
81
- if (normPattern.includes('*')) {
82
- const regex = globToRegExp(normPattern);
83
- if (regex.test(touched)) {
84
- matchedExact.push(touched);
85
- break;
86
- }
87
- }
88
-
89
- // Directory prefix match, e.g. "src/auth/" matches "src/auth/login.ts"
90
- const dirPrefix = normPattern.endsWith('/') ? normPattern : normPattern + '/';
91
- if (touched.startsWith(dirPrefix)) {
92
- matchedPrefix.push(touched);
93
- break;
94
- }
95
- }
96
- }
97
-
98
- if (matchedExact.length > 0) {
99
- return {
100
- anchor,
101
- score: 1.0,
102
- matchedFiles: Array.from(new Set(matchedExact)),
103
- reason: 'exact-file'
104
- };
105
- }
106
-
107
- if (matchedPrefix.length > 0) {
108
- return {
109
- anchor,
110
- score: 0.85,
111
- matchedFiles: Array.from(new Set(matchedPrefix)),
112
- reason: 'dir-prefix'
113
- };
114
- }
115
-
116
- return null;
117
- }
118
-
119
- /**
120
- * Filter and sort anchors that match a set of touched files
121
- * Prioritizes high confidence (exact > prefix > keyword) and priority (P0 > P1 > P2).
122
- */
123
- export function findMatchedAnchors(
124
- anchors: Anchor[],
125
- touchedFiles: string[]
126
- ): TouchMatchResult[] {
127
- const priorityWeight: Record<string, number> = { p0: 3, p1: 2, p2: 1 };
128
- const results: TouchMatchResult[] = [];
129
-
130
- for (const anchor of anchors) {
131
- const match = matchAnchorAgainstTouchedFiles(anchor, touchedFiles);
132
- if (match) {
133
- results.push(match);
134
- }
135
- }
136
-
137
- return results.sort((a, b) => {
138
- // 1. Match score confidence desc
139
- if (b.score !== a.score) {
140
- return b.score - a.score;
141
- }
142
- // 2. Anchor priority desc
143
- const weightA = priorityWeight[a.anchor.priority] || 0;
144
- const weightB = priorityWeight[b.anchor.priority] || 0;
145
- if (weightB !== weightA) {
146
- return weightB - weightA;
147
- }
148
- // 3. Recency desc
149
- return b.anchor.lastTouchedAt - a.anchor.lastTouchedAt;
150
- });
151
- }
package/src/observer.ts DELETED
@@ -1,141 +0,0 @@
1
- import { normalizePath } from './matcher.ts';
2
- import type { Anchor } from './types.ts';
3
-
4
- const MUTATION_TOOLS = new Set(['edit', 'write', 'patch', 'apply_diff', 'create_file', 'modify']);
5
-
6
- export class SessionTouchObserver {
7
- private touchedFiles: Set<string> = new Set();
8
- private modifiedFiles: Set<string> = new Set();
9
- private inspectedFiles: Set<string> = new Set();
10
- private committed: boolean = false;
11
- private commitMessages: string[] = [];
12
-
13
- public clear(): void {
14
- this.touchedFiles.clear();
15
- this.modifiedFiles.clear();
16
- this.inspectedFiles.clear();
17
- this.committed = false;
18
- this.commitMessages = [];
19
- }
20
-
21
- /**
22
- * Observe and record a tool call invocation, separating inspection from mutation
23
- */
24
- public recordToolCall(toolName: string, input: Record<string, unknown>): void {
25
- if (!input || typeof input !== 'object') return;
26
- const lowerTool = (toolName || '').toLowerCase();
27
- const isMutation = MUTATION_TOOLS.has(lowerTool);
28
-
29
- const paths: string[] = [];
30
- const pathField = (input as any).path || (input as any).filePath || (input as any).file;
31
- if (typeof pathField === 'string') {
32
- paths.push(normalizePath(pathField));
33
- }
34
-
35
- if (Array.isArray((input as any).paths)) {
36
- for (const p of (input as any).paths) {
37
- if (typeof p === 'string') {
38
- paths.push(normalizePath(p));
39
- }
40
- }
41
- }
42
-
43
- for (const p of paths) {
44
- this.touchedFiles.add(p);
45
- if (isMutation) {
46
- this.modifiedFiles.add(p);
47
- } else {
48
- this.inspectedFiles.add(p);
49
- }
50
- }
51
-
52
- // Shell executions that might involve git commits
53
- if (lowerTool === 'bash' || lowerTool === 'powershell') {
54
- const cmd = String((input as any).command || '');
55
- if (cmd.includes('git commit')) {
56
- this.committed = true;
57
- const msgMatch = cmd.match(/-m\s+["']([^"']+)["']/);
58
- if (msgMatch && msgMatch[1]) {
59
- this.commitMessages.push(msgMatch[1]);
60
- }
61
- }
62
- }
63
- }
64
-
65
- /**
66
- * Add a file path manually (e.g. from git status diff)
67
- */
68
- public addTouchedFile(filePath: string, isModified = true): void {
69
- if (filePath) {
70
- const norm = normalizePath(filePath);
71
- this.touchedFiles.add(norm);
72
- if (isModified) {
73
- this.modifiedFiles.add(norm);
74
- } else {
75
- this.inspectedFiles.add(norm);
76
- }
77
- }
78
- }
79
-
80
- public getModifiedFiles(): string[] {
81
- return Array.from(this.modifiedFiles);
82
- }
83
-
84
- public getInspectedFiles(): string[] {
85
- return Array.from(this.inspectedFiles);
86
- }
87
-
88
- public getTouchedFiles(): string[] {
89
- return Array.from(this.touchedFiles);
90
- }
91
-
92
- public hasCommitted(): boolean {
93
- return this.committed;
94
- }
95
-
96
- public getCommitMessages(): string[] {
97
- return [...this.commitMessages];
98
- }
99
-
100
- /**
101
- * Check if any commit message explicitly references or resolves an anchor
102
- */
103
- public matchesCommit(anchor: Anchor): { matched: boolean; message?: string } {
104
- const idLower = anchor.id.toLowerCase();
105
- const title = anchor.title.toLowerCase();
106
-
107
- // Universal tokenization for CJK & Latin words
108
- let titleTokens: string[] = [];
109
- if (typeof Intl !== 'undefined' && (Intl as any).Segmenter) {
110
- try {
111
- const segmenter = new (Intl as any).Segmenter('und', { granularity: 'word' });
112
- titleTokens = Array.from(segmenter.segment(title))
113
- .filter((s: any) => s.isWordLike)
114
- .map((s: any) => s.segment.trim().toLowerCase())
115
- .filter((t: string) => t.length >= 2);
116
- } catch {
117
- titleTokens = title.split(/[\s,._\-\/]+/).filter(t => t.length >= 2);
118
- }
119
- } else {
120
- titleTokens = title.split(/[\s,._\-\/]+/).filter(t => t.length >= 2);
121
- }
122
-
123
- for (const msg of this.commitMessages) {
124
- const msgLower = msg.toLowerCase();
125
- // 1. Explicit ID reference, e.g. "fix: auth leak (#anc-1)" or "anc-1"
126
- if (msgLower.includes(idLower)) {
127
- return { matched: true, message: msg };
128
- }
129
-
130
- // 2. High-confidence token intersection
131
- if (titleTokens.length > 0) {
132
- const matchingCount = titleTokens.filter(tok => msgLower.includes(tok)).length;
133
- if (matchingCount >= Math.min(2, titleTokens.length)) {
134
- return { matched: true, message: msg };
135
- }
136
- }
137
- }
138
-
139
- return { matched: false };
140
- }
141
- }
package/src/protocol.ts DELETED
@@ -1,95 +0,0 @@
1
- import type { AnchorStore } from './store.ts';
2
- import type { Anchor } from './types.ts';
3
- import { renderColdStartAnchorsContext, makeSafeTaskAnnotation } from './context_injector.ts';
4
- import { findMatchedAnchors, normalizePath } from './matcher.ts';
5
- import { sweepStore } from './decay.ts';
6
- import { SessionTouchObserver } from './observer.ts';
7
-
8
- /**
9
- * Universal Agent Adapter Interface.
10
- * Any AI coding harness (Pi, Claude Code, Cursor, Aider, custom MCP server) implements this thin layer.
11
- */
12
- export interface AgentAdapter {
13
- readonly name: string;
14
- getCwd(): string;
15
- notify?(message: string, level: 'info' | 'warn' | 'error'): void;
16
- }
17
-
18
- /**
19
- * Anchor protocol engine for cross-session task management.
20
- * Decoupled, harness-agnostic core managing state, decay, JIT annotations, and settlement.
21
- */
22
- export class AnchorProtocol {
23
- public readonly store: AnchorStore;
24
- public readonly observer: SessionTouchObserver;
25
- private readonly annotatedThisSession = new Set<string>();
26
-
27
- constructor(store: AnchorStore) {
28
- this.store = store;
29
- this.observer = new SessionTouchObserver();
30
- }
31
-
32
- /**
33
- * Run session initialization lifecycle: sweeps stale anchors and resets session state
34
- */
35
- public handleSessionStart(now: number = Date.now()): { sleepingCount: number } {
36
- this.observer.clear();
37
- this.annotatedThisSession.clear();
38
- const sweep = sweepStore(this.store, now);
39
- return { sleepingCount: sweep.transitionedToSleeping.length };
40
- }
41
-
42
- /**
43
- * Handle turn lifecycle: injects cold-start prompt on turn 1, returns null (0 tokens) on turn 2+
44
- */
45
- public handleBeforeTurn(turnCount: number, cwd: string, now: number = Date.now()): string | null {
46
- if (turnCount <= 1) {
47
- return renderColdStartAnchorsContext(this.store, cwd, now) || null;
48
- }
49
- return null;
50
- }
51
-
52
- /**
53
- * Handle tool execution result: detects touched paths, refreshes mutation touch timers,
54
- * and returns safe language-specific JIT annotation (or null if syntax incompatible/already annotated).
55
- */
56
- public handleToolResult(input: {
57
- toolName: string;
58
- filePath?: string;
59
- isError?: boolean;
60
- isMutation?: boolean;
61
- cwd: string;
62
- }): { annotation: string | null; matchedAnchors: Anchor[] } {
63
- if (!input.isError) {
64
- this.observer.recordToolCall(input.toolName, input.filePath ? { path: input.filePath } : {});
65
- }
66
-
67
- if (!input.filePath) {
68
- return { annotation: null, matchedAnchors: [] };
69
- }
70
-
71
- const touchedPath = normalizePath(input.filePath);
72
- const anchors = this.store.list({ cwd: input.cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
73
- const matches = findMatchedAnchors(anchors, [touchedPath]);
74
-
75
- if (matches.length === 0) {
76
- return { annotation: null, matchedAnchors: [] };
77
- }
78
-
79
- // Physical mutations refresh decay timer and wake sleeping anchors
80
- if (input.isMutation && !input.isError) {
81
- for (const m of matches) {
82
- this.store.touch(m.anchor.id);
83
- }
84
- }
85
-
86
- const a = matches[0].anchor;
87
- if (!this.annotatedThisSession.has(a.id)) {
88
- this.annotatedThisSession.add(a.id);
89
- const annotation = makeSafeTaskAnnotation(touchedPath, a);
90
- return { annotation, matchedAnchors: matches.map(m => m.anchor) };
91
- }
92
-
93
- return { annotation: null, matchedAnchors: matches.map(m => m.anchor) };
94
- }
95
- }
package/src/settlement.ts DELETED
@@ -1,74 +0,0 @@
1
- import type { AnchorStore } from './store.ts';
2
- import { findMatchedAnchors } from './matcher.ts';
3
- import type { Anchor, SettlementProposal } from './types.ts';
4
- import { execSync } from 'node:child_process';
5
-
6
- /**
7
- * Execute physical verification test command for an anchor
8
- */
9
- export function runPhysicalVerification(anchor: Anchor, cwd: string): { success: boolean; output: string } {
10
- if (!anchor.verifyCommand?.trim()) {
11
- return { success: false, output: 'No verification command specified' };
12
- }
13
-
14
- const targetCwd = anchor.cwd || cwd;
15
- try {
16
- const stdout = execSync(anchor.verifyCommand, {
17
- cwd: targetCwd,
18
- encoding: 'utf-8',
19
- timeout: 8000,
20
- maxBuffer: 4 * 1024 * 1024,
21
- stdio: ['ignore', 'pipe', 'pipe']
22
- });
23
- return { success: true, output: stdout.trim() };
24
- } catch (err: any) {
25
- return { success: false, output: String(err.stderr || err.stdout || err.message).trim() };
26
- }
27
- }
28
-
29
- /**
30
- * Generate settlement candidates based on files touched during the session
31
- */
32
- export function generateSettlementProposals(
33
- store: AnchorStore,
34
- touchedFiles: string[],
35
- cwd?: string
36
- ): SettlementProposal[] {
37
- // Only match active/sleeping anchors belonging to current workspace
38
- const activeAndSleeping = store.list({ cwd }).filter(a => a.status === 'active' || a.status === 'sleeping');
39
- const matches = findMatchedAnchors(activeAndSleeping, touchedFiles);
40
-
41
- const proposals: SettlementProposal[] = [];
42
-
43
- for (const match of matches) {
44
- const fileList = match.matchedFiles.slice(0, 3).join(', ');
45
- const moreSuffix = match.matchedFiles.length > 3 ? ` 等 ${match.matchedFiles.length} 个文件` : '';
46
-
47
- proposals.push({
48
- anchor: match.anchor,
49
- matchedFiles: match.matchedFiles,
50
- reason: `本次会话修改了 ${fileList}${moreSuffix}`,
51
- recommendedAction: match.score >= 0.85 ? 'settle' : 'defer'
52
- });
53
- }
54
-
55
- return proposals;
56
- }
57
-
58
- /**
59
- * Format settlement proposal into terminal-ready card
60
- */
61
- export function formatSettlementCard(proposal: SettlementProposal): string {
62
- const a = proposal.anchor;
63
- const tagStr = a.tags.length > 0 ? ` [${a.tags.join(', ')}]` : '';
64
- const fileStr = proposal.matchedFiles.slice(0, 2).join(', ');
65
-
66
- return [
67
- `───────────────────────────────────────────────────────────────────`,
68
- `⚓ 关门结案提议 | Anchor Settlement`,
69
- ` 任务 #${a.id}: ${a.title}${tagStr} (${a.priority.toUpperCase()})`,
70
- ` 证据触发: 修改了 ${fileStr}`,
71
- ` [Enter 确认结案并归档] / [Tab 暂未完成,继续挂起]`,
72
- `───────────────────────────────────────────────────────────────────`
73
- ].join('\n');
74
- }