pi-hermes-memory 0.7.22 → 0.8.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 +18 -2
- package/package.json +3 -2
- package/src/config.ts +14 -1
- package/src/constants.ts +72 -0
- package/src/extension-root-migration.ts +429 -5
- package/src/handlers/auto-consolidate.ts +118 -8
- package/src/handlers/background-review.ts +164 -64
- package/src/handlers/child-process-watchdog.mjs +90 -0
- package/src/handlers/correction-detector.ts +52 -36
- package/src/handlers/pi-child-process.ts +270 -37
- package/src/handlers/review-memory-ops.ts +383 -0
- package/src/handlers/session-flush.ts +71 -4
- package/src/handlers/sync-markdown-memories.ts +143 -51
- package/src/index.ts +36 -17
- package/src/store/atomic-lock-coordinator.ts +252 -0
- package/src/store/canonical-storage-path.ts +54 -0
- package/src/store/db.ts +244 -9
- package/src/store/markdown-mutation-lock.ts +38 -0
- package/src/store/memory-store.ts +455 -57
- package/src/store/sqlite-memory-store.ts +121 -4
- package/src/tools/memory-tool.ts +48 -9
- package/src/tools/session-search-tool.ts +70 -12
- package/src/types.ts +6 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Markdown memory sync command — /memory-sync-markdown
|
|
3
|
-
*
|
|
2
|
+
* Markdown memory sync command — /memory-sync-markdown reconciles the SQLite
|
|
3
|
+
* search mirror with authoritative Markdown memory files.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import fs from 'node:fs';
|
|
@@ -8,20 +8,30 @@ import path from 'node:path';
|
|
|
8
8
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { DatabaseManager } from '../store/db.js';
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
reconcileMarkdownFailureScopes,
|
|
12
|
+
reconcileMarkdownMemoryScope,
|
|
13
13
|
} from '../store/sqlite-memory-store.js';
|
|
14
14
|
import { ENTRY_DELIMITER, MEMORY_FILE, USER_FILE } from '../constants.js';
|
|
15
15
|
import { AGENT_ROOT } from '../paths.js';
|
|
16
|
+
import {
|
|
17
|
+
migrateExtensionRoot,
|
|
18
|
+
type ExtensionRootMigrationOptions,
|
|
19
|
+
} from '../extension-root-migration.js';
|
|
20
|
+
import { withMarkdownMutationLock } from '../store/markdown-mutation-lock.js';
|
|
16
21
|
|
|
17
22
|
export interface BackfillCounters {
|
|
18
23
|
filesScanned: number;
|
|
19
24
|
entriesScanned: number;
|
|
20
25
|
imported: number;
|
|
21
26
|
skipped: number;
|
|
27
|
+
removed: number;
|
|
22
28
|
warnings: string[];
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
export interface MigrationSyncOptions extends ExtensionRootMigrationOptions {
|
|
32
|
+
onMigrationSucceeded?: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
function readEntries(filePath: string): string[] {
|
|
26
36
|
if (!fs.existsSync(filePath)) return [];
|
|
27
37
|
const raw = fs.readFileSync(filePath, 'utf-8').trim();
|
|
@@ -29,37 +39,15 @@ function readEntries(filePath: string): string[] {
|
|
|
29
39
|
return raw.split(ENTRY_DELIMITER).map((entry) => entry.trim()).filter(Boolean);
|
|
30
40
|
}
|
|
31
41
|
|
|
32
|
-
function importEntries(
|
|
33
|
-
dbManager: DatabaseManager,
|
|
34
|
-
counters: BackfillCounters,
|
|
35
|
-
entries: string[],
|
|
36
|
-
target: 'memory' | 'user' | 'failure',
|
|
37
|
-
project: string | null = null,
|
|
38
|
-
): void {
|
|
39
|
-
for (const rawEntry of entries) {
|
|
40
|
-
counters.entriesScanned++;
|
|
41
|
-
try {
|
|
42
|
-
const parsed = parseMarkdownMemoryEntry(rawEntry, target, project);
|
|
43
|
-
const result = syncMemoryEntry(dbManager, parsed);
|
|
44
|
-
if (result.action === 'inserted') counters.imported++;
|
|
45
|
-
else counters.skipped++;
|
|
46
|
-
} catch (err) {
|
|
47
|
-
counters.warnings.push(
|
|
48
|
-
`${path.basename(project ?? 'global')}/${target}: ${err instanceof Error ? err.message : String(err)}`,
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
42
|
function scanProjectDirs(agentRoot: string, globalDir: string, projectsMemoryDir = "projects-memory"): Array<{ name: string; memoryFile: string }> {
|
|
55
|
-
const projectsRoot = path.
|
|
43
|
+
const projectsRoot = path.resolve(agentRoot, projectsMemoryDir);
|
|
56
44
|
const projects = new Map<string, string>();
|
|
57
45
|
|
|
58
46
|
if (fs.existsSync(projectsRoot)) {
|
|
59
47
|
for (const name of fs.readdirSync(projectsRoot)) {
|
|
60
|
-
|
|
61
|
-
const memoryFile =
|
|
62
|
-
if (
|
|
48
|
+
if (!isSafeProjectName(name, projectsRoot)) continue;
|
|
49
|
+
const memoryFile = resolveAuthoritativeMemoryFile(projectsRoot, name);
|
|
50
|
+
if (memoryFile) {
|
|
63
51
|
projects.set(name, memoryFile);
|
|
64
52
|
}
|
|
65
53
|
}
|
|
@@ -74,9 +62,9 @@ function scanProjectDirs(agentRoot: string, globalDir: string, projectsMemoryDir
|
|
|
74
62
|
for (const name of fs.readdirSync(agentRoot)) {
|
|
75
63
|
if ((globalDirName && name === globalDirName) || name === projectsMemoryDir || name === 'skills' || name.startsWith('.')) continue;
|
|
76
64
|
if (projects.has(name)) continue;
|
|
77
|
-
|
|
78
|
-
const memoryFile =
|
|
79
|
-
if (
|
|
65
|
+
if (!isSafeProjectName(name, resolvedAgentRoot)) continue;
|
|
66
|
+
const memoryFile = resolveAuthoritativeMemoryFile(resolvedAgentRoot, name);
|
|
67
|
+
if (memoryFile) {
|
|
80
68
|
projects.set(name, memoryFile);
|
|
81
69
|
}
|
|
82
70
|
}
|
|
@@ -87,17 +75,73 @@ function scanProjectDirs(agentRoot: string, globalDir: string, projectsMemoryDir
|
|
|
87
75
|
.filter(({ memoryFile }) => fs.existsSync(memoryFile));
|
|
88
76
|
}
|
|
89
77
|
|
|
90
|
-
|
|
78
|
+
function realpathIfPresent(filePath: string): string {
|
|
79
|
+
try {
|
|
80
|
+
return fs.realpathSync(filePath);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return path.resolve(filePath);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveAuthoritativeMemoryFile(root: string, projectName: string): string | null {
|
|
88
|
+
const canonicalRoot = realpathIfPresent(root);
|
|
89
|
+
if (!isSafeProjectName(projectName, path.resolve(root))) return null;
|
|
90
|
+
|
|
91
|
+
const projectDir = path.join(root, projectName);
|
|
92
|
+
let projectStat: fs.Stats;
|
|
93
|
+
try {
|
|
94
|
+
projectStat = fs.lstatSync(projectDir);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
97
|
+
return path.join(canonicalRoot, projectName, MEMORY_FILE);
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (projectStat.isSymbolicLink() || !projectStat.isDirectory()) return null;
|
|
102
|
+
|
|
103
|
+
const canonicalProjectDir = fs.realpathSync(projectDir);
|
|
104
|
+
if (path.dirname(canonicalProjectDir) !== canonicalRoot) return null;
|
|
105
|
+
|
|
106
|
+
const memoryFile = path.join(projectDir, MEMORY_FILE);
|
|
107
|
+
let memoryStat: fs.Stats;
|
|
108
|
+
try {
|
|
109
|
+
memoryStat = fs.lstatSync(memoryFile);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
112
|
+
return path.join(canonicalProjectDir, MEMORY_FILE);
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
if (memoryStat.isSymbolicLink() || !memoryStat.isFile()) return null;
|
|
117
|
+
|
|
118
|
+
const canonicalMemoryFile = fs.realpathSync(memoryFile);
|
|
119
|
+
if (path.dirname(canonicalMemoryFile) !== canonicalProjectDir || path.basename(canonicalMemoryFile) !== MEMORY_FILE) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return canonicalMemoryFile;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isSafeProjectName(name: string, projectsRoot: string): boolean {
|
|
126
|
+
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\') || path.isAbsolute(name)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const projectDir = path.resolve(projectsRoot, name);
|
|
130
|
+
return path.dirname(projectDir) === projectsRoot && path.basename(projectDir) === name;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function syncMarkdownMemoriesToSqlite(
|
|
91
134
|
dbManager: DatabaseManager,
|
|
92
135
|
globalDir: string,
|
|
93
136
|
projectsMemoryDir?: string,
|
|
94
137
|
agentRoot = AGENT_ROOT,
|
|
95
|
-
): BackfillCounters & { projectCount: number } {
|
|
138
|
+
): Promise<BackfillCounters & { projectCount: number }> {
|
|
96
139
|
const counters: BackfillCounters = {
|
|
97
140
|
filesScanned: 0,
|
|
98
141
|
entriesScanned: 0,
|
|
99
142
|
imported: 0,
|
|
100
143
|
skipped: 0,
|
|
144
|
+
removed: 0,
|
|
101
145
|
warnings: [],
|
|
102
146
|
};
|
|
103
147
|
|
|
@@ -105,27 +149,74 @@ export function syncMarkdownMemoriesToSqlite(
|
|
|
105
149
|
const globalUserFile = path.join(globalDir, USER_FILE);
|
|
106
150
|
const globalFailureFile = path.join(globalDir, 'failures.md');
|
|
107
151
|
|
|
108
|
-
const
|
|
109
|
-
filePath: string,
|
|
152
|
+
const reconcileFile = async (
|
|
153
|
+
filePath: string | null,
|
|
110
154
|
target: 'memory' | 'user' | 'failure',
|
|
111
155
|
project: string | null = null,
|
|
112
156
|
) => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
157
|
+
const reconcile = () => {
|
|
158
|
+
if (filePath && fs.existsSync(filePath)) counters.filesScanned++;
|
|
159
|
+
const entries = filePath ? readEntries(filePath) : [];
|
|
160
|
+
counters.entriesScanned += entries.length;
|
|
161
|
+
try {
|
|
162
|
+
const result = target === 'failure'
|
|
163
|
+
? reconcileMarkdownFailureScopes(dbManager, entries)
|
|
164
|
+
: reconcileMarkdownMemoryScope(dbManager, entries, target, project);
|
|
165
|
+
counters.imported += result.inserted;
|
|
166
|
+
counters.skipped += result.existing;
|
|
167
|
+
counters.removed += result.removed;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
counters.warnings.push(
|
|
170
|
+
`${path.basename(project ?? 'global')}/${target}: ${err instanceof Error ? err.message : String(err)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
if (filePath) await withMarkdownMutationLock(filePath, reconcile);
|
|
175
|
+
else reconcile();
|
|
117
176
|
};
|
|
118
177
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
178
|
+
await reconcileFile(globalMemoryFile, 'memory');
|
|
179
|
+
await reconcileFile(globalUserFile, 'user');
|
|
180
|
+
await reconcileFile(globalFailureFile, 'failure');
|
|
122
181
|
|
|
123
182
|
const projects = scanProjectDirs(agentRoot, globalDir, projectsMemoryDir);
|
|
124
|
-
|
|
125
|
-
|
|
183
|
+
const projectFiles = new Map(projects.map((project) => [project.name, project.memoryFile]));
|
|
184
|
+
const mirroredProjects = dbManager.getDb().prepare(`
|
|
185
|
+
SELECT DISTINCT project
|
|
186
|
+
FROM memories
|
|
187
|
+
WHERE project IS NOT NULL AND target = 'memory'
|
|
188
|
+
`).all() as Array<{ project: string }>;
|
|
189
|
+
const projectNames = new Set([
|
|
190
|
+
...projectFiles.keys(),
|
|
191
|
+
...mirroredProjects.map(({ project }) => project),
|
|
192
|
+
]);
|
|
193
|
+
const projectsRoot = path.resolve(agentRoot, projectsMemoryDir ?? 'projects-memory');
|
|
194
|
+
for (const projectName of projectNames) {
|
|
195
|
+
const memoryFile = projectFiles.get(projectName)
|
|
196
|
+
?? resolveAuthoritativeMemoryFile(projectsRoot, projectName);
|
|
197
|
+
await reconcileFile(memoryFile, 'memory', projectName);
|
|
126
198
|
}
|
|
127
199
|
|
|
128
|
-
return { ...counters, projectCount:
|
|
200
|
+
return { ...counters, projectCount: projectNames.size };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function migrateThenSyncMarkdownMemories(
|
|
204
|
+
dbManager: DatabaseManager,
|
|
205
|
+
legacyGlobalDir: string | null,
|
|
206
|
+
globalDir: string,
|
|
207
|
+
projectsMemoryDir?: string,
|
|
208
|
+
agentRoot = AGENT_ROOT,
|
|
209
|
+
migrationOptions: MigrationSyncOptions = {},
|
|
210
|
+
): Promise<BackfillCounters & { projectCount: number }> {
|
|
211
|
+
if (legacyGlobalDir) {
|
|
212
|
+
const migration = await migrateExtensionRoot(legacyGlobalDir, globalDir, migrationOptions);
|
|
213
|
+
const sessionsFailure = migration.criticalFailures.find((failure) => failure.name === 'sessions.db');
|
|
214
|
+
if (sessionsFailure) {
|
|
215
|
+
throw new Error(`sessions.db migration failed: ${sessionsFailure.message}`);
|
|
216
|
+
}
|
|
217
|
+
migrationOptions.onMigrationSucceeded?.();
|
|
218
|
+
}
|
|
219
|
+
return await syncMarkdownMemoriesToSqlite(dbManager, globalDir, projectsMemoryDir, agentRoot);
|
|
129
220
|
}
|
|
130
221
|
|
|
131
222
|
export function registerSyncMarkdownMemoriesCommand(
|
|
@@ -136,19 +227,20 @@ export function registerSyncMarkdownMemoriesCommand(
|
|
|
136
227
|
agentRoot = AGENT_ROOT,
|
|
137
228
|
): void {
|
|
138
229
|
pi.registerCommand('memory-sync-markdown', {
|
|
139
|
-
description: '
|
|
230
|
+
description: 'Reconcile the SQLite search mirror with Markdown memories',
|
|
140
231
|
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
141
|
-
ctx.ui.notify('🔄
|
|
232
|
+
ctx.ui.notify('🔄 Reconciling the SQLite search mirror with Markdown memories...', 'info');
|
|
142
233
|
|
|
143
234
|
try {
|
|
144
|
-
const counters = syncMarkdownMemoriesToSqlite(dbManager, globalDir, projectsMemoryDir, agentRoot);
|
|
235
|
+
const counters = await syncMarkdownMemoriesToSqlite(dbManager, globalDir, projectsMemoryDir, agentRoot);
|
|
145
236
|
|
|
146
237
|
let output = `\n✅ Markdown → SQLite sync complete!\n\n`;
|
|
147
238
|
output += `📊 Results:\n`;
|
|
148
239
|
output += `├─ Files scanned: ${counters.filesScanned}\n`;
|
|
149
240
|
output += `├─ Entries scanned: ${counters.entriesScanned}\n`;
|
|
150
241
|
output += `├─ Imported into SQLite: ${counters.imported}\n`;
|
|
151
|
-
output +=
|
|
242
|
+
output += `├─ Skipped as duplicates: ${counters.skipped}\n`;
|
|
243
|
+
output += `└─ Removed orphaned rows: ${counters.removed}\n`;
|
|
152
244
|
|
|
153
245
|
if (counters.projectCount > 0) {
|
|
154
246
|
output += `\n📁 Project memories scanned: ${counters.projectCount}\n`;
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import * as path from "node:path";
|
|
26
|
+
import * as fs from "node:fs";
|
|
26
27
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
28
|
import { MemoryStore } from "./store/memory-store.js";
|
|
28
29
|
import { SkillStore } from "./store/skill-store.js";
|
|
@@ -45,14 +46,14 @@ import { registerInterviewCommand } from "./handlers/interview.js";
|
|
|
45
46
|
import { registerSwitchProjectCommand } from "./handlers/switch-project.js";
|
|
46
47
|
import { registerIndexSessionsCommand } from "./handlers/index-sessions.js";
|
|
47
48
|
import { registerLearnMemoryCommand } from "./handlers/learn-memory.js";
|
|
48
|
-
import {
|
|
49
|
+
import { migrateThenSyncMarkdownMemories, registerSyncMarkdownMemoriesCommand } from "./handlers/sync-markdown-memories.js";
|
|
49
50
|
import { registerPreviewContextCommand } from "./handlers/preview-context.js";
|
|
50
51
|
import { loadConfig } from "./config.js";
|
|
51
52
|
import { detectProject, detectProjectSkills } from "./project.js";
|
|
52
53
|
import { buildPromptContext } from "./prompt-context.js";
|
|
53
54
|
import { migrateLegacyProjectMemoryDirs } from "./project-memory-migration.js";
|
|
54
|
-
import { migrateExtensionRoot } from "./extension-root-migration.js";
|
|
55
55
|
import { AGENT_ROOT } from "./paths.js";
|
|
56
|
+
import { isDatabaseMigrationPending } from "./extension-root-migration.js";
|
|
56
57
|
|
|
57
58
|
export function resolveProjectSkillDiscovery(
|
|
58
59
|
skillStore: SkillStore,
|
|
@@ -95,7 +96,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
95
96
|
: configuredMemoryDir;
|
|
96
97
|
|
|
97
98
|
const shouldMigrateExtensionRoot = !configuredMemoryDir || pointsToLegacyMemoryDir;
|
|
98
|
-
let
|
|
99
|
+
let persistenceInitialized = false;
|
|
99
100
|
|
|
100
101
|
const store = new MemoryStore({ ...config, memoryDir: globalDir });
|
|
101
102
|
const project = detectProject(config.projectsMemoryDir);
|
|
@@ -109,6 +110,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
109
110
|
migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
|
|
110
111
|
});
|
|
111
112
|
const dbManager = new DatabaseManager(globalDir);
|
|
113
|
+
let databaseMigrationPending = shouldMigrateExtensionRoot
|
|
114
|
+
&& isDatabaseMigrationPending(legacyGlobalDir, globalDir);
|
|
115
|
+
if (databaseMigrationPending) {
|
|
116
|
+
dbManager.setOpenGuard(() => {
|
|
117
|
+
if (databaseMigrationPending) {
|
|
118
|
+
throw new Error("Legacy sessions.db migration is pending");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
112
122
|
const sessionsDir = path.join(agentRoot, "sessions");
|
|
113
123
|
|
|
114
124
|
const refreshSkillProjectContext = (cwd?: string) => {
|
|
@@ -124,12 +134,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
124
134
|
// ~/.pi/agent/<project>/ layout. This is non-destructive: legacy folders
|
|
125
135
|
// remain in place while entries are copied/merged into projects-memory/.
|
|
126
136
|
migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir);
|
|
127
|
-
try {
|
|
128
|
-
syncMarkdownMemoriesToSqlite(dbManager, globalDir, config.projectsMemoryDir, agentRoot);
|
|
129
|
-
} catch {
|
|
130
|
-
// Best-effort only: failed SQLite backfill should not block extension startup.
|
|
131
|
-
}
|
|
132
|
-
|
|
133
137
|
// Detect project from cwd using shared helper
|
|
134
138
|
// Project-scoped store: ~/.pi/agent/<projectsMemoryDir>/<project_name>/
|
|
135
139
|
const projectConfig = project.memoryDir
|
|
@@ -139,13 +143,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
139
143
|
|
|
140
144
|
// ── 1. Load memory from disk on session start ──
|
|
141
145
|
pi.on("session_start", async (_event, ctx) => {
|
|
142
|
-
if (
|
|
146
|
+
if (!persistenceInitialized) {
|
|
143
147
|
try {
|
|
144
|
-
await
|
|
148
|
+
await migrateThenSyncMarkdownMemories(
|
|
149
|
+
dbManager,
|
|
150
|
+
shouldMigrateExtensionRoot ? legacyGlobalDir : null,
|
|
151
|
+
globalDir,
|
|
152
|
+
config.projectsMemoryDir,
|
|
153
|
+
agentRoot,
|
|
154
|
+
{
|
|
155
|
+
onMigrationSucceeded: () => {
|
|
156
|
+
databaseMigrationPending = false;
|
|
157
|
+
dbManager.setOpenGuard(null);
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
persistenceInitialized = true;
|
|
145
162
|
} catch {
|
|
146
|
-
//
|
|
163
|
+
// Best-effort only: migration or SQLite backfill must not block startup.
|
|
147
164
|
}
|
|
148
|
-
extensionRootMigrated = true;
|
|
149
165
|
}
|
|
150
166
|
|
|
151
167
|
refreshSkillProjectContext(ctx.cwd);
|
|
@@ -154,7 +170,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
154
170
|
await store.loadFromDisk();
|
|
155
171
|
if (projectStore) await projectStore.loadFromDisk();
|
|
156
172
|
|
|
157
|
-
scheduleSessionBackfill(dbManager, sessionsDir, {
|
|
173
|
+
if (persistenceInitialized) scheduleSessionBackfill(dbManager, sessionsDir, {
|
|
158
174
|
notify: (message, level) => {
|
|
159
175
|
const ui = (ctx as { ui?: { notify?: (message: string, level?: string) => void } }).ui;
|
|
160
176
|
if (ui?.notify) {
|
|
@@ -188,10 +204,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
188
204
|
registerSkillTool(pi, skillStore);
|
|
189
205
|
|
|
190
206
|
// ── 5. Setup background learning loop (with tool-call-aware nudge) ──
|
|
191
|
-
setupBackgroundReview(pi, store, projectStore, config
|
|
207
|
+
setupBackgroundReview(pi, store, projectStore, config, {
|
|
208
|
+
dbManager,
|
|
209
|
+
projectName: projectName || null,
|
|
210
|
+
});
|
|
192
211
|
|
|
193
212
|
// ── 6. Setup session-end flush ──
|
|
194
|
-
setupSessionFlush(pi, store, projectStore, config);
|
|
213
|
+
setupSessionFlush(pi, store, projectStore, config, dbManager, projectName);
|
|
195
214
|
|
|
196
215
|
// ── 7. Setup auto-consolidation (inject consolidator into stores) ──
|
|
197
216
|
store.setConsolidator(async (target, signal) => {
|
|
@@ -203,7 +222,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
203
222
|
return triggerConsolidation(pi, projectStore, target, signal, config.consolidationTimeoutMs, toolTarget, config);
|
|
204
223
|
});
|
|
205
224
|
}
|
|
206
|
-
registerConsolidateCommand(pi, store, config.consolidationTimeoutMs, projectStore, projectName, config);
|
|
225
|
+
registerConsolidateCommand(pi, store, config.consolidationTimeoutMs, projectStore, projectName, config, dbManager);
|
|
207
226
|
|
|
208
227
|
// ── 8. Setup correction detection ──
|
|
209
228
|
setupCorrectionDetector(pi, store, projectStore, config, dbManager, projectName);
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
type StatementLike = {
|
|
8
|
+
run: (...args: unknown[]) => unknown;
|
|
9
|
+
get: (...args: unknown[]) => unknown;
|
|
10
|
+
all: (...args: unknown[]) => unknown[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type DatabaseLike = {
|
|
14
|
+
prepare: (sql: string) => StatementLike;
|
|
15
|
+
exec: (sql: string) => void;
|
|
16
|
+
close: () => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type DatabaseCtor = new (dbPath: string) => DatabaseLike;
|
|
20
|
+
|
|
21
|
+
export interface AtomicLockOptions {
|
|
22
|
+
staleMs: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AtomicLockLease {
|
|
26
|
+
token: string;
|
|
27
|
+
release: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AtomicLockCoordinatorOptions {
|
|
31
|
+
pid?: number;
|
|
32
|
+
incarnation?: string;
|
|
33
|
+
probeIncarnation?: (pid: number) => string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadDatabaseCtor(): DatabaseCtor {
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') {
|
|
39
|
+
const bunSqlite = require('bun:sqlite') as { Database: DatabaseCtor };
|
|
40
|
+
return bunSqlite.Database;
|
|
41
|
+
}
|
|
42
|
+
const mod = require('better-sqlite3') as { default?: DatabaseCtor } | DatabaseCtor;
|
|
43
|
+
return (mod as { default?: DatabaseCtor }).default ?? (mod as DatabaseCtor);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const Database = loadDatabaseCtor();
|
|
47
|
+
|
|
48
|
+
function processIsAlive(pid: number): boolean {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function probeProcessIncarnation(pid: number): string | null {
|
|
58
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
59
|
+
if (process.platform === 'linux') {
|
|
60
|
+
try {
|
|
61
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
|
62
|
+
const end = stat.lastIndexOf(')');
|
|
63
|
+
const fields = stat.slice(end + 2).split(' ');
|
|
64
|
+
return fields[19] || null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (process.platform !== 'win32') {
|
|
71
|
+
const result = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
72
|
+
encoding: 'utf-8',
|
|
73
|
+
timeout: 250,
|
|
74
|
+
});
|
|
75
|
+
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const result = spawnSync(
|
|
79
|
+
'powershell.exe',
|
|
80
|
+
['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${pid} -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().Ticks`],
|
|
81
|
+
{ encoding: 'utf-8', timeout: 500 },
|
|
82
|
+
);
|
|
83
|
+
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const currentProcessIncarnation = probeProcessIncarnation(process.pid);
|
|
87
|
+
const RELEASE_ATTEMPTS = 3;
|
|
88
|
+
const pendingReleases = new Map<string, () => void>();
|
|
89
|
+
|
|
90
|
+
export class AtomicLockCoordinator {
|
|
91
|
+
private readonly pid: number;
|
|
92
|
+
private readonly incarnation: string | null;
|
|
93
|
+
private readonly probeIncarnation: (pid: number) => string | null;
|
|
94
|
+
|
|
95
|
+
constructor(private readonly dbPath: string, options: AtomicLockCoordinatorOptions = {}) {
|
|
96
|
+
this.pid = options.pid ?? process.pid;
|
|
97
|
+
this.probeIncarnation = options.probeIncarnation
|
|
98
|
+
?? ((pid) => pid === process.pid ? currentProcessIncarnation : probeProcessIncarnation(pid));
|
|
99
|
+
this.incarnation = options.incarnation
|
|
100
|
+
?? this.probeIncarnation(this.pid)
|
|
101
|
+
?? null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
tryAcquire(key: string, options: AtomicLockOptions): AtomicLockLease | null {
|
|
105
|
+
this.retryPendingReleases(key);
|
|
106
|
+
const token = randomUUID();
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const db = this.open();
|
|
109
|
+
let acquired = false;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
db.exec('BEGIN IMMEDIATE');
|
|
113
|
+
try {
|
|
114
|
+
const owner = db.prepare(`
|
|
115
|
+
SELECT token, pid, incarnation, acquired_at
|
|
116
|
+
FROM locks
|
|
117
|
+
WHERE lock_key = ?
|
|
118
|
+
`).get(key) as { token: string; pid: number; incarnation: string | null; acquired_at: number } | undefined;
|
|
119
|
+
|
|
120
|
+
if (!owner) {
|
|
121
|
+
db.prepare(`
|
|
122
|
+
INSERT INTO locks (lock_key, token, pid, incarnation, acquired_at)
|
|
123
|
+
VALUES (?, ?, ?, ?, ?)
|
|
124
|
+
`).run(key, token, this.pid, this.incarnation, now);
|
|
125
|
+
acquired = true;
|
|
126
|
+
} else {
|
|
127
|
+
const observedIncarnation = this.probeIncarnation(owner.pid);
|
|
128
|
+
const alive = observedIncarnation !== null || processIsAlive(owner.pid);
|
|
129
|
+
const sameIncarnation = alive
|
|
130
|
+
&& owner.incarnation !== null
|
|
131
|
+
&& observedIncarnation !== null
|
|
132
|
+
&& owner.incarnation === observedIncarnation;
|
|
133
|
+
const unknownIncarnation = alive && (owner.incarnation === null || observedIncarnation === null);
|
|
134
|
+
// A lease is reclaimable once it has been held for longer than staleMs,
|
|
135
|
+
// regardless of whether the owning process is still alive — it may be
|
|
136
|
+
// making no progress (blocked I/O, wedged, suspended) rather than dead.
|
|
137
|
+
// This is the sole backstop for that case: liveness/incarnation checks
|
|
138
|
+
// alone cannot distinguish "alive and working" from "alive and stuck".
|
|
139
|
+
// staleMs <= 0 disables time-based takeover (liveness checks only).
|
|
140
|
+
const stale = options.staleMs > 0 && now - owner.acquired_at >= options.staleMs;
|
|
141
|
+
if (stale || (!sameIncarnation && !unknownIncarnation)) {
|
|
142
|
+
db.prepare(`
|
|
143
|
+
UPDATE locks
|
|
144
|
+
SET token = ?, pid = ?, incarnation = ?, acquired_at = ?
|
|
145
|
+
WHERE lock_key = ? AND token = ?
|
|
146
|
+
`).run(token, this.pid, this.incarnation, now, key, owner.token);
|
|
147
|
+
acquired = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
db.exec('COMMIT');
|
|
152
|
+
} catch (error) {
|
|
153
|
+
try { db.exec('ROLLBACK'); } catch {}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
db.close();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!acquired) return null;
|
|
161
|
+
return {
|
|
162
|
+
token,
|
|
163
|
+
release: () => this.release(key, token),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Fencing check for destructive operations that lack their own independent
|
|
169
|
+
* compare-and-swap (e.g. a plain fs.renameSync with no content/inode
|
|
170
|
+
* verification). A lease can be legitimately stolen from a stale-but-alive
|
|
171
|
+
* holder (see tryAcquire); a holder resuming after being stuck must verify
|
|
172
|
+
* it is still the current owner immediately before publishing, or abort.
|
|
173
|
+
* This narrows — it cannot fully close — the check-then-act race, since
|
|
174
|
+
* synchronous work between this call and the actual write is not atomic
|
|
175
|
+
* with it.
|
|
176
|
+
*/
|
|
177
|
+
isCurrentOwner(key: string, token: string): boolean {
|
|
178
|
+
const db = this.open();
|
|
179
|
+
try {
|
|
180
|
+
const row = db.prepare('SELECT token FROM locks WHERE lock_key = ?').get(key) as { token: string } | undefined;
|
|
181
|
+
return row?.token === token;
|
|
182
|
+
} finally {
|
|
183
|
+
db.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
release(key: string, token: string): void {
|
|
188
|
+
const pendingKey = this.pendingReleaseKey(key, token);
|
|
189
|
+
for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt++) {
|
|
190
|
+
try {
|
|
191
|
+
this.deleteOwnedLock(key, token);
|
|
192
|
+
pendingReleases.delete(pendingKey);
|
|
193
|
+
return;
|
|
194
|
+
} catch {
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
pendingReleases.set(pendingKey, () => this.release(key, token));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private deleteOwnedLock(key: string, token: string): void {
|
|
201
|
+
const db = this.open();
|
|
202
|
+
try {
|
|
203
|
+
db.prepare('DELETE FROM locks WHERE lock_key = ? AND token = ?').run(key, token);
|
|
204
|
+
} finally {
|
|
205
|
+
db.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private retryPendingReleases(key: string): void {
|
|
210
|
+
const prefix = `${path.resolve(this.dbPath)}\0${key}\0`;
|
|
211
|
+
for (const [pendingKey, release] of [...pendingReleases.entries()]) {
|
|
212
|
+
if (pendingKey.startsWith(prefix)) release();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private pendingReleaseKey(key: string, token: string): string {
|
|
217
|
+
return `${path.resolve(this.dbPath)}\0${key}\0${token}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private open(): DatabaseLike {
|
|
221
|
+
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
|
|
222
|
+
const existed = fs.existsSync(this.dbPath);
|
|
223
|
+
const db = new Database(this.dbPath);
|
|
224
|
+
try {
|
|
225
|
+
db.exec(`
|
|
226
|
+
PRAGMA busy_timeout = 5000;
|
|
227
|
+
PRAGMA journal_mode = WAL;
|
|
228
|
+
CREATE TABLE IF NOT EXISTS locks (
|
|
229
|
+
lock_key TEXT PRIMARY KEY,
|
|
230
|
+
token TEXT NOT NULL,
|
|
231
|
+
pid INTEGER NOT NULL,
|
|
232
|
+
incarnation TEXT,
|
|
233
|
+
acquired_at INTEGER NOT NULL
|
|
234
|
+
);
|
|
235
|
+
`);
|
|
236
|
+
const columns = db.prepare('PRAGMA table_info(locks)').all() as Array<{ name: string }>;
|
|
237
|
+
if (!columns.some(({ name }) => name === 'incarnation')) {
|
|
238
|
+
try {
|
|
239
|
+
db.exec('ALTER TABLE locks ADD COLUMN incarnation TEXT');
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const refreshed = db.prepare('PRAGMA table_info(locks)').all() as Array<{ name: string }>;
|
|
242
|
+
if (!refreshed.some(({ name }) => name === 'incarnation')) throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!existed) fs.chmodSync(this.dbPath, 0o600);
|
|
246
|
+
return db;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
db.close();
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|