pi-hermes-memory 0.8.1 → 0.9.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 +17 -0
- package/package.json +1 -1
- package/src/constants.ts +10 -9
- package/src/extension-root-migration.ts +118 -4
- package/src/handlers/auto-consolidate.ts +1 -1
- package/src/index.ts +7 -6
- package/src/project.ts +104 -5
- package/src/store/atomic-lock-coordinator.ts +63 -29
- package/src/store/db.ts +23 -18
- package/src/store/fts-query.ts +31 -0
- package/src/store/markdown-mutation-lock.ts +1 -1
- package/src/store/memory-store.ts +71 -23
- package/src/store/session-search.ts +33 -2
- package/src/store/skill-store.ts +177 -35
- package/src/store/sqlite-memory-store.ts +30 -1
- package/src/store/sqlite-native.ts +241 -0
- package/src/tools/skill-tool.ts +59 -11
package/src/store/db.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
|
|
|
4
4
|
import { SCHEMA_SQL } from './schema.js';
|
|
5
5
|
import { AtomicLockCoordinator } from './atomic-lock-coordinator.js';
|
|
6
6
|
import { canonicalStoragePathSync } from './canonical-storage-path.js';
|
|
7
|
+
import { isBunRuntime, loadBetterSqlite3 } from './sqlite-native.js';
|
|
7
8
|
|
|
8
9
|
type StatementLike = {
|
|
9
10
|
run: (...args: any[]) => any;
|
|
@@ -69,6 +70,7 @@ class DatabaseCorruptionError extends Error {
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
export const SQLITE_BUSY_TIMEOUT_MS = 5000;
|
|
72
74
|
export const SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000;
|
|
73
75
|
|
|
74
76
|
const DATABASE_FILE_SUFFIXES: readonly DatabaseFileSuffix[] = ['', '-wal', '-shm'];
|
|
@@ -118,24 +120,23 @@ function createBunCompatDatabaseCtor(require: NodeRequire): DatabaseCtor {
|
|
|
118
120
|
};
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
123
|
+
let cachedDatabaseCtor: DatabaseCtor | null = null;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolved on first use, never at import time. A module-scope native load turns
|
|
127
|
+
* any SQLite resolve/ABI failure into "Failed to load extension", which hides
|
|
128
|
+
* the actionable rebuild message and bricks the whole extension (issue #117).
|
|
129
|
+
*/
|
|
130
|
+
function getDatabaseCtor(): DatabaseCtor {
|
|
131
|
+
if (!cachedDatabaseCtor) {
|
|
132
|
+
const require = createRequire(import.meta.url);
|
|
133
|
+
cachedDatabaseCtor = isBunRuntime()
|
|
134
|
+
? createBunCompatDatabaseCtor(require)
|
|
135
|
+
: (loadBetterSqlite3({ requireImpl: require }) as DatabaseCtor);
|
|
134
136
|
}
|
|
137
|
+
return cachedDatabaseCtor;
|
|
135
138
|
}
|
|
136
139
|
|
|
137
|
-
const Database = loadDatabaseCtor();
|
|
138
|
-
|
|
139
140
|
export class DatabaseManager {
|
|
140
141
|
private db: DatabaseLike | null = null;
|
|
141
142
|
private readonly displayDbPath: string;
|
|
@@ -273,7 +274,7 @@ export class DatabaseManager {
|
|
|
273
274
|
|
|
274
275
|
private openUnchecked(): DatabaseLike {
|
|
275
276
|
const existed = this.hasExistingMainDatabaseFile();
|
|
276
|
-
const db = new
|
|
277
|
+
const db = new (getDatabaseCtor())(this.dbPath);
|
|
277
278
|
let ok = false;
|
|
278
279
|
|
|
279
280
|
try {
|
|
@@ -294,6 +295,9 @@ export class DatabaseManager {
|
|
|
294
295
|
}
|
|
295
296
|
|
|
296
297
|
private configureConnection(db: DatabaseLike): void {
|
|
298
|
+
// Wait briefly for concurrent writers across Pi processes instead of failing
|
|
299
|
+
// immediately with SQLITE_BUSY. Connection-local; applies before WAL/schema.
|
|
300
|
+
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
297
301
|
// Enable WAL mode + FK enforcement for each connection. Keep SQLite's
|
|
298
302
|
// default WAL autocheckpoint size; a very aggressive checkpoint cadence
|
|
299
303
|
// increases the chance that abrupt VM/host shutdown catches a checkpoint.
|
|
@@ -361,7 +365,7 @@ export class DatabaseManager {
|
|
|
361
365
|
}
|
|
362
366
|
|
|
363
367
|
private recoverDatabaseFile(cause: unknown, verify: () => void): DatabaseRecoveryResult {
|
|
364
|
-
const coordinator =
|
|
368
|
+
const coordinator = AtomicLockCoordinator.shared(path.join(path.dirname(this.dbPath), '.pi-hermes-locks.sqlite'));
|
|
365
369
|
const lockKey = `recovery:${this.dbPath}`;
|
|
366
370
|
const deadline = Date.now() + Math.max(0, this.recoveryOptions.recoveryLockWaitMs);
|
|
367
371
|
|
|
@@ -434,7 +438,7 @@ export class DatabaseManager {
|
|
|
434
438
|
if (!this.hasExistingMainDatabaseFile()) return false;
|
|
435
439
|
let db: DatabaseLike | null = null;
|
|
436
440
|
try {
|
|
437
|
-
db = new
|
|
441
|
+
db = new (getDatabaseCtor())(this.dbPath);
|
|
438
442
|
this.assertIntegrityOk(db, 'quick_check', 'while joining corruption recovery');
|
|
439
443
|
return true;
|
|
440
444
|
} catch {
|
|
@@ -546,6 +550,7 @@ export class DatabaseManager {
|
|
|
546
550
|
let rebuildOk = false;
|
|
547
551
|
|
|
548
552
|
try {
|
|
553
|
+
const Database = getDatabaseCtor();
|
|
549
554
|
source = new Database(this.dbPath);
|
|
550
555
|
target = new Database(tempPath);
|
|
551
556
|
target.exec('PRAGMA journal_mode = DELETE');
|
package/src/store/fts-query.ts
CHANGED
|
@@ -63,6 +63,37 @@ export function buildFallbackFts5Query(query: string): string | null {
|
|
|
63
63
|
.join(' OR ');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function quoteTerms(terms: string[], separator: string): string {
|
|
67
|
+
return terms.map((term) => `"${term.replace(/"/g, '""')}"`).join(separator);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalize a query as natural language even when it contains uppercase
|
|
72
|
+
* operator words. Queries like "DO NOT USE FIND /" are passed through as raw
|
|
73
|
+
* FTS5 syntax by normalizeFts5Query; when that raw form fails to parse, this
|
|
74
|
+
* produces the quoted-term form to retry with.
|
|
75
|
+
*/
|
|
76
|
+
export function normalizeNaturalLanguageFts5Query(query: string): string {
|
|
77
|
+
const trimmed = query.trim();
|
|
78
|
+
if (trimmed.length === 0) return '';
|
|
79
|
+
|
|
80
|
+
return quoteTerms(collectNaturalLanguageTerms(trimmed), ' ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the broader OR fallback for the same recovery path, ignoring
|
|
85
|
+
* operator detection.
|
|
86
|
+
*/
|
|
87
|
+
export function buildNaturalLanguageFallbackQuery(query: string): string | null {
|
|
88
|
+
const trimmed = query.trim();
|
|
89
|
+
if (trimmed.length === 0) return null;
|
|
90
|
+
|
|
91
|
+
const terms = collectNaturalLanguageTerms(trimmed);
|
|
92
|
+
if (terms.length <= 1) return null;
|
|
93
|
+
|
|
94
|
+
return quoteTerms(terms, ' OR ');
|
|
95
|
+
}
|
|
96
|
+
|
|
66
97
|
export function isFts5QueryError(err: unknown): boolean {
|
|
67
98
|
if (!(err instanceof Error)) return false;
|
|
68
99
|
const msg = err.message.toLowerCase();
|
|
@@ -12,7 +12,7 @@ export async function canonicalMarkdownIdentity(filePath: string): Promise<strin
|
|
|
12
12
|
export async function acquireMarkdownMutationLock(filePath: string): Promise<AtomicLockLease> {
|
|
13
13
|
const identity = await canonicalMarkdownIdentity(filePath);
|
|
14
14
|
const coordinatorDir = path.dirname(path.dirname(identity));
|
|
15
|
-
const coordinator =
|
|
15
|
+
const coordinator = AtomicLockCoordinator.shared(path.join(coordinatorDir, ".pi-hermes-locks.sqlite"));
|
|
16
16
|
const lockKey = `mutation:${identity}`;
|
|
17
17
|
const deadline = Date.now() + MUTATION_WAIT_MS;
|
|
18
18
|
let lease = coordinator.tryAcquire(lockKey, { staleMs: MUTATION_STALE_MS });
|
|
@@ -587,6 +587,46 @@ export class MemoryStore {
|
|
|
587
587
|
this.fileFingerprints[filePath] = state.fingerprint;
|
|
588
588
|
}
|
|
589
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Reload target state from disk (source of truth), refresh success metadata,
|
|
592
|
+
* and always notify the mutation observer so SQLite stays aligned even when
|
|
593
|
+
* the mutation itself failed or an external editor raced the write.
|
|
594
|
+
*/
|
|
595
|
+
private async finalizeTargetMutation(
|
|
596
|
+
target: "memory" | "user" | "failure",
|
|
597
|
+
storagePath: string,
|
|
598
|
+
result: MemoryResult,
|
|
599
|
+
): Promise<MemoryResult> {
|
|
600
|
+
const state = await this.readFileState(storagePath);
|
|
601
|
+
this.setEntries(target, [...new Set(state.entries)]);
|
|
602
|
+
this.fileFingerprints[storagePath] = state.fingerprint;
|
|
603
|
+
|
|
604
|
+
let finalized = result;
|
|
605
|
+
if (result.success) {
|
|
606
|
+
finalized = {
|
|
607
|
+
...result,
|
|
608
|
+
...this.successResponse(target, result.message),
|
|
609
|
+
};
|
|
610
|
+
if (result.evicted_entries) finalized.evicted_entries = result.evicted_entries;
|
|
611
|
+
if (result.evicted_count !== undefined) finalized.evicted_count = result.evicted_count;
|
|
612
|
+
if (result.matches) finalized.matches = result.matches;
|
|
613
|
+
if (result.entries) finalized.entries = result.entries;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!this.mutationObserver) return finalized;
|
|
617
|
+
|
|
618
|
+
const warning = await this.mutationObserver(target, [...state.entries]);
|
|
619
|
+
if (!warning || !finalized.success) return finalized;
|
|
620
|
+
|
|
621
|
+
const warnings = [...(finalized.warnings ?? []), warning];
|
|
622
|
+
return {
|
|
623
|
+
...finalized,
|
|
624
|
+
message: finalized.message ? `${finalized.message} Warning: ${warning}` : warning,
|
|
625
|
+
warning,
|
|
626
|
+
warnings,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
590
630
|
private async runTargetMutation(
|
|
591
631
|
target: "memory" | "user" | "failure",
|
|
592
632
|
mutation: () => Promise<MemoryResult>,
|
|
@@ -596,35 +636,32 @@ export class MemoryStore {
|
|
|
596
636
|
for (let attempt = 0; ; attempt++) {
|
|
597
637
|
try {
|
|
598
638
|
const result = await mutation();
|
|
599
|
-
if (result.success
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
this.fileFingerprints[
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
warnings,
|
|
612
|
-
};
|
|
639
|
+
if (result.success) {
|
|
640
|
+
// saveToDisk stamps fileFingerprints on success. If an editor
|
|
641
|
+
// truncates/replaces the file after publish returns, refuse the
|
|
642
|
+
// phantom success and retry against disk truth.
|
|
643
|
+
const expectedFingerprint = this.fileFingerprints[storagePath];
|
|
644
|
+
if (expectedFingerprint !== undefined) {
|
|
645
|
+
const state = await this.readFileState(storagePath);
|
|
646
|
+
if (state.fingerprint !== expectedFingerprint) {
|
|
647
|
+
this.setEntries(target, [...new Set(state.entries)]);
|
|
648
|
+
this.fileFingerprints[storagePath] = state.fingerprint;
|
|
649
|
+
throw new ExternalMemoryWriteConflict();
|
|
650
|
+
}
|
|
613
651
|
}
|
|
614
652
|
}
|
|
615
|
-
return result;
|
|
653
|
+
return await this.finalizeTargetMutation(target, storagePath, result);
|
|
616
654
|
} catch (error) {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const state = await this.readFileState(filePath);
|
|
655
|
+
delete this.fileFingerprints[storagePath];
|
|
656
|
+
const state = await this.readFileState(storagePath);
|
|
620
657
|
this.setEntries(target, [...new Set(state.entries)]);
|
|
621
|
-
this.fileFingerprints[
|
|
658
|
+
this.fileFingerprints[storagePath] = state.fingerprint;
|
|
622
659
|
if (!(error instanceof ExternalMemoryWriteConflict)) throw error;
|
|
623
660
|
if (attempt >= MAX_EXTERNAL_WRITE_RETRIES) {
|
|
624
|
-
return {
|
|
661
|
+
return await this.finalizeTargetMutation(target, storagePath, {
|
|
625
662
|
success: false,
|
|
626
|
-
error: "Memory file changed repeatedly during this update. No external changes were overwritten.",
|
|
627
|
-
};
|
|
663
|
+
error: "Memory file changed repeatedly during this update. No external changes were overwritten. If you edited the file manually, re-run the memory tool or /memory-sync-markdown after the file is stable.",
|
|
664
|
+
});
|
|
628
665
|
}
|
|
629
666
|
}
|
|
630
667
|
}
|
|
@@ -718,7 +755,18 @@ export class MemoryStore {
|
|
|
718
755
|
}
|
|
719
756
|
|
|
720
757
|
try { await this.unlinkPublishedTempLink(tmpPath); } catch { /* ignore */ }
|
|
721
|
-
|
|
758
|
+
|
|
759
|
+
// Re-read after publish. An external truncate/cp can land between the
|
|
760
|
+
// link/rename and returning success; treat that as a write conflict so
|
|
761
|
+
// the caller retries against disk truth instead of reporting phantom state.
|
|
762
|
+
const publishedFingerprint = this.fingerprint(content);
|
|
763
|
+
this.fileFingerprints[filePath] = publishedFingerprint;
|
|
764
|
+
const publishedState = await this.readFileState(filePath);
|
|
765
|
+
if (publishedState.fingerprint !== publishedFingerprint) {
|
|
766
|
+
this.setEntries(target, [...new Set(publishedState.entries)]);
|
|
767
|
+
this.fileFingerprints[filePath] = publishedState.fingerprint;
|
|
768
|
+
throw new ExternalMemoryWriteConflict();
|
|
769
|
+
}
|
|
722
770
|
} catch (err) {
|
|
723
771
|
try { await fs.unlink(tmpPath); } catch { /* ignore */ }
|
|
724
772
|
throw err;
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { DatabaseManager } from './db.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildFallbackFts5Query,
|
|
4
|
+
buildNaturalLanguageFallbackQuery,
|
|
5
|
+
hasExplicitFts5Operator,
|
|
6
|
+
isFts5QueryError,
|
|
7
|
+
normalizeFts5Query,
|
|
8
|
+
normalizeNaturalLanguageFts5Query,
|
|
9
|
+
} from './fts-query.js';
|
|
3
10
|
|
|
4
11
|
/**
|
|
5
12
|
* Search result from session history.
|
|
@@ -93,6 +100,8 @@ export function searchSessions(
|
|
|
93
100
|
const db = dbManager.getDb();
|
|
94
101
|
const { limit = 10, project, role, since } = options;
|
|
95
102
|
|
|
103
|
+
let ftsParseError = false;
|
|
104
|
+
|
|
96
105
|
const executeSearch = (match: SearchMatch): SessionSearchResult[] => {
|
|
97
106
|
const conditions: string[] = [];
|
|
98
107
|
const params: unknown[] = [];
|
|
@@ -160,6 +169,7 @@ export function searchSessions(
|
|
|
160
169
|
return mapRows(rows);
|
|
161
170
|
} catch (err) {
|
|
162
171
|
if (match.type === 'fts' && isFts5QueryError(err)) {
|
|
172
|
+
ftsParseError = true;
|
|
163
173
|
return [];
|
|
164
174
|
}
|
|
165
175
|
throw err;
|
|
@@ -178,7 +188,28 @@ export function searchSessions(
|
|
|
178
188
|
|
|
179
189
|
const explicitOperatorQuery = hasExplicitFts5Operator(query);
|
|
180
190
|
if (explicitOperatorQuery) {
|
|
181
|
-
|
|
191
|
+
// Same recovery as searchMemories: only when the raw operator query fails
|
|
192
|
+
// to parse do we retry it as natural language; valid operator queries that
|
|
193
|
+
// match nothing keep their exact semantics.
|
|
194
|
+
if (!ftsParseError) {
|
|
195
|
+
return exactResults;
|
|
196
|
+
}
|
|
197
|
+
const nlQuery = normalizeNaturalLanguageFts5Query(query);
|
|
198
|
+
if (nlQuery.length > 0 && nlQuery !== normalizedQuery) {
|
|
199
|
+
const nlResults = executeSearch({ type: 'fts', query: nlQuery });
|
|
200
|
+
if (nlResults.length > 0) {
|
|
201
|
+
return nlResults;
|
|
202
|
+
}
|
|
203
|
+
const nlFallback = buildNaturalLanguageFallbackQuery(query);
|
|
204
|
+
if (nlFallback && nlFallback !== nlQuery) {
|
|
205
|
+
const nlFallbackResults = executeSearch({ type: 'fts', query: nlFallback });
|
|
206
|
+
if (nlFallbackResults.length > 0) {
|
|
207
|
+
return nlFallbackResults;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const likeTerms = collectLikeTerms(query);
|
|
212
|
+
return executeSearch({ type: 'like', terms: likeTerms });
|
|
182
213
|
}
|
|
183
214
|
|
|
184
215
|
const fallbackQuery = buildFallbackFts5Query(query);
|
package/src/store/skill-store.ts
CHANGED
|
@@ -27,8 +27,9 @@ interface SkillStoreOptions {
|
|
|
27
27
|
projectSkillsDir?: string | null;
|
|
28
28
|
projectName?: string | null;
|
|
29
29
|
legacySkillsDir?: string;
|
|
30
|
-
|
|
30
|
+
legacyExtensionSkillsDir?: string;
|
|
31
31
|
migrationSentinelPath?: string;
|
|
32
|
+
extensionSkillsMigrationSentinelPath?: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
interface SkillLocation {
|
|
@@ -46,13 +47,119 @@ export interface LegacySkillMigrationResult {
|
|
|
46
47
|
warnings: string[];
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
const LIST_SECTIONS = new Set(["procedure", "pitfalls", "verification"]);
|
|
51
|
+
|
|
52
|
+
function normalizeSectionName(section: string): string {
|
|
53
|
+
return section.replace(/^#+\s*/, "").trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isExactSectionHeader(line: string, section: string): boolean {
|
|
57
|
+
const heading = line.trim().match(/^##\s+(.+?)\s*$/);
|
|
58
|
+
if (!heading) return false;
|
|
59
|
+
return heading[1].trim().toLowerCase() === section.trim().toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function looksLikeJsonArray(content: string): boolean {
|
|
63
|
+
const trimmed = content.trim();
|
|
64
|
+
return trimmed.startsWith("[") && trimmed.endsWith("]");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function looksLikeJsonObject(content: string): boolean {
|
|
68
|
+
const trimmed = content.trim();
|
|
69
|
+
return trimmed.startsWith("{") && trimmed.endsWith("}");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatPatchList(section: string, items: string[]): string {
|
|
73
|
+
const cleaned = items.map((item) => item.trim()).filter(Boolean);
|
|
74
|
+
if (cleaned.length === 0) return "";
|
|
75
|
+
const key = section.trim().toLowerCase();
|
|
76
|
+
if (key === "pitfalls") {
|
|
77
|
+
return cleaned.map((item) => `- ${item.replace(/^[-*]\s+/, "")}`).join("\n");
|
|
78
|
+
}
|
|
79
|
+
// Procedure / Verification default to ordered steps.
|
|
80
|
+
return cleaned
|
|
81
|
+
.map((item, index) => `${index + 1}. ${item.replace(/^\d+\.\s+/, "").replace(/^[-*]\s+/, "")}`)
|
|
82
|
+
.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Normalize/validate free-form patch content so LLM format drift cannot wipe
|
|
87
|
+
* or splice skill sections. Coerces JSON string arrays into Markdown lists for
|
|
88
|
+
* list-shaped sections and rejects empty / object / header-injecting payloads.
|
|
89
|
+
*/
|
|
90
|
+
export function normalizeSkillPatchContent(
|
|
91
|
+
section: string,
|
|
92
|
+
rawContent: string,
|
|
93
|
+
): { content: string } | { error: string } {
|
|
94
|
+
const sectionName = normalizeSectionName(section);
|
|
95
|
+
if (!sectionName) {
|
|
96
|
+
return { error: "section is required for patch." };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let content = typeof rawContent === "string" ? rawContent.trim() : "";
|
|
100
|
+
if (!content) {
|
|
101
|
+
return {
|
|
102
|
+
error: "New content is required for patch. Prefer structured fields (procedure_steps, pitfalls, verification_steps, when_to_use) over free-form content.",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (looksLikeJsonObject(content)) {
|
|
107
|
+
return {
|
|
108
|
+
error: "Patch content looks like a JSON object. Provide Markdown section body or a string array via structured fields.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (looksLikeJsonArray(content)) {
|
|
113
|
+
try {
|
|
114
|
+
const parsed: unknown = JSON.parse(content);
|
|
115
|
+
if (!Array.isArray(parsed)) {
|
|
116
|
+
return { error: "Patch content looks like JSON but is not a string array." };
|
|
117
|
+
}
|
|
118
|
+
const items = parsed
|
|
119
|
+
.filter((item): item is string => typeof item === "string")
|
|
120
|
+
.map((item) => item.trim())
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
if (items.length === 0) {
|
|
123
|
+
return { error: "Patch content JSON array must contain non-empty strings." };
|
|
124
|
+
}
|
|
125
|
+
const key = sectionName.toLowerCase();
|
|
126
|
+
if (key === "when to use") {
|
|
127
|
+
content = items.join("\n\n");
|
|
128
|
+
} else if (LIST_SECTIONS.has(key)) {
|
|
129
|
+
content = formatPatchList(sectionName, items);
|
|
130
|
+
} else {
|
|
131
|
+
content = items.map((item) => `- ${item}`).join("\n");
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
return {
|
|
135
|
+
error: "Patch content looks like a JSON array but could not be parsed. Use Markdown or structured string[] fields.",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Reject payloads that would inject extra ## sections mid-body.
|
|
141
|
+
if (/^#{1,6}\s+\S/m.test(content)) {
|
|
142
|
+
return {
|
|
143
|
+
error: "Patch content must not include Markdown section headers (## ...). Patch only the body of the target section.",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!content.trim()) {
|
|
148
|
+
return { error: "New content is required for patch." };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { content: content.trim() };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
49
155
|
export class SkillStore {
|
|
50
156
|
private globalSkillsDir: string;
|
|
51
157
|
private projectSkillsDir: string | null;
|
|
52
158
|
private projectName: string | null;
|
|
53
159
|
private legacySkillsDir: string;
|
|
54
|
-
private
|
|
160
|
+
private legacyExtensionSkillsDir: string;
|
|
55
161
|
private migrationSentinelPath: string;
|
|
162
|
+
private extensionSkillsMigrationSentinelPath: string;
|
|
56
163
|
|
|
57
164
|
constructor(options: SkillStoreOptions = {}) {
|
|
58
165
|
const agentRoot = AGENT_ROOT;
|
|
@@ -60,9 +167,12 @@ export class SkillStore {
|
|
|
60
167
|
this.projectSkillsDir = options.projectSkillsDir ?? null;
|
|
61
168
|
this.projectName = options.projectName ?? null;
|
|
62
169
|
this.legacySkillsDir = options.legacySkillsDir ?? path.join(agentRoot, "memory", "skills");
|
|
63
|
-
this.
|
|
170
|
+
this.legacyExtensionSkillsDir = options.legacyExtensionSkillsDir
|
|
171
|
+
?? path.join(agentRoot, "pi-hermes-memory", "skills");
|
|
64
172
|
this.migrationSentinelPath = options.migrationSentinelPath
|
|
65
173
|
?? path.join(agentRoot, "pi-hermes-memory", ".skills-migrated-to-extension-storage");
|
|
174
|
+
this.extensionSkillsMigrationSentinelPath = options.extensionSkillsMigrationSentinelPath
|
|
175
|
+
?? path.join(agentRoot, "pi-hermes-memory", ".skills-migrated-to-pi-global");
|
|
66
176
|
}
|
|
67
177
|
|
|
68
178
|
getGlobalSkillsDir(): string {
|
|
@@ -95,16 +205,19 @@ export class SkillStore {
|
|
|
95
205
|
// Always normalize flat markdown files under the global skills root,
|
|
96
206
|
// even when a previous migration sentinel already exists.
|
|
97
207
|
await this.migrateFlatMarkdownInGlobalSkillsDir(result);
|
|
208
|
+
await this.migrateExtensionSkillsToGlobalRoot(result);
|
|
98
209
|
|
|
99
210
|
if (await exists(this.migrationSentinelPath)) return result;
|
|
100
211
|
|
|
101
212
|
await fs.mkdir(path.dirname(this.migrationSentinelPath), { recursive: true });
|
|
102
213
|
|
|
214
|
+
// Only this migration's own warnings may hold back its sentinel — a
|
|
215
|
+
// permanently shadowed skill reported above must not make it retry forever.
|
|
216
|
+
const warningsBefore = result.warnings.length;
|
|
103
217
|
try {
|
|
104
218
|
await this.migrateLegacyMarkdownSkills(result);
|
|
105
|
-
await this.migrateLegacyPiGlobalSkillDirs(result);
|
|
106
219
|
} finally {
|
|
107
|
-
if (result.warnings.length ===
|
|
220
|
+
if (result.warnings.length === warningsBefore) {
|
|
108
221
|
await fs.writeFile(this.migrationSentinelPath, `${new Date().toISOString()}\n`, "utf-8");
|
|
109
222
|
}
|
|
110
223
|
}
|
|
@@ -202,45 +315,61 @@ export class SkillStore {
|
|
|
202
315
|
}
|
|
203
316
|
}
|
|
204
317
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Move extension-managed global skills back into Pi's own global skills root.
|
|
320
|
+
*
|
|
321
|
+
* Pi keys skills by name, first-loaded wins, and `~/.pi/agent/skills/` is
|
|
322
|
+
* auto-discovered at higher precedence than anything an extension contributes
|
|
323
|
+
* via `resources_discover`. While global skills lived in a private directory,
|
|
324
|
+
* any name that also existed in Pi's root was permanently shadowed: the
|
|
325
|
+
* collision warning fired every session and `skill_manage` edits silently had
|
|
326
|
+
* no effect on the skill the agent actually loaded (#125, #126).
|
|
327
|
+
*
|
|
328
|
+
* Runs on every startup until it completes with no shadowed names, since a
|
|
329
|
+
* shadowed skill can only be resolved by the user renaming one of the two.
|
|
330
|
+
*/
|
|
331
|
+
private async migrateExtensionSkillsToGlobalRoot(result: LegacySkillMigrationResult): Promise<void> {
|
|
332
|
+
if (path.resolve(this.legacyExtensionSkillsDir) === path.resolve(this.globalSkillsDir)) return;
|
|
333
|
+
if (await exists(this.extensionSkillsMigrationSentinelPath)) return;
|
|
334
|
+
if (!await exists(this.legacyExtensionSkillsDir)) return;
|
|
335
|
+
|
|
336
|
+
const entries = await fs.readdir(this.legacyExtensionSkillsDir, { withFileTypes: true });
|
|
337
|
+
let shadowed = 0;
|
|
208
338
|
|
|
209
|
-
const entries = await fs.readdir(this.legacyPiGlobalSkillsDir, { withFileTypes: true });
|
|
210
339
|
for (const entry of entries) {
|
|
211
340
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
212
341
|
|
|
213
|
-
const sourceDir = path.join(this.
|
|
214
|
-
|
|
215
|
-
if (!await exists(sourceSkill)) continue;
|
|
342
|
+
const sourceDir = path.join(this.legacyExtensionSkillsDir, entry.name);
|
|
343
|
+
if (!await exists(path.join(sourceDir, "SKILL.md"))) continue;
|
|
216
344
|
|
|
217
345
|
const targetDir = path.join(this.globalSkillsDir, entry.name);
|
|
218
|
-
|
|
219
|
-
|
|
346
|
+
if (await exists(path.join(targetDir, "SKILL.md"))) {
|
|
347
|
+
// Pi already loads the copy in its own root; ours was never reachable.
|
|
348
|
+
// Leave both in place rather than clobbering a skill we did not write.
|
|
349
|
+
shadowed++;
|
|
220
350
|
result.skipped++;
|
|
351
|
+
result.warnings.push(
|
|
352
|
+
`${entry.name}: already exists at ${targetDir}; the copy at ${sourceDir} was never loaded by Pi. `
|
|
353
|
+
+ `Rename or delete one of them, then restart Pi.`,
|
|
354
|
+
);
|
|
221
355
|
continue;
|
|
222
356
|
}
|
|
223
357
|
|
|
224
358
|
try {
|
|
225
|
-
const raw = await fs.readFile(sourceSkill, "utf-8");
|
|
226
|
-
const parsed = parseFrontmatter(raw);
|
|
227
|
-
const hasExtensionManagedMeta = Boolean(parsed.meta.display_name)
|
|
228
|
-
&& Boolean(parsed.meta.created)
|
|
229
|
-
&& Boolean(parsed.meta.updated)
|
|
230
|
-
&& /^\d+$/.test(parsed.meta.version ?? "");
|
|
231
|
-
|
|
232
|
-
if (!hasExtensionManagedMeta) {
|
|
233
|
-
result.skipped++;
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
359
|
await fs.mkdir(path.dirname(targetDir), { recursive: true });
|
|
238
360
|
await fs.rename(sourceDir, targetDir);
|
|
239
361
|
result.migrated++;
|
|
240
362
|
} catch (error) {
|
|
363
|
+
shadowed++;
|
|
241
364
|
result.warnings.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
242
365
|
}
|
|
243
366
|
}
|
|
367
|
+
|
|
368
|
+
if (shadowed === 0) {
|
|
369
|
+
await fs.mkdir(path.dirname(this.extensionSkillsMigrationSentinelPath), { recursive: true });
|
|
370
|
+
await fs.writeFile(this.extensionSkillsMigrationSentinelPath, `${new Date().toISOString()}\n`, "utf-8");
|
|
371
|
+
await fs.rm(this.legacyExtensionSkillsDir, { recursive: true, force: true });
|
|
372
|
+
}
|
|
244
373
|
}
|
|
245
374
|
|
|
246
375
|
async loadIndex(scope?: SkillScope): Promise<SkillIndex[]> {
|
|
@@ -352,34 +481,47 @@ export class SkillStore {
|
|
|
352
481
|
}
|
|
353
482
|
|
|
354
483
|
async patch(skillId: string, section: string, newContent: string): Promise<SkillResult> {
|
|
355
|
-
|
|
356
|
-
if (!
|
|
484
|
+
const sectionName = normalizeSectionName(section);
|
|
485
|
+
if (!sectionName) return { success: false, error: "section is required for patch." };
|
|
486
|
+
|
|
487
|
+
const normalized = normalizeSkillPatchContent(sectionName, newContent);
|
|
488
|
+
if ("error" in normalized) return { success: false, error: normalized.error };
|
|
489
|
+
const content = normalized.content;
|
|
357
490
|
|
|
358
|
-
const scanError = scanContent(
|
|
491
|
+
const scanError = scanContent(content);
|
|
359
492
|
if (scanError) return { success: false, error: scanError };
|
|
360
493
|
|
|
361
494
|
const doc = await this.loadSkill(skillId);
|
|
362
495
|
if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
|
|
363
496
|
|
|
364
|
-
const sectionHeader = `## ${
|
|
497
|
+
const sectionHeader = `## ${sectionName}`;
|
|
365
498
|
const lines = doc.body.split("\n");
|
|
366
499
|
let found = false;
|
|
367
500
|
const result: string[] = [];
|
|
368
501
|
|
|
369
502
|
for (let i = 0; i < lines.length; i++) {
|
|
370
|
-
if (lines[i]
|
|
503
|
+
if (isExactSectionHeader(lines[i], sectionName)) {
|
|
371
504
|
result.push(sectionHeader);
|
|
372
|
-
|
|
505
|
+
// Preserve multi-line body as discrete lines so later headers stay exact.
|
|
506
|
+
for (const bodyLine of content.split("\n")) {
|
|
507
|
+
result.push(bodyLine);
|
|
508
|
+
}
|
|
373
509
|
found = true;
|
|
374
510
|
i++;
|
|
375
|
-
while (i < lines.length && !lines[i].startsWith("## ")) i++;
|
|
511
|
+
while (i < lines.length && !lines[i].trim().startsWith("## ")) i++;
|
|
376
512
|
if (i < lines.length) result.push(lines[i]);
|
|
377
513
|
} else {
|
|
378
514
|
result.push(lines[i]);
|
|
379
515
|
}
|
|
380
516
|
}
|
|
381
517
|
|
|
382
|
-
if (!found)
|
|
518
|
+
if (!found) {
|
|
519
|
+
if (result.length > 0 && result[result.length - 1] !== "") result.push("");
|
|
520
|
+
result.push(sectionHeader);
|
|
521
|
+
for (const bodyLine of content.split("\n")) {
|
|
522
|
+
result.push(bodyLine);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
383
525
|
|
|
384
526
|
await this.atomicWrite(doc.path, formatFrontmatter({
|
|
385
527
|
name: doc.name,
|
|
@@ -393,7 +535,7 @@ export class SkillStore {
|
|
|
393
535
|
|
|
394
536
|
return {
|
|
395
537
|
success: true,
|
|
396
|
-
message: `Skill '${doc.displayName || doc.name}' section '${
|
|
538
|
+
message: `Skill '${doc.displayName || doc.name}' section '${sectionName}' updated.`,
|
|
397
539
|
fileName: doc.fileName,
|
|
398
540
|
skillId: doc.skillId,
|
|
399
541
|
scope: doc.scope,
|