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 +1 -1
- package/src/cli/migrate.ts +135 -1
- package/src/hooks/lib/paths.ts +1 -0
- package/src/hooks/lib/work-tracking.ts +19 -14
package/package.json
CHANGED
package/src/cli/migrate.ts
CHANGED
|
@@ -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 ────────────────────────────────────────────────────
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -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
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
163
|
+
/** Append a learning entry to the history file owning this cwd */
|
|
156
164
|
export function appendProjectHistory(cwd: string, entry: ProjectHistoryEntry): void {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
170
|
+
/** Read the session history for a given cwd */
|
|
165
171
|
export function readProjectHistory(cwd: string, limit = 15): ProjectHistoryEntry[] {
|
|
166
|
-
const
|
|
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);
|