portable-agent-layer 0.63.3 → 0.65.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 +8 -4
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/skills/analyze-pdf/SKILL.md +11 -0
- package/assets/skills/analyze-youtube/SKILL.md +12 -0
- package/assets/skills/consulting-report/SKILL.md +9 -0
- package/assets/skills/consulting-report/tools/generate-pdf.mjs +2 -2
- package/assets/skills/consulting-report/tools/generate-pdf.ts +5 -2
- package/assets/skills/council/SKILL.md +32 -0
- package/assets/skills/create-pdf/SKILL.md +13 -0
- package/assets/skills/create-skill/SKILL.md +14 -2
- package/assets/skills/create-skill/authoring-guide.md +10 -1
- package/assets/skills/create-subagent/SKILL.md +22 -4
- package/assets/skills/{research → deep-research}/SKILL.md +32 -1
- package/assets/skills/entities/SKILL.md +10 -0
- package/assets/skills/extract-wisdom/SKILL.md +12 -0
- package/assets/skills/first-principles/SKILL.md +8 -0
- package/assets/skills/frontend-design/SKILL.md +14 -0
- package/assets/skills/fyzz-chat-api/SKILL.md +10 -0
- package/assets/skills/humanize/SKILL.md +13 -1
- package/assets/skills/opinion/SKILL.md +11 -0
- package/assets/skills/pal-analyze/SKILL.md +11 -0
- package/assets/skills/pal-reflect/SKILL.md +10 -0
- package/assets/skills/playwright/SKILL.md +15 -2
- package/assets/skills/playwright/tools/shot.ts +6 -7
- package/assets/skills/presentation/SKILL.md +12 -0
- package/assets/skills/projects/SKILL.md +20 -1
- package/assets/skills/reflect/SKILL.md +13 -0
- package/assets/skills/telos/SKILL.md +12 -0
- package/assets/skills/think/SKILL.md +9 -0
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +3 -0
- package/assets/templates/pal-settings.json +1 -0
- package/assets/templates/settings.claude.json +2 -1
- package/package.json +15 -4
- package/src/cli/index.ts +95 -9
- package/src/cli/migrate.ts +69 -3
- package/src/cli/skill.ts +47 -3
- package/src/hooks/handlers/inject-retrieval.ts +20 -10
- package/src/hooks/lib/anchor.ts +90 -0
- package/src/hooks/lib/bindings.ts +117 -0
- package/src/hooks/lib/export.ts +38 -1
- package/src/hooks/lib/import-merge.ts +220 -0
- package/src/hooks/lib/inference.ts +113 -72
- package/src/hooks/lib/machine.ts +176 -0
- package/src/hooks/lib/projects.ts +223 -15
- package/src/hooks/lib/readme-sync.ts +30 -10
- package/src/hooks/lib/relationship.ts +3 -1
- package/src/hooks/lib/remote.ts +58 -0
- package/src/hooks/lib/retrieval.ts +8 -2
- package/src/hooks/lib/signals.ts +2 -1
- package/src/hooks/lib/skill-match.ts +129 -0
- package/src/hooks/lib/skill-triggers.ts +82 -0
- package/src/hooks/lib/stop.ts +5 -2
- package/src/targets/lib.ts +137 -35
- package/src/targets/opencode/plugin.ts +2 -6
- package/src/tools/agent/algorithm-reflect.ts +45 -11
- package/src/tools/agent/project.ts +148 -23
- package/src/tools/agent/thread.ts +7 -2
- package/src/tools/skill-doctor.ts +130 -5
- package/assets/skills/playwright/tools/shot-lib.mjs +0 -44
- package/assets/skills/playwright/tools/shot.mjs +0 -89
- package/assets/skills/review/SKILL.md +0 -20
- package/assets/skills/summarize/SKILL.md +0 -16
- /package/assets/skills/{research → deep-research}/tools/gemini-search.ts +0 -0
- /package/assets/skills/{research → deep-research}/tools/grok-search.ts +0 -0
- /package/assets/skills/{research → deep-research}/tools/perplexity-search.ts +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path anchors — `{proj:slug}/relative` instead of an absolute path.
|
|
3
|
+
*
|
|
4
|
+
* An absolute cwd stamp never matches across machines: same project,
|
|
5
|
+
* different mount, different username, different OS. An anchor replaces the
|
|
6
|
+
* absolute prefix with the project's registry slug, so resolution happens
|
|
7
|
+
* locally at read time — the same trick machine.ts uses for labels, applied
|
|
8
|
+
* to paths. Relocating a project (`project.ts set-path`) then fixes every
|
|
9
|
+
* memory that ever referenced it, because none of them stored the path.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { relative, resolve, sep } from "node:path";
|
|
13
|
+
import type { ProjectProgress } from "./projects";
|
|
14
|
+
import {
|
|
15
|
+
projectPathOnThisMachine,
|
|
16
|
+
readAllProjects,
|
|
17
|
+
resolveProjectFromCwd,
|
|
18
|
+
} from "./projects";
|
|
19
|
+
|
|
20
|
+
const ANCHOR_RE = /^\{proj:([a-z0-9_-]+)\}(\/.*)?$/;
|
|
21
|
+
|
|
22
|
+
export function isAnchor(value: string): boolean {
|
|
23
|
+
return ANCHOR_RE.test(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Absolute path → `{proj:slug}/relative`, if it falls inside a registered
|
|
28
|
+
* project. A path outside every registered project passes through
|
|
29
|
+
* unchanged — most cwd stamps from ad hoc commands will never resolve to a
|
|
30
|
+
* project, and that is fine; they simply do not benefit yet.
|
|
31
|
+
*/
|
|
32
|
+
export function encodeAnchor(
|
|
33
|
+
absPath: string,
|
|
34
|
+
projects: ProjectProgress[] = readAllProjects()
|
|
35
|
+
): string {
|
|
36
|
+
const proj = resolveProjectFromCwd(absPath, projects);
|
|
37
|
+
if (!proj) return absPath;
|
|
38
|
+
|
|
39
|
+
// The same effective path the resolver matched on, not the record's own field:
|
|
40
|
+
// a bound project's path lives in bindings, so anchoring off proj.path directly
|
|
41
|
+
// would measure the relative segment against the wrong root.
|
|
42
|
+
const root = projectPathOnThisMachine(proj);
|
|
43
|
+
if (!root) return absPath;
|
|
44
|
+
|
|
45
|
+
const rel = relative(root, resolve(absPath));
|
|
46
|
+
if (rel.startsWith("..")) return absPath;
|
|
47
|
+
|
|
48
|
+
const relPosix = rel.split(sep).join("/");
|
|
49
|
+
return relPosix ? `{proj:${proj.name}}/${relPosix}` : `{proj:${proj.name}}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type AnchorResolution =
|
|
53
|
+
| { state: "anchored"; path: string }
|
|
54
|
+
| { state: "plain"; path: string }
|
|
55
|
+
| { state: "unresolvable"; slug: string };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `{proj:slug}/relative` → absolute path on THIS machine, via the local
|
|
59
|
+
* registry. A plain (non-anchor) value is returned as-is — either an
|
|
60
|
+
* already-local absolute path, or a record captured before this feature
|
|
61
|
+
* shipped. There is no backfill, so pre-anchor records are handled exactly
|
|
62
|
+
* as they were before.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveAnchor(
|
|
65
|
+
value: string,
|
|
66
|
+
projects: ProjectProgress[] = readAllProjects()
|
|
67
|
+
): AnchorResolution {
|
|
68
|
+
const match = ANCHOR_RE.exec(value);
|
|
69
|
+
if (!match) return { state: "plain", path: value };
|
|
70
|
+
|
|
71
|
+
const [, slug, rel] = match;
|
|
72
|
+
const proj = projects.find((p) => p.name === slug);
|
|
73
|
+
if (!proj) return { state: "unresolvable", slug };
|
|
74
|
+
|
|
75
|
+
const root = projectPathOnThisMachine(proj);
|
|
76
|
+
if (!root) return { state: "unresolvable", slug };
|
|
77
|
+
const path = rel ? resolve(root, `.${rel}`) : root;
|
|
78
|
+
return { state: "anchored", path };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Does `value` (anchored or plain) refer to `cwd` on this machine? */
|
|
82
|
+
export function anchorMatchesCwd(
|
|
83
|
+
value: string,
|
|
84
|
+
cwd: string,
|
|
85
|
+
projects: ProjectProgress[] = readAllProjects()
|
|
86
|
+
): boolean {
|
|
87
|
+
const resolved = resolveAnchor(value, projects);
|
|
88
|
+
if (resolved.state === "unresolvable") return false;
|
|
89
|
+
return resolve(resolved.path) === resolve(cwd);
|
|
90
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bindings — where each project lives on THIS machine.
|
|
3
|
+
*
|
|
4
|
+
* A project record answers two questions in one field today: what the project
|
|
5
|
+
* is (portable — name, goal, criteria, decisions) and where it sits on disk
|
|
6
|
+
* (true on exactly one machine). The second answer rides inside `memory/`,
|
|
7
|
+
* which is exported, so one machine's filesystem layout travels to every other
|
|
8
|
+
* machine as though it were a fact about the project.
|
|
9
|
+
*
|
|
10
|
+
* This module holds the second answer separately. Memory becomes the union of
|
|
11
|
+
* all work; bindings are one machine's intersection with its disk, so "that
|
|
12
|
+
* project is not checked out here" turns into an ordinary state rather than a
|
|
13
|
+
* dead path.
|
|
14
|
+
*
|
|
15
|
+
* `bindings.json` lives at the PAL_HOME root for the same reason `machine.json`
|
|
16
|
+
* does: export walks `telos`, `memory`, `skills` and `agents`, so anything
|
|
17
|
+
* under those would sync and defeat the point.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { resolve } from "node:path";
|
|
23
|
+
import { palHome } from "./paths";
|
|
24
|
+
|
|
25
|
+
/** Project name → absolute path on this machine. */
|
|
26
|
+
export type Bindings = Record<string, string>;
|
|
27
|
+
|
|
28
|
+
export function bindingsFilePath(home: string = palHome()): string {
|
|
29
|
+
return resolve(home, "bindings.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Where the previous bindings are kept.
|
|
34
|
+
*
|
|
35
|
+
* Once a record stops storing its own path, this file is the only place a
|
|
36
|
+
* project's location lives, and it is deliberately excluded from exports — so
|
|
37
|
+
* losing it loses every location. One rolling copy of the last good content
|
|
38
|
+
* makes that recoverable by renaming a file, and never goes stale the way a
|
|
39
|
+
* one-off backup taken at migration time would.
|
|
40
|
+
*/
|
|
41
|
+
export function bindingsBackupPath(home: string = palHome()): string {
|
|
42
|
+
return resolve(home, "bindings.backup.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isBindingMap(value: unknown): value is Bindings {
|
|
46
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
47
|
+
return Object.values(value).every((v) => typeof v === "string");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A malformed or unreadable file reads as empty rather than throwing. Bindings
|
|
52
|
+
* are a lookup aid, so a corrupt one degrades to "nothing is bound here" — the
|
|
53
|
+
* same state a fresh machine starts in — instead of breaking every caller.
|
|
54
|
+
*/
|
|
55
|
+
export function readBindings(home: string = palHome()): Bindings {
|
|
56
|
+
const file = bindingsFilePath(home);
|
|
57
|
+
if (!existsSync(file)) return {};
|
|
58
|
+
try {
|
|
59
|
+
const parsed: unknown = JSON.parse(readFileSync(file, "utf-8"));
|
|
60
|
+
if (!isBindingMap(parsed)) return {};
|
|
61
|
+
return parsed;
|
|
62
|
+
} catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Seeding writes on the first project read, which makes an unsandboxed test a
|
|
69
|
+
* silent writer into the developer's own ~/.pal. The suite sets PAL_TEST_SANDBOX,
|
|
70
|
+
* so refuse there and name the file — a test that forgets to point PAL_HOME at a
|
|
71
|
+
* temp dir fails loudly instead of editing the machine running it.
|
|
72
|
+
*/
|
|
73
|
+
function assertNotRealHomeDuringTests(home: string): void {
|
|
74
|
+
if (!process.env.PAL_TEST_SANDBOX) return;
|
|
75
|
+
if (resolve(home) !== resolve(homedir(), ".pal")) return;
|
|
76
|
+
throw new Error(
|
|
77
|
+
"Refusing to write bindings.json into the real ~/.pal during a test run. " +
|
|
78
|
+
"Point PAL_HOME at a temp directory in this test's setup."
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function writeBindings(bindings: Bindings, home: string = palHome()): void {
|
|
83
|
+
assertNotRealHomeDuringTests(home);
|
|
84
|
+
const file = bindingsFilePath(home);
|
|
85
|
+
// Only a differing, non-empty predecessor is worth keeping: backing up an
|
|
86
|
+
// identical file is noise, and backing up an empty one would let a bad write
|
|
87
|
+
// erase the copy that made it recoverable.
|
|
88
|
+
if (existsSync(file) && readFileSync(file, "utf-8").trim().length > 0) {
|
|
89
|
+
copyFileSync(file, bindingsBackupPath(home));
|
|
90
|
+
}
|
|
91
|
+
const sorted: Bindings = {};
|
|
92
|
+
for (const key of Object.keys(bindings).sort()) sorted[key] = bindings[key];
|
|
93
|
+
writeFileSync(bindingsFilePath(home), `${JSON.stringify(sorted, null, 2)}\n`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The absolute path this machine has for `project`, or null when unbound. */
|
|
97
|
+
export function bindingFor(project: string, home: string = palHome()): string | null {
|
|
98
|
+
return readBindings(home)[project] ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Bind `project` to `path`, replacing any existing binding for it. */
|
|
102
|
+
export function writeBinding(
|
|
103
|
+
project: string,
|
|
104
|
+
path: string,
|
|
105
|
+
home: string = palHome()
|
|
106
|
+
): void {
|
|
107
|
+
const bindings = readBindings(home);
|
|
108
|
+
bindings[project] = resolve(path);
|
|
109
|
+
writeBindings(bindings, home);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function removeBinding(project: string, home: string = palHome()): void {
|
|
113
|
+
const bindings = readBindings(home);
|
|
114
|
+
if (!(project in bindings)) return;
|
|
115
|
+
delete bindings[project];
|
|
116
|
+
writeBindings(bindings, home);
|
|
117
|
+
}
|
package/src/hooks/lib/export.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync, readdirSync } from "node:fs";
|
|
7
7
|
import { relative, resolve } from "node:path";
|
|
8
8
|
import AdmZip from "adm-zip";
|
|
9
|
+
import { ensureRegistered } from "./machine";
|
|
9
10
|
import { palHome } from "./paths";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -59,9 +60,41 @@ export function collectExportFiles(): string[] {
|
|
|
59
60
|
return files;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
/**
|
|
63
|
+
/** Archive metadata naming the machine that produced it. */
|
|
64
|
+
export const MANIFEST_NAME = "export-manifest.json";
|
|
65
|
+
|
|
66
|
+
export interface ExportManifest {
|
|
67
|
+
machineId: string;
|
|
68
|
+
label: string;
|
|
69
|
+
os: string;
|
|
70
|
+
exportedAt: string;
|
|
71
|
+
fileCount: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildManifest(
|
|
75
|
+
identity: { id: string; label: string; os: string },
|
|
76
|
+
fileCount: number
|
|
77
|
+
): ExportManifest {
|
|
78
|
+
return {
|
|
79
|
+
machineId: identity.id,
|
|
80
|
+
label: identity.label,
|
|
81
|
+
os: identity.os,
|
|
82
|
+
exportedAt: new Date().toISOString(),
|
|
83
|
+
fileCount,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Zip the given files and write to outputPath. Returns file count.
|
|
89
|
+
*
|
|
90
|
+
* The archive declares its source machine in a manifest. Registry entries under
|
|
91
|
+
* memory/machines/ travel with the corpus, so the manifest exists to say which
|
|
92
|
+
* machine produced THIS archive — after a merge an archive can carry entries
|
|
93
|
+
* for several machines.
|
|
94
|
+
*/
|
|
63
95
|
export function exportZip(outputPath: string): number {
|
|
64
96
|
const root = palHome();
|
|
97
|
+
const identity = ensureRegistered(root);
|
|
65
98
|
const files = collectExportFiles();
|
|
66
99
|
if (files.length === 0) return 0;
|
|
67
100
|
|
|
@@ -71,6 +104,10 @@ export function exportZip(outputPath: string): number {
|
|
|
71
104
|
const dir = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "";
|
|
72
105
|
zip.addLocalFile(fullPath, dir);
|
|
73
106
|
}
|
|
107
|
+
zip.addFile(
|
|
108
|
+
MANIFEST_NAME,
|
|
109
|
+
Buffer.from(`${JSON.stringify(buildManifest(identity, files.length), null, 2)}\n`)
|
|
110
|
+
);
|
|
74
111
|
|
|
75
112
|
zip.writeZip(outputPath);
|
|
76
113
|
return files.length;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import merge — fold an export archive into an existing PAL home without
|
|
3
|
+
* destroying local records.
|
|
4
|
+
*
|
|
5
|
+
* `extractAllTo(home, true)` overwrites every colliding path, so importing
|
|
6
|
+
* machine A onto machine B silently discards B's side of every append-only log.
|
|
7
|
+
* This module replaces that with a per-type policy:
|
|
8
|
+
*
|
|
9
|
+
* *.jsonl union of both sides, deduplicated by exact line
|
|
10
|
+
* new files written as-is
|
|
11
|
+
* identical no-op
|
|
12
|
+
* diverged local kept in place, incoming quarantined under backups/
|
|
13
|
+
* denylisted never written (machine identity, rebuildable indexes)
|
|
14
|
+
*
|
|
15
|
+
* Every policy is idempotent: re-importing the same archive is a no-op on the
|
|
16
|
+
* corpus.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, resolve } from "node:path";
|
|
21
|
+
import { type ExportManifest, MANIFEST_NAME } from "./export";
|
|
22
|
+
|
|
23
|
+
/** One file inside an export archive, decoupled from the zip library. */
|
|
24
|
+
export interface ArchiveEntry {
|
|
25
|
+
path: string;
|
|
26
|
+
data(): Buffer;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MergeResult {
|
|
30
|
+
created: string[];
|
|
31
|
+
merged: string[];
|
|
32
|
+
identical: string[];
|
|
33
|
+
conflicts: string[];
|
|
34
|
+
skipped: string[];
|
|
35
|
+
linesAdded: number;
|
|
36
|
+
quarantineDir: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Paths that must never cross machines. `machine.json` carries this install's
|
|
41
|
+
* identity — importing it would give two machines one id and silently break
|
|
42
|
+
* every origin-scoped read. The retrieval index is rebuilt from its sources.
|
|
43
|
+
*/
|
|
44
|
+
const NEVER_IMPORT = [
|
|
45
|
+
"machine.json",
|
|
46
|
+
"export-manifest.json",
|
|
47
|
+
"memory/learning/.retrieval-index.json",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function normalize(path: string): string {
|
|
51
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isNeverImport(path: string): boolean {
|
|
55
|
+
const rel = normalize(path);
|
|
56
|
+
return NEVER_IMPORT.some((deny) => rel === deny || rel.endsWith(`/${deny}`));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isJsonl(path: string): boolean {
|
|
60
|
+
return normalize(path).endsWith(".jsonl");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function splitLines(raw: string): string[] {
|
|
64
|
+
return raw.split("\n").filter((l) => l.trim().length > 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Union of two JSONL bodies, local order preserved, incoming lines appended
|
|
69
|
+
* only when not already present. Exact-line identity is the dedupe key — no
|
|
70
|
+
* schema is shared across PAL's jsonl files, and every writer serializes a
|
|
71
|
+
* record the same way, so byte equality is the only key that holds for all of
|
|
72
|
+
* them.
|
|
73
|
+
*/
|
|
74
|
+
export function mergeJsonlLines(
|
|
75
|
+
localRaw: string,
|
|
76
|
+
incomingRaw: string
|
|
77
|
+
): { text: string; added: number } {
|
|
78
|
+
const local = splitLines(localRaw);
|
|
79
|
+
const seen = new Set(local);
|
|
80
|
+
const added: string[] = [];
|
|
81
|
+
for (const line of splitLines(incomingRaw)) {
|
|
82
|
+
if (seen.has(line)) continue;
|
|
83
|
+
seen.add(line);
|
|
84
|
+
added.push(line);
|
|
85
|
+
}
|
|
86
|
+
const all = [...local, ...added];
|
|
87
|
+
return { text: all.length > 0 ? `${all.join("\n")}\n` : "", added: added.length };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function writeFileEnsuringDir(target: string, data: Buffer | string): void {
|
|
91
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
92
|
+
writeFileSync(target, data);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function quarantine(quarantineDir: string, rel: string, data: Buffer): void {
|
|
96
|
+
writeFileEnsuringDir(resolve(quarantineDir, rel), data);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Merge every archive entry into `home`. `quarantineDir` receives the incoming
|
|
101
|
+
* copy of any file that diverged from its local counterpart, so a conflict
|
|
102
|
+
* loses neither side.
|
|
103
|
+
*/
|
|
104
|
+
export function mergeArchive(
|
|
105
|
+
entries: ArchiveEntry[],
|
|
106
|
+
home: string,
|
|
107
|
+
quarantineDir: string
|
|
108
|
+
): MergeResult {
|
|
109
|
+
const result: MergeResult = {
|
|
110
|
+
created: [],
|
|
111
|
+
merged: [],
|
|
112
|
+
identical: [],
|
|
113
|
+
conflicts: [],
|
|
114
|
+
skipped: [],
|
|
115
|
+
linesAdded: 0,
|
|
116
|
+
quarantineDir: null,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
const rel = normalize(entry.path);
|
|
121
|
+
if (rel.length === 0 || rel.endsWith("/")) continue;
|
|
122
|
+
|
|
123
|
+
if (isNeverImport(rel)) {
|
|
124
|
+
result.skipped.push(rel);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const target = resolve(home, rel);
|
|
129
|
+
const incoming = entry.data();
|
|
130
|
+
|
|
131
|
+
if (!existsSync(target)) {
|
|
132
|
+
writeFileEnsuringDir(target, incoming);
|
|
133
|
+
result.created.push(rel);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const localRaw = readFileSync(target);
|
|
138
|
+
if (localRaw.equals(incoming)) {
|
|
139
|
+
result.identical.push(rel);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isJsonl(rel)) {
|
|
144
|
+
const { text, added } = mergeJsonlLines(
|
|
145
|
+
localRaw.toString("utf-8"),
|
|
146
|
+
incoming.toString("utf-8")
|
|
147
|
+
);
|
|
148
|
+
writeFileSync(target, text);
|
|
149
|
+
result.merged.push(rel);
|
|
150
|
+
result.linesAdded += added;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
quarantine(quarantineDir, rel, incoming);
|
|
155
|
+
result.conflicts.push(rel);
|
|
156
|
+
result.quarantineDir = quarantineDir;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The source machine declared by an archive's manifest, or null when the
|
|
164
|
+
* archive predates manifests.
|
|
165
|
+
*/
|
|
166
|
+
export function readManifest(entries: ArchiveEntry[]): ExportManifest | null {
|
|
167
|
+
const hit = entries.find((e) => normalize(e.path) === MANIFEST_NAME);
|
|
168
|
+
if (!hit) return null;
|
|
169
|
+
try {
|
|
170
|
+
const parsed = JSON.parse(hit.data().toString("utf-8")) as Partial<ExportManifest>;
|
|
171
|
+
if (typeof parsed.machineId !== "string" || parsed.machineId.length === 0)
|
|
172
|
+
return null;
|
|
173
|
+
return {
|
|
174
|
+
machineId: parsed.machineId,
|
|
175
|
+
label: parsed.label ?? parsed.machineId,
|
|
176
|
+
os: parsed.os ?? "",
|
|
177
|
+
exportedAt: parsed.exportedAt ?? "",
|
|
178
|
+
fileCount: parsed.fileCount ?? 0,
|
|
179
|
+
};
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface ImportLogEntry {
|
|
186
|
+
ts: string;
|
|
187
|
+
archive: string;
|
|
188
|
+
mode: "merge" | "overwrite";
|
|
189
|
+
created: number;
|
|
190
|
+
merged: number;
|
|
191
|
+
identical: number;
|
|
192
|
+
conflicts: number;
|
|
193
|
+
skipped: number;
|
|
194
|
+
linesAdded: number;
|
|
195
|
+
quarantineDir: string | null;
|
|
196
|
+
sourceMachineId?: string | null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Append one record per import so a merged corpus stays attributable. */
|
|
200
|
+
export function appendImportLog(home: string, entry: ImportLogEntry): void {
|
|
201
|
+
const logPath = resolve(home, "memory", "state", "import-log.jsonl");
|
|
202
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
203
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
204
|
+
if (existsSync(logPath)) {
|
|
205
|
+
writeFileSync(logPath, readFileSync(logPath, "utf-8") + line);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
writeFileSync(logPath, line);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function summarize(result: MergeResult): string {
|
|
212
|
+
const parts = [
|
|
213
|
+
`${result.created.length} new`,
|
|
214
|
+
`${result.merged.length} merged (+${result.linesAdded} records)`,
|
|
215
|
+
`${result.identical.length} unchanged`,
|
|
216
|
+
];
|
|
217
|
+
if (result.conflicts.length > 0) parts.push(`${result.conflicts.length} conflicts`);
|
|
218
|
+
if (result.skipped.length > 0) parts.push(`${result.skipped.length} skipped`);
|
|
219
|
+
return parts.join(", ");
|
|
220
|
+
}
|