portable-agent-layer 0.76.0 → 0.76.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.76.0",
3
+ "version": "0.76.1",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,16 +12,22 @@
12
12
 
13
13
  import {
14
14
  existsSync,
15
+ mkdirSync,
15
16
  readdirSync,
16
17
  readFileSync,
17
18
  renameSync,
19
+ rmdirSync,
20
+ unlinkSync,
18
21
  writeFileSync,
19
22
  } from "node:fs";
20
- import { resolve } from "node:path";
23
+ import { basename, dirname, resolve } from "node:path";
24
+ import { readBindings } from "../hooks/lib/bindings";
25
+ import { mergeJsonlLines } from "../hooks/lib/import-merge";
21
26
  import { palHome, paths } from "../hooks/lib/paths";
22
27
  import {
23
28
  legacyJsonToProgress,
24
29
  type ProjectProgress,
30
+ projectPathOnThisMachine,
25
31
  readAllProjects,
26
32
  readProject,
27
33
  writeProject,
@@ -516,12 +522,140 @@ const v5AttributionKeys: Migration = {
516
522
  },
517
523
  };
518
524
 
525
+ // ── v6-history-slugs: history filed by cwd name → its owning project ──
526
+
527
+ /**
528
+ * Session history used to be keyed on the last segment of the cwd, so a session
529
+ * in an unregistered directory minted a project-shaped folder, and a project
530
+ * checked out under a differently-named directory had its history filed under
531
+ * that directory instead. Both now route through `historyFileFor`; this brings
532
+ * the records already on disk onto the same rule.
533
+ */
534
+ interface StrandedHistory {
535
+ slug: string;
536
+ file: string;
537
+ owner: string | null;
538
+ parked: boolean;
539
+ }
540
+
541
+ function orphanHistoryFolders(): { slug: string; file: string }[] {
542
+ const base = paths.projectHistory();
543
+ if (!existsSync(base)) return [];
544
+ const out: { slug: string; file: string }[] = [];
545
+ for (const slug of readdirSync(base)) {
546
+ const dir = resolve(base, slug);
547
+ const file = resolve(dir, "history.jsonl");
548
+ if (!existsSync(file) || existsSync(resolve(dir, "ISA.md"))) continue;
549
+ out.push({ slug, file });
550
+ }
551
+ return out;
552
+ }
553
+
554
+ function parkedHistoryFiles(): { slug: string; file: string }[] {
555
+ const base = paths.unboundHistory();
556
+ if (!existsSync(base)) return [];
557
+ return readdirSync(base)
558
+ .filter((f) => f.endsWith(".jsonl"))
559
+ .map((f) => ({ slug: f.slice(0, -".jsonl".length), file: resolve(base, f) }));
560
+ }
561
+
562
+ /** The project checked out in a directory of this name, when exactly one is. */
563
+ function projectOwningDirectoryNamed(slug: string): string | null {
564
+ const bindings = readBindings();
565
+ const owners = readAllProjects().filter((p) => {
566
+ const path = projectPathOnThisMachine(p, bindings);
567
+ return path !== null && basename(path) === slug;
568
+ });
569
+ return owners.length === 1 ? owners[0].name : null;
570
+ }
571
+
572
+ /** Orphans always move; parked history moves only once its project appears. */
573
+ function strandedHistory(): StrandedHistory[] {
574
+ const out: StrandedHistory[] = [];
575
+ for (const { slug, file } of orphanHistoryFolders()) {
576
+ out.push({ slug, file, owner: projectOwningDirectoryNamed(slug), parked: false });
577
+ }
578
+ for (const { slug, file } of parkedHistoryFiles()) {
579
+ const owner = projectOwningDirectoryNamed(slug);
580
+ if (owner) out.push({ slug, file, owner, parked: true });
581
+ }
582
+ return out;
583
+ }
584
+
585
+ function destinationFor(item: StrandedHistory): string {
586
+ return item.owner
587
+ ? resolve(paths.projectHistory(), item.owner, "history.jsonl")
588
+ : resolve(paths.unboundHistory(), `${item.slug}.jsonl`);
589
+ }
590
+
591
+ /** Union both sides so a destination that already has history keeps it. */
592
+ function foldHistoryInto(target: string, source: string): number {
593
+ const local = existsSync(target) ? readFileSync(target, "utf-8") : "";
594
+ const { text, added } = mergeJsonlLines(local, readFileSync(source, "utf-8"));
595
+ mkdirSync(dirname(target), { recursive: true });
596
+ writeFileSync(target, text, "utf-8");
597
+ return added;
598
+ }
599
+
600
+ function discardEmptyFolder(dir: string): void {
601
+ try {
602
+ if (readdirSync(dir).length === 0) rmdirSync(dir);
603
+ } catch {
604
+ /* left in place — never worth failing a migration over */
605
+ }
606
+ }
607
+
608
+ const v6HistorySlugs: Migration = {
609
+ id: "v6-history-slugs",
610
+ description: "Re-file session history keyed on a cwd name onto its owning project",
611
+
612
+ check() {
613
+ const stranded = strandedHistory();
614
+ return {
615
+ pending: stranded.length > 0,
616
+ detail:
617
+ stranded.length > 0
618
+ ? `${stranded.length} history file(s) filed under a directory name`
619
+ : undefined,
620
+ };
621
+ },
622
+
623
+ run(dryRun = false): MigrationResult {
624
+ const results: string[] = [];
625
+ let migrated = 0;
626
+ let skipped = 0;
627
+
628
+ for (const item of strandedHistory()) {
629
+ const target = destinationFor(item);
630
+ const where = item.owner ? `project ${item.owner}` : "unbound-history";
631
+ if (dryRun) {
632
+ migrated++;
633
+ results.push(`${item.slug}: would move to ${where}`);
634
+ continue;
635
+ }
636
+ try {
637
+ const added = foldHistoryInto(target, item.file);
638
+ unlinkSync(item.file);
639
+ if (!item.parked) discardEmptyFolder(dirname(item.file));
640
+ migrated++;
641
+ results.push(`${item.slug}: ${added} entr(ies) moved to ${where}`);
642
+ } catch (e) {
643
+ skipped++;
644
+ results.push(`${item.slug}: skipped (${(e as Error).message})`);
645
+ }
646
+ }
647
+
648
+ return { migrated, skipped, results };
649
+ },
650
+ };
651
+
519
652
  const MIGRATIONS: Migration[] = [
520
653
  v1Projects,
521
654
  v2ThreadsToIsc,
522
655
  v3EntitiesToKnowledge,
523
656
  v4PathsToBindings,
524
657
  v5AttributionKeys,
658
+ v6HistorySlugs,
525
659
  ];
526
660
 
527
661
  // ── Public API ────────────────────────────────────────────────────
@@ -97,6 +97,7 @@ export const paths = {
97
97
  ledger: () => ensureDir(home("memory", "ledger")),
98
98
  progress: () => ensureDir(home("memory", "state", "progress")),
99
99
  projectHistory: () => ensureDir(home("memory", "projects")),
100
+ unboundHistory: () => ensureDir(home("memory", "state", "unbound-history")),
100
101
  sessionLearning: () => ensureDir(home("memory", "learning", "session")),
101
102
  synthesis: () => ensureDir(home("memory", "learning", "synthesis")),
102
103
  work: () => ensureDir(home("memory", "work")),
@@ -4,8 +4,9 @@
4
4
  */
5
5
 
6
6
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
7
- import { resolve } from "node:path";
7
+ import { dirname, resolve } from "node:path";
8
8
  import { ensureDir, paths } from "./paths";
9
+ import { defaultSlug, readAllProjects, resolveProjectFromCwd } from "./projects";
9
10
 
10
11
  // ── Session Records ──────────────────────────────────────────────
11
12
 
@@ -146,25 +147,29 @@ interface ProjectHistoryEntry {
146
147
  insights: string;
147
148
  }
148
149
 
149
- /** Convert a cwd path to a filesystem-safe slug (last directory segment) */
150
- function cwdToSlug(cwd: string): string {
151
- const normalized = cwd.replaceAll("\\", "/").replace(/\/+$/, "");
152
- return normalized.split("/").pop() || "unknown";
150
+ /**
151
+ * A directory under `memory/projects/` is what `list`, `resume` and the export
152
+ * read as a project, so only a registered one may own a folder there. Writer and
153
+ * readers share this resolver: keying them apart is what filed a project's
154
+ * history under its parent directory's name.
155
+ */
156
+ function historyFileFor(cwd: string): string {
157
+ const project = resolveProjectFromCwd(cwd, readAllProjects());
158
+ return project
159
+ ? resolve(paths.projectHistory(), project.name, "history.jsonl")
160
+ : resolve(paths.unboundHistory(), `${defaultSlug(cwd)}.jsonl`);
153
161
  }
154
162
 
155
- /** Append a learning entry to the project's history.jsonl */
163
+ /** Append a learning entry to the history file owning this cwd */
156
164
  export function appendProjectHistory(cwd: string, entry: ProjectHistoryEntry): void {
157
- const slug = cwdToSlug(cwd);
158
- const dir = ensureDir(resolve(paths.projectHistory(), slug));
159
- const historyPath = resolve(dir, "history.jsonl");
160
- const line = `${JSON.stringify(entry)}\n`;
161
- appendFileSync(historyPath, line, "utf-8");
165
+ const historyPath = historyFileFor(cwd);
166
+ ensureDir(dirname(historyPath));
167
+ appendFileSync(historyPath, `${JSON.stringify(entry)}\n`, "utf-8");
162
168
  }
163
169
 
164
- /** Read the project history for a given cwd */
170
+ /** Read the session history for a given cwd */
165
171
  export function readProjectHistory(cwd: string, limit = 15): ProjectHistoryEntry[] {
166
- const slug = cwdToSlug(cwd);
167
- const historyPath = resolve(paths.projectHistory(), slug, "history.jsonl");
172
+ const historyPath = historyFileFor(cwd);
168
173
  if (!existsSync(historyPath)) return [];
169
174
  try {
170
175
  const lines = readFileSync(historyPath, "utf-8").trim().split("\n").filter(Boolean);