pi-hermes-memory 0.7.9 → 0.7.11

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.
@@ -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(" Skills are auto-created after complex tasks,");
218
- lines.push(" or you can ask the agent to create one.");
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 globalDirName = path.basename(globalDir);
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 = path.join(os.homedir(), ".pi", "agent");
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(globalDir, config.projectsMemoryDir);
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 (event, _ctx) => {
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((event as { cwd?: string }).cwd);
148
+ refreshSkillProjectContext(ctx.cwd);
150
149
  await skillStore.migrateLegacySkills();
151
150
  await skillStore.ensureDiscoveredRoots();
152
151
  await store.loadFromDisk();
@@ -187,24 +186,21 @@ export default function (pi: ExtensionAPI) {
187
186
  // ── 8. Setup correction detection ──
188
187
  setupCorrectionDetector(pi, store, projectStore, config, dbManager, projectName);
189
188
 
190
- // ── 9. Setup skill auto-trigger ──
191
- setupSkillAutoTrigger(pi, store, skillStore, config);
192
-
193
- // ── 10. Register commands ──
189
+ // ── 9. Register commands ──
194
190
  registerInsightsCommand(pi, store, projectStore, projectName);
195
191
  registerSkillsCommand(pi, skillStore);
196
192
  registerInterviewCommand(pi, store);
197
193
  registerSwitchProjectCommand(pi, config);
198
194
  registerLearnMemoryCommand(pi);
199
- registerSyncMarkdownMemoriesCommand(pi, dbManager, globalDir, config.projectsMemoryDir);
195
+ registerSyncMarkdownMemoriesCommand(pi, dbManager, globalDir, config.projectsMemoryDir, agentRoot);
200
196
  registerPreviewContextCommand(pi, store, projectStore, projectName, config);
201
197
 
202
- // ── 11. SQLite session search + extended memory ──
198
+ // ── 10. SQLite session search + extended memory ──
203
199
  registerSessionSearchTool(pi, dbManager, config.sessionSearch ?? { variant: "legacy" });
204
200
  registerMemorySearchTool(pi, dbManager);
205
201
  registerIndexSessionsCommand(pi);
206
202
 
207
- // ── 12. Auto-index session on shutdown ──
203
+ // ── 11. Auto-index session on shutdown ──
208
204
  pi.on("session_shutdown", async (_event, ctx) => {
209
205
  try {
210
206
  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
- globalDir: string,
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(homeDir, ".pi", "agent", projectsMemoryDir, name),
48
+ memoryDir: path.join(resolveProjectsRoot(projectsMemoryDir), name),
48
49
  };
49
50
  }
50
51
 
@@ -253,6 +253,8 @@ export class SkillStore {
253
253
  }
254
254
 
255
255
  return skills.sort((a, b) => {
256
+ if (a.updated !== b.updated) return b.updated.localeCompare(a.updated);
257
+ if (a.created !== b.created) return b.created.localeCompare(a.created);
256
258
  if (a.scope !== b.scope) return a.scope.localeCompare(b.scope);
257
259
  return (a.displayName || a.name).localeCompare(b.displayName || b.name);
258
260
  });
@@ -290,7 +292,7 @@ export class SkillStore {
290
292
  if (existing) {
291
293
  return {
292
294
  success: false,
293
- error: `Skill '${slug}' already exists (${skillId}). Use 'patch' or 'edit' to update it.`,
295
+ error: `Skill '${slug}' already exists (${skillId}). Use 'patch' or 'update' to update it.`,
294
296
  conflictType: "duplicate",
295
297
  similarSkillIds: [skillId],
296
298
  suggestedAction: "patch",
@@ -303,7 +305,7 @@ export class SkillStore {
303
305
  const targetId = similarSkillIds[0];
304
306
  return {
305
307
  success: false,
306
- error: `A similar global skill already exists (${targetId}). Enhance the existing skill with new learnings/failures using 'patch' or 'edit' instead of creating a duplicate.`,
308
+ error: `A similar global skill already exists (${targetId}). Enhance the existing skill with new learnings/failures using 'patch' or 'update' instead of creating a duplicate.`,
307
309
  conflictType: "similar",
308
310
  similarSkillIds,
309
311
  suggestedAction: "patch",
@@ -315,7 +317,7 @@ export class SkillStore {
315
317
  const targetId = collidingNameSkillIds[0];
316
318
  return {
317
319
  success: false,
318
- error: `A near-name global skill already exists (${targetId}) but with different intent. Use a clearer differentiated name for the new skill, or patch/edit the existing skill if the intent is actually the same.`,
320
+ error: `A near-name global skill already exists (${targetId}) but with different intent. Use a clearer differentiated name for the new skill, or patch/update the existing skill if the intent is actually the same.`,
319
321
  conflictType: "name-collision",
320
322
  similarSkillIds: collidingNameSkillIds,
321
323
  suggestedAction: "rename",
@@ -790,6 +792,8 @@ export class SkillStore {
790
792
  name: doc.name,
791
793
  displayName: doc.displayName,
792
794
  description: doc.description,
795
+ created: doc.created,
796
+ updated: doc.updated,
793
797
  };
794
798
  }
795
799
 
@@ -6,6 +6,19 @@ export interface ParsedSkillFile {
6
6
  body: string;
7
7
  }
8
8
 
9
+ function parseScalar(value: string): string {
10
+ const trimmed = value.trim();
11
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
12
+ try {
13
+ const parsed = JSON.parse(trimmed);
14
+ if (typeof parsed === "string") return parsed;
15
+ } catch {
16
+ // fall through to raw trimmed
17
+ }
18
+ }
19
+ return trimmed;
20
+ }
21
+
9
22
  export function parseFrontmatter(raw: string): ParsedSkillFile {
10
23
  const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
11
24
  if (!match) return { meta: {}, body: raw.trim() };
@@ -15,7 +28,7 @@ export function parseFrontmatter(raw: string): ParsedSkillFile {
15
28
  const idx = line.indexOf(":");
16
29
  if (idx > 0) {
17
30
  const key = line.slice(0, idx).trim();
18
- const value = line.slice(idx + 1).trim();
31
+ const value = parseScalar(line.slice(idx + 1));
19
32
  meta[key] = value;
20
33
  }
21
34
  }
@@ -23,18 +36,22 @@ export function parseFrontmatter(raw: string): ParsedSkillFile {
23
36
  return { meta, body: match[2].trim() };
24
37
  }
25
38
 
39
+ function yamlDoubleQuoted(value: string): string {
40
+ return JSON.stringify(value);
41
+ }
42
+
26
43
  export function formatFrontmatter(doc: Pick<SkillDocument, "name" | "displayName" | "description" | "version" | "created" | "updated" | "body">): string {
27
44
  const lines = [
28
45
  "---",
29
- `name: ${doc.name}`,
30
- `description: ${doc.description}`,
46
+ `name: ${yamlDoubleQuoted(doc.name)}`,
47
+ `description: ${yamlDoubleQuoted(doc.description)}`,
31
48
  `version: ${doc.version}`,
32
- `created: ${doc.created}`,
33
- `updated: ${doc.updated}`,
49
+ `created: ${yamlDoubleQuoted(doc.created)}`,
50
+ `updated: ${yamlDoubleQuoted(doc.updated)}`,
34
51
  ];
35
52
 
36
53
  if (doc.displayName && doc.displayName.trim() && doc.displayName.trim() !== doc.name) {
37
- lines.push(`display_name: ${doc.displayName.trim()}`);
54
+ lines.push(`display_name: ${yamlDoubleQuoted(doc.displayName.trim())}`);
38
55
  }
39
56
 
40
57
  lines.push("---", doc.body);