pi-hermes-memory 0.7.10 → 0.7.12
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 +36 -7
- package/docs/images/source-architecture.svg +1 -1
- package/docs/mermaid/source-architecture.mmd +0 -4
- package/package.json +1 -1
- package/src/config.ts +9 -2
- package/src/constants.ts +42 -6
- package/src/handlers/auto-consolidate.ts +46 -15
- package/src/handlers/learn-memory.ts +1 -1
- package/src/handlers/skills-command.ts +116 -15
- package/src/handlers/sync-markdown-memories.ts +10 -4
- package/src/index.ts +18 -16
- package/src/paths.ts +57 -0
- package/src/project-memory-migration.ts +1 -2
- package/src/project.ts +2 -1
- package/src/store/db.ts +3 -0
- package/src/store/fts-query.ts +38 -0
- package/src/store/session-search.ts +14 -16
- package/src/store/skill-store.ts +7 -3
- package/src/store/sqlite-memory-store.ts +31 -28
- package/src/tools/skill-tool.ts +166 -31
- package/src/types.ts +5 -5
- package/src/handlers/skill-auto-trigger.ts +0 -128
- package/src/skills/procedural-skill-creator/SKILL.md +0 -146
|
@@ -12,24 +12,38 @@ import { MemoryStore } from "../store/memory-store.js";
|
|
|
12
12
|
import { CONSOLIDATION_PROMPT, ENTRY_DELIMITER } from "../constants.js";
|
|
13
13
|
import type { ConsolidationResult } from "../types.js";
|
|
14
14
|
|
|
15
|
+
type MemoryTarget = "memory" | "user" | "failure";
|
|
16
|
+
type ToolMemoryTarget = MemoryTarget | "project";
|
|
17
|
+
|
|
18
|
+
function entriesForTarget(store: MemoryStore, target: MemoryTarget): string[] {
|
|
19
|
+
return target === "user" ? store.getUserEntries() : store.getMemoryEntries();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function labelForTarget(target: MemoryTarget, toolTarget: ToolMemoryTarget): string {
|
|
23
|
+
if (toolTarget === "project") return "Project Memory";
|
|
24
|
+
if (target === "user") return "User Profile";
|
|
25
|
+
if (target === "failure") return "Failure Memory";
|
|
26
|
+
return "Memory";
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
export async function triggerConsolidation(
|
|
16
30
|
pi: ExtensionAPI,
|
|
17
31
|
store: MemoryStore,
|
|
18
|
-
target:
|
|
32
|
+
target: MemoryTarget,
|
|
19
33
|
signal?: AbortSignal,
|
|
20
34
|
timeoutMs: number = 60000,
|
|
35
|
+
toolTarget: ToolMemoryTarget = target,
|
|
21
36
|
): Promise<ConsolidationResult> {
|
|
22
|
-
const entries =
|
|
23
|
-
target === "memory" ? store.getMemoryEntries() : store.getUserEntries();
|
|
37
|
+
const entries = entriesForTarget(store, target);
|
|
24
38
|
const currentContent = entries.join(ENTRY_DELIMITER);
|
|
25
39
|
|
|
26
40
|
const prompt = [
|
|
27
41
|
CONSOLIDATION_PROMPT,
|
|
28
42
|
"",
|
|
29
|
-
`--- Current ${target
|
|
43
|
+
`--- Current ${labelForTarget(target, toolTarget)} Entries ---`,
|
|
30
44
|
currentContent || "(empty)",
|
|
31
45
|
"",
|
|
32
|
-
`Use the memory tool to consolidate. Target: '${
|
|
46
|
+
`Use the memory tool to consolidate. Target: '${toolTarget}'`,
|
|
33
47
|
].join("\n");
|
|
34
48
|
|
|
35
49
|
try {
|
|
@@ -60,30 +74,47 @@ export function registerConsolidateCommand(
|
|
|
60
74
|
pi: ExtensionAPI,
|
|
61
75
|
store: MemoryStore,
|
|
62
76
|
timeoutMs: number = 60000,
|
|
77
|
+
projectStore: MemoryStore | null = null,
|
|
78
|
+
projectName?: string | null,
|
|
63
79
|
): void {
|
|
64
80
|
pi.registerCommand("memory-consolidate", {
|
|
65
81
|
description: "Manually trigger memory consolidation to free up space",
|
|
66
82
|
handler: async (_args, ctx) => {
|
|
67
83
|
const results: string[] = [];
|
|
84
|
+
const targets: Array<{
|
|
85
|
+
label: string;
|
|
86
|
+
store: MemoryStore;
|
|
87
|
+
target: MemoryTarget;
|
|
88
|
+
toolTarget: ToolMemoryTarget;
|
|
89
|
+
}> = [
|
|
90
|
+
{ label: "memory", store, target: "memory", toolTarget: "memory" },
|
|
91
|
+
{ label: "user", store, target: "user", toolTarget: "user" },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
if (projectStore) {
|
|
95
|
+
targets.push({
|
|
96
|
+
label: projectName ? `project:${projectName}` : "project",
|
|
97
|
+
store: projectStore,
|
|
98
|
+
target: "memory",
|
|
99
|
+
toolTarget: "project",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
68
102
|
|
|
69
|
-
for (const
|
|
70
|
-
const entries =
|
|
71
|
-
target === "memory"
|
|
72
|
-
? store.getMemoryEntries()
|
|
73
|
-
: store.getUserEntries();
|
|
103
|
+
for (const item of targets) {
|
|
104
|
+
const entries = entriesForTarget(item.store, item.target);
|
|
74
105
|
|
|
75
106
|
if (entries.length === 0) {
|
|
76
|
-
results.push(`${
|
|
107
|
+
results.push(`${item.label}: (empty, nothing to consolidate)`);
|
|
77
108
|
continue;
|
|
78
109
|
}
|
|
79
110
|
|
|
80
|
-
const result = await triggerConsolidation(pi, store, target, ctx.signal, timeoutMs);
|
|
111
|
+
const result = await triggerConsolidation(pi, item.store, item.target, ctx.signal, timeoutMs, item.toolTarget);
|
|
81
112
|
|
|
82
113
|
if (result.consolidated) {
|
|
83
|
-
await store.loadFromDisk();
|
|
84
|
-
results.push(`${
|
|
114
|
+
await item.store.loadFromDisk();
|
|
115
|
+
results.push(`${item.label}: ✅ consolidated`);
|
|
85
116
|
} else {
|
|
86
|
-
results.push(`${
|
|
117
|
+
results.push(`${item.label}: ❌ ${result.error}`);
|
|
87
118
|
}
|
|
88
119
|
}
|
|
89
120
|
|
|
@@ -63,7 +63,7 @@ export function registerLearnMemoryCommand(pi: ExtensionAPI): void {
|
|
|
63
63
|
lines.push(" Save, update, or delete memories");
|
|
64
64
|
lines.push(" Targets: memory, user, failure, project");
|
|
65
65
|
lines.push("");
|
|
66
|
-
lines.push(" skill (create/view/patch/
|
|
66
|
+
lines.push(" skill (create/view/patch/update/delete)");
|
|
67
67
|
lines.push(" Save reusable procedures");
|
|
68
68
|
lines.push("");
|
|
69
69
|
lines.push(" session_search");
|
|
@@ -24,6 +24,7 @@ export const MEMORY_SKILLS_KEYMAP = {
|
|
|
24
24
|
moveGlobal: "g",
|
|
25
25
|
moveProject: "p",
|
|
26
26
|
deleteSelected: "d",
|
|
27
|
+
cycleSort: "s",
|
|
27
28
|
selectAllFiltered: "a",
|
|
28
29
|
clearSelection: "n",
|
|
29
30
|
focusSearch: "/",
|
|
@@ -34,6 +35,7 @@ export const MEMORY_SKILLS_KEYMAP = {
|
|
|
34
35
|
} as const;
|
|
35
36
|
|
|
36
37
|
export type SkillRowCategory = "G" | "P" | "E";
|
|
38
|
+
export type SkillSortMode = "updated" | "created" | "name";
|
|
37
39
|
|
|
38
40
|
export interface SkillModalRow {
|
|
39
41
|
skillId: string;
|
|
@@ -45,6 +47,8 @@ export interface SkillModalRow {
|
|
|
45
47
|
description: string;
|
|
46
48
|
path: string;
|
|
47
49
|
displayPath: string;
|
|
50
|
+
created?: string;
|
|
51
|
+
updated?: string;
|
|
48
52
|
projectName?: string;
|
|
49
53
|
selected: boolean;
|
|
50
54
|
searchText: string;
|
|
@@ -162,6 +166,70 @@ function categoryOrder(category: SkillRowCategory): number {
|
|
|
162
166
|
}
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
function recencyValue(row: Pick<SkillModalRow, "updated" | "created">): string {
|
|
170
|
+
return row.updated || row.created || "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function sortModeLabel(sortMode: SkillSortMode): string {
|
|
174
|
+
switch (sortMode) {
|
|
175
|
+
case "updated":
|
|
176
|
+
return "Updated";
|
|
177
|
+
case "created":
|
|
178
|
+
return "Created";
|
|
179
|
+
case "name":
|
|
180
|
+
return "Name";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function nextSortMode(sortMode: SkillSortMode): SkillSortMode {
|
|
185
|
+
switch (sortMode) {
|
|
186
|
+
case "updated":
|
|
187
|
+
return "created";
|
|
188
|
+
case "created":
|
|
189
|
+
return "name";
|
|
190
|
+
case "name":
|
|
191
|
+
return "updated";
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function compareSkillRows(a: SkillModalRow, b: SkillModalRow, sortMode: SkillSortMode): number {
|
|
196
|
+
if (sortMode === "name") {
|
|
197
|
+
const byName = a.displayName.localeCompare(b.displayName);
|
|
198
|
+
if (byName !== 0) return byName;
|
|
199
|
+
return categoryOrder(a.category) - categoryOrder(b.category);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const primaryA = sortMode === "updated" ? recencyValue(a) : (a.created || "");
|
|
203
|
+
const primaryB = sortMode === "updated" ? recencyValue(b) : (b.created || "");
|
|
204
|
+
if (primaryA || primaryB) {
|
|
205
|
+
if (!primaryA) return 1;
|
|
206
|
+
if (!primaryB) return -1;
|
|
207
|
+
if (primaryA !== primaryB) return primaryB.localeCompare(primaryA);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (sortMode === "updated") {
|
|
211
|
+
const createdA = a.created || "";
|
|
212
|
+
const createdB = b.created || "";
|
|
213
|
+
if (createdA || createdB) {
|
|
214
|
+
if (!createdA) return 1;
|
|
215
|
+
if (!createdB) return -1;
|
|
216
|
+
if (createdA !== createdB) return createdB.localeCompare(createdA);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
const updatedA = recencyValue(a);
|
|
220
|
+
const updatedB = recencyValue(b);
|
|
221
|
+
if (updatedA || updatedB) {
|
|
222
|
+
if (!updatedA) return 1;
|
|
223
|
+
if (!updatedB) return -1;
|
|
224
|
+
if (updatedA !== updatedB) return updatedB.localeCompare(updatedA);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const byCategory = categoryOrder(a.category) - categoryOrder(b.category);
|
|
229
|
+
if (byCategory !== 0) return byCategory;
|
|
230
|
+
return a.displayName.localeCompare(b.displayName);
|
|
231
|
+
}
|
|
232
|
+
|
|
165
233
|
export function collectLoadedSkillsFromCommands(commands: SkillCommandInfo[]): LoadedSkillRow[] {
|
|
166
234
|
const loaded: LoadedSkillRow[] = [];
|
|
167
235
|
|
|
@@ -214,8 +282,8 @@ export function formatSkillsList(rows: SkillModalRow[], projectName: string | nu
|
|
|
214
282
|
if (rows.length === 0) {
|
|
215
283
|
lines.push(" (no skills found in this session)");
|
|
216
284
|
lines.push("");
|
|
217
|
-
lines.push("
|
|
218
|
-
lines.push("
|
|
285
|
+
lines.push(" Ask the agent to save a reusable procedure");
|
|
286
|
+
lines.push(" with the skill tool when it is worth keeping.");
|
|
219
287
|
return lines.join("\n");
|
|
220
288
|
}
|
|
221
289
|
|
|
@@ -269,6 +337,8 @@ export function buildSkillRows(skills: SkillIndex[], selectedSkillIds = new Set<
|
|
|
269
337
|
description: skill.description,
|
|
270
338
|
path: skill.path,
|
|
271
339
|
displayPath,
|
|
340
|
+
created: skill.created,
|
|
341
|
+
updated: skill.updated,
|
|
272
342
|
projectName: skill.projectName,
|
|
273
343
|
selected: selectedSkillIds.has(skill.skillId),
|
|
274
344
|
searchText: `${displayName} ${skill.name} ${skill.description || ""} ${skill.path} ${displayPath}`.trim(),
|
|
@@ -280,6 +350,7 @@ export function buildUnifiedSkillRows(
|
|
|
280
350
|
managedSkills: SkillIndex[],
|
|
281
351
|
loadedSkills: LoadedSkillRow[],
|
|
282
352
|
selectedSkillIds = new Set<string>(),
|
|
353
|
+
sortMode: SkillSortMode = "updated",
|
|
283
354
|
): SkillModalRow[] {
|
|
284
355
|
const managedRows = buildSkillRows(managedSkills, selectedSkillIds);
|
|
285
356
|
const managedPathKeys = new Set(managedRows.map((row) => normalizePathForKey(row.path)));
|
|
@@ -308,12 +379,7 @@ export function buildUnifiedSkillRows(
|
|
|
308
379
|
});
|
|
309
380
|
}
|
|
310
381
|
|
|
311
|
-
return [...managedRows, ...externalRows]
|
|
312
|
-
.sort((a, b) => {
|
|
313
|
-
const byCategory = categoryOrder(a.category) - categoryOrder(b.category);
|
|
314
|
-
if (byCategory !== 0) return byCategory;
|
|
315
|
-
return a.displayName.localeCompare(b.displayName);
|
|
316
|
-
});
|
|
382
|
+
return [...managedRows, ...externalRows].sort((a, b) => compareSkillRows(a, b, sortMode));
|
|
317
383
|
}
|
|
318
384
|
|
|
319
385
|
export function filterSkillRows(rows: SkillModalRow[], query: string): SkillModalRow[] {
|
|
@@ -534,8 +600,9 @@ export class SkillsManagerModal implements Focusable {
|
|
|
534
600
|
private activeFilters: SkillCategoryFilters = { ...DEFAULT_SKILL_FILTERS };
|
|
535
601
|
private pendingFilters: SkillCategoryFilters | null = null;
|
|
536
602
|
private filterCursor = 0;
|
|
603
|
+
private sortMode: SkillSortMode = "updated";
|
|
537
604
|
private summaryLines: string[] = [
|
|
538
|
-
"Select skills with space, then move with g/p or delete with d. Press f for filters.",
|
|
605
|
+
"Select skills with space, then move with g/p or delete with d. Press s to change sort and f for filters.",
|
|
539
606
|
];
|
|
540
607
|
|
|
541
608
|
constructor(
|
|
@@ -573,9 +640,11 @@ export class SkillsManagerModal implements Focusable {
|
|
|
573
640
|
name: row.name,
|
|
574
641
|
displayName: row.displayName,
|
|
575
642
|
description: row.description,
|
|
643
|
+
created: row.created ?? "",
|
|
644
|
+
updated: row.updated ?? "",
|
|
576
645
|
}));
|
|
577
646
|
|
|
578
|
-
this.rows = buildUnifiedSkillRows(this.managedSkills, this.loadedSkills, selectedSkillIds);
|
|
647
|
+
this.rows = buildUnifiedSkillRows(this.managedSkills, this.loadedSkills, selectedSkillIds, this.sortMode);
|
|
579
648
|
this.syncSearchFocus();
|
|
580
649
|
}
|
|
581
650
|
|
|
@@ -632,7 +701,7 @@ export class SkillsManagerModal implements Focusable {
|
|
|
632
701
|
|
|
633
702
|
private setRows(managedSkills: SkillIndex[], retainSelectedSkillIds: string[] = [], focusSkillId?: string): void {
|
|
634
703
|
this.managedSkills = managedSkills;
|
|
635
|
-
this.rows = buildUnifiedSkillRows(this.managedSkills, this.loadedSkills, new Set(retainSelectedSkillIds));
|
|
704
|
+
this.rows = buildUnifiedSkillRows(this.managedSkills, this.loadedSkills, new Set(retainSelectedSkillIds), this.sortMode);
|
|
636
705
|
this.syncQueryFromInput();
|
|
637
706
|
|
|
638
707
|
const rows = this.filteredRows;
|
|
@@ -687,6 +756,34 @@ export class SkillsManagerModal implements Focusable {
|
|
|
687
756
|
this.tui.requestRender();
|
|
688
757
|
}
|
|
689
758
|
|
|
759
|
+
private cycleSortMode(): void {
|
|
760
|
+
this.sortMode = nextSortMode(this.sortMode);
|
|
761
|
+
const selectedIds = this.getSelectedIds();
|
|
762
|
+
const currentRow = this.getCurrentRow();
|
|
763
|
+
this.rows = buildUnifiedSkillRows(
|
|
764
|
+
this.managedSkills,
|
|
765
|
+
this.loadedSkills,
|
|
766
|
+
new Set(selectedIds),
|
|
767
|
+
this.sortMode,
|
|
768
|
+
);
|
|
769
|
+
this.syncQueryFromInput();
|
|
770
|
+
|
|
771
|
+
const rows = this.filteredRows;
|
|
772
|
+
if (rows.length === 0) {
|
|
773
|
+
this.selectedIndex = 0;
|
|
774
|
+
} else if (currentRow) {
|
|
775
|
+
const focusIndex = rows.findIndex((row) => row.skillId === currentRow.skillId);
|
|
776
|
+
this.selectedIndex = focusIndex >= 0
|
|
777
|
+
? focusIndex
|
|
778
|
+
: Math.min(this.selectedIndex, rows.length - 1);
|
|
779
|
+
} else {
|
|
780
|
+
this.selectedIndex = Math.min(this.selectedIndex, rows.length - 1);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
this.summaryLines = [`Sort mode: ${sortModeLabel(this.sortMode)}.`];
|
|
784
|
+
this.tui.requestRender();
|
|
785
|
+
}
|
|
786
|
+
|
|
690
787
|
private appendExternalReadOnlySummary(
|
|
691
788
|
result: SkillBatchActionResult,
|
|
692
789
|
blockedExternalRows: SkillModalRow[],
|
|
@@ -943,6 +1040,10 @@ export class SkillsManagerModal implements Focusable {
|
|
|
943
1040
|
this.openFilterPanel();
|
|
944
1041
|
return;
|
|
945
1042
|
}
|
|
1043
|
+
if (data === MEMORY_SKILLS_KEYMAP.cycleSort) {
|
|
1044
|
+
this.cycleSortMode();
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
946
1047
|
|
|
947
1048
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.slash)) {
|
|
948
1049
|
this.focusSearchWithOptionalInput();
|
|
@@ -998,7 +1099,7 @@ export class SkillsManagerModal implements Focusable {
|
|
|
998
1099
|
this.promptDelete();
|
|
999
1100
|
return;
|
|
1000
1101
|
}
|
|
1001
|
-
if (this.isPrintableInput(data) && !["g", "p", "d", "a", "n", "f"].includes(data)) {
|
|
1102
|
+
if (this.isPrintableInput(data) && !["g", "p", "d", "a", "n", "f", "s"].includes(data)) {
|
|
1002
1103
|
this.focusSearchWithOptionalInput(data);
|
|
1003
1104
|
}
|
|
1004
1105
|
}
|
|
@@ -1076,7 +1177,7 @@ export class SkillsManagerModal implements Focusable {
|
|
|
1076
1177
|
lines.push(this.renderFramedLine(
|
|
1077
1178
|
this.theme.fg(
|
|
1078
1179
|
"dim",
|
|
1079
|
-
`${filteredRows.length} visible · ${this.rows.length} total · ${selectedCount} selected${this.busy ? " · working…" : ""}`,
|
|
1180
|
+
`${filteredRows.length} visible · ${this.rows.length} total · ${selectedCount} selected · sort: ${sortModeLabel(this.sortMode)}${this.busy ? " · working…" : ""}`,
|
|
1080
1181
|
),
|
|
1081
1182
|
safeWidth,
|
|
1082
1183
|
));
|
|
@@ -1141,8 +1242,8 @@ export class SkillsManagerModal implements Focusable {
|
|
|
1141
1242
|
const help = this.pendingDeleteConfirm
|
|
1142
1243
|
? "Confirm delete: y yes · n no · esc cancel"
|
|
1143
1244
|
: this.callbacks.projectName
|
|
1144
|
-
? "↑↓ move · space select · / search · f filters · tab switch · g global · p project · d delete · a all · n none · esc close"
|
|
1145
|
-
: "↑↓ move · space select · / search · f filters · tab switch · g global · p project (disabled) · d delete · a all · n none · esc close";
|
|
1245
|
+
? "↑↓ move · space select · / search · s sort · f filters · tab switch · g global · p project · d delete · a all · n none · esc close"
|
|
1246
|
+
: "↑↓ move · space select · / search · s sort · f filters · tab switch · g global · p project (disabled) · d delete · a all · n none · esc close";
|
|
1146
1247
|
lines.push(this.renderFramedLine(this.theme.fg("dim", help), safeWidth));
|
|
1147
1248
|
|
|
1148
1249
|
if (this.focusArea === "filters") {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
syncMemoryEntry,
|
|
13
13
|
} from '../store/sqlite-memory-store.js';
|
|
14
14
|
import { ENTRY_DELIMITER, MEMORY_FILE, USER_FILE } from '../constants.js';
|
|
15
|
+
import { AGENT_ROOT } from '../paths.js';
|
|
15
16
|
|
|
16
17
|
export interface BackfillCounters {
|
|
17
18
|
filesScanned: number;
|
|
@@ -64,10 +65,14 @@ function scanProjectDirs(agentRoot: string, globalDir: string, projectsMemoryDir
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
+
const resolvedAgentRoot = path.resolve(agentRoot);
|
|
69
|
+
const resolvedGlobalDir = path.resolve(globalDir);
|
|
70
|
+
const globalDirName = path.dirname(resolvedGlobalDir) === resolvedAgentRoot
|
|
71
|
+
? path.basename(resolvedGlobalDir)
|
|
72
|
+
: null;
|
|
68
73
|
if (fs.existsSync(agentRoot)) {
|
|
69
74
|
for (const name of fs.readdirSync(agentRoot)) {
|
|
70
|
-
if (name === globalDirName || name === projectsMemoryDir || name === 'skills' || name.startsWith('.')) continue;
|
|
75
|
+
if ((globalDirName && name === globalDirName) || name === projectsMemoryDir || name === 'skills' || name.startsWith('.')) continue;
|
|
71
76
|
if (projects.has(name)) continue;
|
|
72
77
|
const dir = path.join(agentRoot, name);
|
|
73
78
|
const memoryFile = path.join(dir, MEMORY_FILE);
|
|
@@ -86,6 +91,7 @@ export function syncMarkdownMemoriesToSqlite(
|
|
|
86
91
|
dbManager: DatabaseManager,
|
|
87
92
|
globalDir: string,
|
|
88
93
|
projectsMemoryDir?: string,
|
|
94
|
+
agentRoot = AGENT_ROOT,
|
|
89
95
|
): BackfillCounters & { projectCount: number } {
|
|
90
96
|
const counters: BackfillCounters = {
|
|
91
97
|
filesScanned: 0,
|
|
@@ -114,7 +120,6 @@ export function syncMarkdownMemoriesToSqlite(
|
|
|
114
120
|
importFile(globalUserFile, 'user');
|
|
115
121
|
importFile(globalFailureFile, 'failure');
|
|
116
122
|
|
|
117
|
-
const agentRoot = path.dirname(globalDir);
|
|
118
123
|
const projects = scanProjectDirs(agentRoot, globalDir, projectsMemoryDir);
|
|
119
124
|
for (const project of projects) {
|
|
120
125
|
importFile(project.memoryFile, 'memory', project.name);
|
|
@@ -128,6 +133,7 @@ export function registerSyncMarkdownMemoriesCommand(
|
|
|
128
133
|
dbManager: DatabaseManager,
|
|
129
134
|
globalDir: string,
|
|
130
135
|
projectsMemoryDir?: string,
|
|
136
|
+
agentRoot = AGENT_ROOT,
|
|
131
137
|
): void {
|
|
132
138
|
pi.registerCommand('memory-sync-markdown', {
|
|
133
139
|
description: 'Backfill Markdown memories into the SQLite search store',
|
|
@@ -135,7 +141,7 @@ export function registerSyncMarkdownMemoriesCommand(
|
|
|
135
141
|
ctx.ui.notify('🔄 Scanning Markdown memory files for SQLite backfill...', 'info');
|
|
136
142
|
|
|
137
143
|
try {
|
|
138
|
-
const counters = syncMarkdownMemoriesToSqlite(dbManager, globalDir, projectsMemoryDir);
|
|
144
|
+
const counters = syncMarkdownMemoriesToSqlite(dbManager, globalDir, projectsMemoryDir, agentRoot);
|
|
139
145
|
|
|
140
146
|
let output = `\n✅ Markdown → SQLite sync complete!\n\n`;
|
|
141
147
|
output += `📊 Results:\n`;
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import * as path from "node:path";
|
|
26
|
-
import * as os from "node:os";
|
|
27
26
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
28
27
|
import { MemoryStore } from "./store/memory-store.js";
|
|
29
28
|
import { SkillStore } from "./store/skill-store.js";
|
|
@@ -39,7 +38,6 @@ import { setupSessionFlush } from "./handlers/session-flush.js";
|
|
|
39
38
|
import { registerInsightsCommand } from "./handlers/insights.js";
|
|
40
39
|
import { triggerConsolidation, registerConsolidateCommand } from "./handlers/auto-consolidate.js";
|
|
41
40
|
import { setupCorrectionDetector } from "./handlers/correction-detector.js";
|
|
42
|
-
import { setupSkillAutoTrigger } from "./handlers/skill-auto-trigger.js";
|
|
43
41
|
import { registerSkillsCommand } from "./handlers/skills-command.js";
|
|
44
42
|
import { registerInterviewCommand } from "./handlers/interview.js";
|
|
45
43
|
import { registerSwitchProjectCommand } from "./handlers/switch-project.js";
|
|
@@ -52,6 +50,7 @@ import { detectProject, detectProjectSkills } from "./project.js";
|
|
|
52
50
|
import { buildPromptContext } from "./prompt-context.js";
|
|
53
51
|
import { migrateLegacyProjectMemoryDirs } from "./project-memory-migration.js";
|
|
54
52
|
import { migrateExtensionRoot } from "./extension-root-migration.js";
|
|
53
|
+
import { AGENT_ROOT } from "./paths.js";
|
|
55
54
|
|
|
56
55
|
export function resolveProjectSkillDiscovery(
|
|
57
56
|
skillStore: SkillStore,
|
|
@@ -80,7 +79,7 @@ export function registerProjectSkillDiscoveryHandler(
|
|
|
80
79
|
export default function (pi: ExtensionAPI) {
|
|
81
80
|
const config = loadConfig();
|
|
82
81
|
|
|
83
|
-
const agentRoot =
|
|
82
|
+
const agentRoot = AGENT_ROOT;
|
|
84
83
|
const legacyGlobalDir = path.join(agentRoot, "memory");
|
|
85
84
|
const defaultGlobalDir = path.join(agentRoot, "pi-hermes-memory");
|
|
86
85
|
|
|
@@ -121,9 +120,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
121
120
|
// Keep project memory available for users upgrading from the old
|
|
122
121
|
// ~/.pi/agent/<project>/ layout. This is non-destructive: legacy folders
|
|
123
122
|
// remain in place while entries are copied/merged into projects-memory/.
|
|
124
|
-
migrateLegacyProjectMemoryDirs(
|
|
123
|
+
migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir);
|
|
125
124
|
try {
|
|
126
|
-
syncMarkdownMemoriesToSqlite(dbManager, globalDir, config.projectsMemoryDir);
|
|
125
|
+
syncMarkdownMemoriesToSqlite(dbManager, globalDir, config.projectsMemoryDir, agentRoot);
|
|
127
126
|
} catch {
|
|
128
127
|
// Best-effort only: failed SQLite backfill should not block extension startup.
|
|
129
128
|
}
|
|
@@ -136,7 +135,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
136
135
|
const projectStore = project.memoryDir ? new MemoryStore(projectConfig) : null;
|
|
137
136
|
|
|
138
137
|
// ── 1. Load memory from disk on session start ──
|
|
139
|
-
pi.on("session_start", async (
|
|
138
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
140
139
|
if (shouldMigrateExtensionRoot && !extensionRootMigrated) {
|
|
141
140
|
try {
|
|
142
141
|
await migrateExtensionRoot(legacyGlobalDir, globalDir);
|
|
@@ -146,7 +145,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
146
145
|
extensionRootMigrated = true;
|
|
147
146
|
}
|
|
148
147
|
|
|
149
|
-
refreshSkillProjectContext(
|
|
148
|
+
refreshSkillProjectContext(ctx.cwd);
|
|
150
149
|
await skillStore.migrateLegacySkills();
|
|
151
150
|
await skillStore.ensureDiscoveredRoots();
|
|
152
151
|
await store.loadFromDisk();
|
|
@@ -178,33 +177,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
177
|
// ── 6. Setup session-end flush ──
|
|
179
178
|
setupSessionFlush(pi, store, projectStore, config);
|
|
180
179
|
|
|
181
|
-
// ── 7. Setup auto-consolidation (inject consolidator into
|
|
180
|
+
// ── 7. Setup auto-consolidation (inject consolidator into stores) ──
|
|
182
181
|
store.setConsolidator(async (target, signal) => {
|
|
183
182
|
return triggerConsolidation(pi, store, target, signal, config.consolidationTimeoutMs);
|
|
184
183
|
});
|
|
185
|
-
|
|
184
|
+
if (projectStore) {
|
|
185
|
+
projectStore.setConsolidator(async (target, signal) => {
|
|
186
|
+
const toolTarget = target === "memory" ? "project" : target;
|
|
187
|
+
return triggerConsolidation(pi, projectStore, target, signal, config.consolidationTimeoutMs, toolTarget);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
registerConsolidateCommand(pi, store, config.consolidationTimeoutMs, projectStore, projectName);
|
|
186
191
|
|
|
187
192
|
// ── 8. Setup correction detection ──
|
|
188
193
|
setupCorrectionDetector(pi, store, projectStore, config, dbManager, projectName);
|
|
189
194
|
|
|
190
|
-
// ── 9.
|
|
191
|
-
setupSkillAutoTrigger(pi, store, skillStore, config);
|
|
192
|
-
|
|
193
|
-
// ── 10. Register commands ──
|
|
195
|
+
// ── 9. Register commands ──
|
|
194
196
|
registerInsightsCommand(pi, store, projectStore, projectName);
|
|
195
197
|
registerSkillsCommand(pi, skillStore);
|
|
196
198
|
registerInterviewCommand(pi, store);
|
|
197
199
|
registerSwitchProjectCommand(pi, config);
|
|
198
200
|
registerLearnMemoryCommand(pi);
|
|
199
|
-
registerSyncMarkdownMemoriesCommand(pi, dbManager, globalDir, config.projectsMemoryDir);
|
|
201
|
+
registerSyncMarkdownMemoriesCommand(pi, dbManager, globalDir, config.projectsMemoryDir, agentRoot);
|
|
200
202
|
registerPreviewContextCommand(pi, store, projectStore, projectName, config);
|
|
201
203
|
|
|
202
|
-
// ──
|
|
204
|
+
// ── 10. SQLite session search + extended memory ──
|
|
203
205
|
registerSessionSearchTool(pi, dbManager, config.sessionSearch ?? { variant: "legacy" });
|
|
204
206
|
registerMemorySearchTool(pi, dbManager);
|
|
205
207
|
registerIndexSessionsCommand(pi);
|
|
206
208
|
|
|
207
|
-
// ──
|
|
209
|
+
// ── 11. Auto-index session on shutdown ──
|
|
208
210
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
209
211
|
try {
|
|
210
212
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { DEFAULT_PROJECTS_MEMORY_DIR } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
export const AGENT_ROOT = path.join(os.homedir(), ".pi", "agent");
|
|
6
|
+
|
|
7
|
+
export function expandHome(input: string): string {
|
|
8
|
+
if (input === "~") return os.homedir();
|
|
9
|
+
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
|
10
|
+
return path.join(os.homedir(), input.slice(2));
|
|
11
|
+
}
|
|
12
|
+
return input;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeConfiguredMemoryDir(input: string): string | undefined {
|
|
16
|
+
const trimmed = input.trim();
|
|
17
|
+
if (!trimmed) return undefined;
|
|
18
|
+
|
|
19
|
+
const expanded = expandHome(trimmed);
|
|
20
|
+
if (path.isAbsolute(expanded)) return path.normalize(expanded);
|
|
21
|
+
return path.resolve(AGENT_ROOT, expanded);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isSafeRelativeDirectory(input: string): boolean {
|
|
25
|
+
const segments = input.split(/[\\/]+/).filter(Boolean);
|
|
26
|
+
return segments.length === 1 && segments[0] !== "." && segments[0] !== "..";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeProjectsMemoryDir(input: string): string | undefined {
|
|
30
|
+
const trimmed = input.trim();
|
|
31
|
+
if (!trimmed) return undefined;
|
|
32
|
+
|
|
33
|
+
const expanded = expandHome(trimmed);
|
|
34
|
+
let relative = expanded;
|
|
35
|
+
|
|
36
|
+
if (path.isAbsolute(expanded)) {
|
|
37
|
+
const resolved = path.resolve(expanded);
|
|
38
|
+
const relativeToAgentRoot = path.relative(AGENT_ROOT, resolved);
|
|
39
|
+
if (
|
|
40
|
+
relativeToAgentRoot === ""
|
|
41
|
+
|| relativeToAgentRoot.startsWith("..")
|
|
42
|
+
|| path.isAbsolute(relativeToAgentRoot)
|
|
43
|
+
) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
relative = relativeToAgentRoot;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const normalized = path.normalize(relative).replace(/^[\\/]+|[\\/]+$/g, "");
|
|
50
|
+
if (!isSafeRelativeDirectory(normalized)) return undefined;
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveProjectsRoot(projectsMemoryDir = DEFAULT_PROJECTS_MEMORY_DIR): string {
|
|
55
|
+
const normalized = normalizeProjectsMemoryDir(projectsMemoryDir) ?? DEFAULT_PROJECTS_MEMORY_DIR;
|
|
56
|
+
return path.join(AGENT_ROOT, normalized);
|
|
57
|
+
}
|
|
@@ -31,7 +31,7 @@ function isLegacyProjectDir(agentRoot: string, projectsMemoryDir: string, name:
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function migrateLegacyProjectMemoryDirs(
|
|
34
|
-
|
|
34
|
+
agentRoot: string,
|
|
35
35
|
projectsMemoryDir = "projects-memory",
|
|
36
36
|
): ProjectMemoryMigrationResult {
|
|
37
37
|
const result: ProjectMemoryMigrationResult = {
|
|
@@ -42,7 +42,6 @@ export function migrateLegacyProjectMemoryDirs(
|
|
|
42
42
|
warnings: [],
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
-
const agentRoot = path.dirname(globalDir);
|
|
46
45
|
if (!fs.existsSync(agentRoot)) return result;
|
|
47
46
|
|
|
48
47
|
const projectsRoot = path.join(agentRoot, projectsMemoryDir);
|
package/src/project.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import * as os from "node:os";
|
|
8
|
+
import { resolveProjectsRoot } from "./paths.js";
|
|
8
9
|
|
|
9
10
|
export interface ProjectInfo {
|
|
10
11
|
/** Project name (directory basename), or null if not in a project. */
|
|
@@ -44,7 +45,7 @@ export function detectProject(projectsMemoryDir = "projects-memory", cwd?: strin
|
|
|
44
45
|
|
|
45
46
|
return {
|
|
46
47
|
name,
|
|
47
|
-
memoryDir: path.join(
|
|
48
|
+
memoryDir: path.join(resolveProjectsRoot(projectsMemoryDir), name),
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
51
|
|
package/src/store/db.ts
CHANGED
|
@@ -106,6 +106,8 @@ export class DatabaseManager {
|
|
|
106
106
|
|
|
107
107
|
// Enable WAL mode + FK enforcement for each connection.
|
|
108
108
|
db.exec('PRAGMA journal_mode = WAL');
|
|
109
|
+
db.exec('PRAGMA wal_autocheckpoint = 100');
|
|
110
|
+
db.exec('PRAGMA journal_size_limit = 5242880');
|
|
109
111
|
db.exec('PRAGMA foreign_keys = ON');
|
|
110
112
|
|
|
111
113
|
// Create tables and triggers
|
|
@@ -251,6 +253,7 @@ export class DatabaseManager {
|
|
|
251
253
|
*/
|
|
252
254
|
close(): void {
|
|
253
255
|
if (this.db) {
|
|
256
|
+
try { this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* best effort */ }
|
|
254
257
|
this.db.close();
|
|
255
258
|
this.db = null;
|
|
256
259
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const FTS5_OPERATOR_PATTERN = /\b(OR|AND|NOT|NEAR)\b/;
|
|
2
|
+
const FTS5_TOKEN_PATTERN = /"([^"]*)"|(\S+)/g;
|
|
3
|
+
const NATURAL_LANGUAGE_CONNECTORS = new Set(['and', 'or', 'not', 'near']);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize natural-language search input into an FTS5 query.
|
|
7
|
+
* Plain terms become individually quoted for implicit AND matching.
|
|
8
|
+
* Explicit quoted phrases are preserved, connector stopwords are ignored in
|
|
9
|
+
* natural-language mode, and raw uppercase FTS5 operators pass through.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeFts5Query(query: string): string {
|
|
12
|
+
const trimmed = query.trim();
|
|
13
|
+
if (trimmed.length === 0) return '';
|
|
14
|
+
|
|
15
|
+
if (FTS5_OPERATOR_PATTERN.test(trimmed)) {
|
|
16
|
+
return trimmed;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const normalizedTerms: string[] = [];
|
|
20
|
+
for (const match of trimmed.matchAll(FTS5_TOKEN_PATTERN)) {
|
|
21
|
+
const phrase = match[1];
|
|
22
|
+
const term = match[2];
|
|
23
|
+
if (phrase === undefined && term && NATURAL_LANGUAGE_CONNECTORS.has(term.toLowerCase())) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rawValue = phrase ?? term ?? '';
|
|
28
|
+
normalizedTerms.push(`"${rawValue.replace(/"/g, '""')}"`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return normalizedTerms.join(' ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isFts5QueryError(err: unknown): boolean {
|
|
35
|
+
if (!(err instanceof Error)) return false;
|
|
36
|
+
const msg = err.message.toLowerCase();
|
|
37
|
+
return msg.includes('fts5') || msg.includes('unterminated string');
|
|
38
|
+
}
|