@staix/agent-mem 0.3.1 → 0.4.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 +74 -6
- package/package.json +4 -3
- package/src/cli.ts +116 -15
- package/src/commands-table.ts +15 -2
- package/src/index.ts +13 -0
- package/src/mcp-memory.ts +151 -0
- package/src/mcp.ts +122 -0
- package/src/memory/candidates.ts +12 -8
- package/src/memory/capacity.ts +179 -0
- package/src/memory/capture.ts +460 -0
- package/src/memory/catalog.ts +136 -0
- package/src/memory/commands.ts +7 -4
- package/src/memory/generation-index.ts +54 -11
- package/src/memory/generation.ts +41 -2
- package/src/memory/home.ts +5 -0
- package/src/memory/import.ts +60 -0
- package/src/memory/migrate.ts +8 -0
- package/src/memory/mutation.ts +13 -1
- package/src/memory/ops-contract.ts +19 -2
- package/src/memory/policy.ts +120 -0
- package/src/memory/project.ts +22 -1
- package/src/memory/reader-lease.ts +136 -0
- package/src/memory/screen.ts +8 -0
- package/src/memory/search-fts.ts +203 -0
- package/src/memory/shard-bridge.ts +19 -0
- package/src/memory/shard-migration.ts +562 -0
- package/src/memory/shard.ts +179 -0
- package/src/memory/store.ts +296 -58
- package/src/memory/vault.ts +40 -0
- package/src/memory/work-lists.ts +243 -0
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
import { SHARDED_SCHEMA } from "./shard.ts";
|
|
2
|
+
import { readShardLegacySnapshot } from "./shard-bridge.ts";
|
|
1
3
|
import type { Database } from "bun:sqlite";
|
|
2
4
|
import { createHash } from "node:crypto";
|
|
3
5
|
import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeSync } from "node:fs";
|
|
4
6
|
import { join } from "node:path";
|
|
5
|
-
import { canonicalJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile } from "./generation.ts";
|
|
7
|
+
import { canonicalJSON, hashJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLink } from "./generation.ts";
|
|
6
8
|
import { legacyEntries, legacyHead, readLegacySnapshot, SegmentReader, type LegacyCheckpoint, type LegacySegment } from "./bridge.ts";
|
|
7
9
|
import { reducePruneRows, validatePruneRow, type PrunePlan, type PruneRow, type PruneSnapshot, type PruneSourceRow, type PruneStorePort } from "./prune.ts";
|
|
8
10
|
import { PROMOTED_KINDS, type Entry } from "./store.ts";
|
|
9
11
|
|
|
10
12
|
const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
11
13
|
const fail = (): never => { throw new Error("generation input unavailable or changed; person inspection required"); };
|
|
12
|
-
export const GENERATION_TABLES = ["generation_rows", "generation_prunes", "generation_targets", "generation_segments", "generation_state"] as const;
|
|
14
|
+
export const GENERATION_TABLES = ["generation_vault_links", "generation_rows", "generation_prunes", "generation_targets", "generation_segments", "generation_state"] as const;
|
|
13
15
|
export interface GenerationIndexOptions {
|
|
14
16
|
profile: BoundProfile; db: Database; now: () => number;
|
|
15
17
|
apply: (line: string, source: string) => void;
|
|
@@ -43,6 +45,18 @@ export function explicitExpirySnapshot(rows: readonly PruneSourceRow[]): PruneSn
|
|
|
43
45
|
});
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/** Exact source-presence reducer shared by indexing and migration proof validation. */
|
|
49
|
+
export function coalesceGenerationSource(prior: PruneSourceRow | undefined, row: Entry, source: { source: string; local: boolean }): PruneSourceRow {
|
|
50
|
+
if (prior && canonicalJSON(prior.row) !== canonicalJSON(row)) return fail();
|
|
51
|
+
const local = source.local || row.op === "settle" || row.op === "note" && row.tier === "short";
|
|
52
|
+
const publicWitness = !local && source.source !== "legacy-local";
|
|
53
|
+
const own = source.source === "generation";
|
|
54
|
+
return { row, placement: local && (!prior || prior.placement === "local") ? "local" : "synced",
|
|
55
|
+
publicWitness: !!prior?.publicWitness || publicWitness,
|
|
56
|
+
own: prior ? row.op === "note" ? prior.own === true && own : prior.own === true || own : own,
|
|
57
|
+
legacy: !!prior?.legacy || !own };
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
/** Generation-owned derived state. Legacy files are inputs, never opened as a MemoryStore. */
|
|
47
61
|
export class GenerationIndex {
|
|
48
62
|
readonly profile: BoundProfile;
|
|
@@ -55,6 +69,7 @@ export class GenerationIndex {
|
|
|
55
69
|
const db = options.db;
|
|
56
70
|
db.run("CREATE TABLE IF NOT EXISTS generation_rows (id TEXT PRIMARY KEY, line TEXT NOT NULL, local INTEGER NOT NULL, public INTEGER NOT NULL, own INTEGER NOT NULL, legacy INTEGER NOT NULL)");
|
|
57
71
|
if (!(db.query("PRAGMA table_info(generation_rows)").all() as { name: string }[]).some(row => row.name === "legacy")) { db.run("ALTER TABLE generation_rows ADD COLUMN legacy INTEGER NOT NULL DEFAULT 0"); this.replayProvenance = true; }
|
|
72
|
+
db.run("CREATE TABLE IF NOT EXISTS generation_vault_links (id TEXT PRIMARY KEY, target TEXT NOT NULL, row_hash TEXT NOT NULL, project TEXT NOT NULL, path TEXT NOT NULL, at TEXT NOT NULL)");
|
|
58
73
|
db.run("CREATE TABLE IF NOT EXISTS generation_prunes (id TEXT PRIMARY KEY, line TEXT NOT NULL, local INTEGER NOT NULL)");
|
|
59
74
|
db.run("CREATE TABLE IF NOT EXISTS generation_targets (id TEXT PRIMARY KEY)");
|
|
60
75
|
db.run("CREATE TABLE IF NOT EXISTS generation_segments (source TEXT NOT NULL, path TEXT NOT NULL, offset INTEGER NOT NULL, hash TEXT NOT NULL, commit_id TEXT, stamp TEXT, PRIMARY KEY(source,path))");
|
|
@@ -74,7 +89,15 @@ export class GenerationIndex {
|
|
|
74
89
|
});
|
|
75
90
|
});
|
|
76
91
|
}
|
|
92
|
+
/** Authoring handles cannot read or append a partially resumed split; migration index bootstraps use captured limits. */
|
|
93
|
+
private shardFrontier(): string | undefined {
|
|
94
|
+
if (!this.profile.sharded || this.options.limits !== undefined) return undefined;
|
|
95
|
+
const value = readProfileJSON(join(this.profile.home, "shards.json")) as { schema?: unknown; phase?: unknown; generation?: unknown; selectorRevision?: unknown } | undefined;
|
|
96
|
+
if (!value || value.schema !== SHARDED_SCHEMA || value.phase !== "active" || value.generation !== this.profile.generation || value.selectorRevision !== this.profile.selectorRevision) return fail();
|
|
97
|
+
return hashJSON(value);
|
|
98
|
+
}
|
|
77
99
|
private capture(): { segments: LegacySegment[]; commit?: string } {
|
|
100
|
+
const shardBefore = this.shardFrontier();
|
|
78
101
|
const db = this.options.db;
|
|
79
102
|
const previous = db.query("SELECT source,path,offset,hash,commit_id,stamp FROM generation_segments WHERE source != 'generation'").all() as (LegacyCheckpoint & { commit_id: string | null; stamp: string | null })[];
|
|
80
103
|
const initial = readProfileJSON(join(this.profile.dir, "migration-sources.json")) as { sources?: LegacyCheckpoint[]; legacyCommit?: string } | undefined;
|
|
@@ -84,13 +107,18 @@ export class GenerationIndex {
|
|
|
84
107
|
const persisted = new Set(previous.map(row => `${row.source}:${row.path}`));
|
|
85
108
|
const completeFrontier = checkpoints.every(row => persisted.has(`${row.source}:${row.path}`));
|
|
86
109
|
const prior = (db.query("SELECT value FROM generation_state WHERE key='legacyCommit'").get() as { value: string } | null)?.value ?? initial?.legacyCommit;
|
|
87
|
-
const
|
|
88
|
-
const
|
|
110
|
+
const legacyHome = this.profile.legacyHome ?? this.profile.home;
|
|
111
|
+
const generation = this.profile.sharded ? undefined : this.profile.generation;
|
|
112
|
+
const commit = this.options.commit ?? (generation ? legacyHead(legacyHome, generation) : undefined);
|
|
113
|
+
const legacy = this.profile.sharded
|
|
114
|
+
? readShardLegacySnapshot(this.profile, { checkpoints, ...(this.options.limits ? { limits: this.options.limits } : {}), reader: this.reader, incremental: !this.fullCapture && !this.options.limits && completeFrontier })
|
|
115
|
+
: readLegacySnapshot(legacyHome, { ...(generation ? { generation } : {}), ...(commit ? { commit } : {}), checkpoints, ...(generation && prior ? { previousCommit: prior } : {}), ...(this.options.limits ? { limits: this.options.limits } : {}), reader: this.reader, incremental: !this.fullCapture && !this.options.limits && completeFrontier });
|
|
89
116
|
const own = this.ownSegments();
|
|
90
117
|
for (const old of db.query("SELECT path,offset,hash FROM generation_segments WHERE source='generation'").all() as { path: string; offset: number; hash: string }[]) {
|
|
91
118
|
const segment = own.find(s => s.path === old.path);
|
|
92
119
|
if (!segment || segment.offset < old.offset) fail();
|
|
93
120
|
}
|
|
121
|
+
if (this.shardFrontier() !== shardBefore) return fail();
|
|
94
122
|
return { segments: [...legacy, ...own], ...(commit ? { commit } : {}) };
|
|
95
123
|
}
|
|
96
124
|
private index(captured: { segments: LegacySegment[]; commit?: string }): void {
|
|
@@ -107,19 +135,21 @@ export class GenerationIndex {
|
|
|
107
135
|
db.query("INSERT OR IGNORE INTO generation_prunes VALUES (?,?,?)").run(row.id, canonicalJSON(row), Number(segment.local));
|
|
108
136
|
continue;
|
|
109
137
|
}
|
|
138
|
+
if (segment.source === "generation" && parsed && typeof parsed === "object" && (parsed as { op?: string }).op === "vault_link") {
|
|
139
|
+
const row = validateVaultLinkRow(parsed, this.profile.generation, segment.local ? "local" : "synced");
|
|
140
|
+
if (segment.path !== `local/${row.machine}.jsonl`) fail();
|
|
141
|
+
db.query("INSERT OR IGNORE INTO generation_vault_links VALUES (?,?,?,?,?,?)").run(row.id, row.target, row.rowHash, row.project, row.path, row.at);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
110
144
|
const bytes = Buffer.from(line + "\n");
|
|
111
145
|
const row = legacyEntries([{ ...segment, bytes, offset: bytes.length, hash: digest(bytes) }], { strict: segment.source === "generation", onMalformed: () => this.recordSkipped(segment) })[0];
|
|
112
146
|
if (!row) continue;
|
|
113
|
-
const canonical = canonicalJSON(row), old = db.query("SELECT line,own,legacy FROM generation_rows WHERE id=?").get(row.id) as { line: string; own: number; legacy: number } | null;
|
|
147
|
+
const canonical = canonicalJSON(row), old = db.query("SELECT line,local,public,own,legacy FROM generation_rows WHERE id=?").get(row.id) as { line: string; local: number; public: number; own: number; legacy: number } | null;
|
|
114
148
|
if (old && old.line !== canonical) fail();
|
|
115
149
|
this.options.apply(line, segment.path);
|
|
116
|
-
const
|
|
117
|
-
const publicWitness = !local && segment.source !== "legacy-local";
|
|
118
|
-
const own = segment.source === "generation", legacy = !own;
|
|
119
|
-
// Notes retain legacy birth when duplicated; ops can independently occur in both birth realms.
|
|
120
|
-
const ownPresence = old ? row.op === "note" ? !!old.own && own : !!old.own || own : own;
|
|
150
|
+
const presence = coalesceGenerationSource(old ? { row, placement: old.local ? "local" : "synced", publicWitness: !!old.public, own: !!old.own, legacy: !!old.legacy } : undefined, row, segment);
|
|
121
151
|
db.query("INSERT INTO generation_rows VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET local=MIN(local,excluded.local), public=MAX(public,excluded.public), own=excluded.own, legacy=excluded.legacy")
|
|
122
|
-
.run(row.id, canonical, Number(local), Number(publicWitness), Number(
|
|
152
|
+
.run(row.id, canonical, Number(presence.placement === "local"), Number(presence.publicWitness), Number(presence.own), Number(presence.legacy));
|
|
123
153
|
}
|
|
124
154
|
db.query("INSERT INTO generation_segments VALUES (?,?,?,?,?,?) ON CONFLICT(source,path) DO UPDATE SET offset=excluded.offset,hash=excluded.hash,commit_id=excluded.commit_id,stamp=excluded.stamp")
|
|
125
155
|
.run(segment.source, segment.path, segment.offset, segment.hash, segment.commit ?? null, segment.stamp ?? null);
|
|
@@ -173,6 +203,18 @@ export class GenerationIndex {
|
|
|
173
203
|
if (!committed) { if (attempt < 5) return this.rebuild(attempt + 1); return fail(); }
|
|
174
204
|
this.replayProvenance = false;
|
|
175
205
|
}
|
|
206
|
+
vaultWitnesses(ids: readonly string[]): Map<string, string> {
|
|
207
|
+
if (ids.length > 5000) throw new Error("vault scan budget exceeded");
|
|
208
|
+
return new Map((this.options.db.query("SELECT id,line FROM generation_rows WHERE id IN (SELECT value FROM json_each(?))").all(JSON.stringify(ids)) as { id: string; line: string }[])
|
|
209
|
+
.map(row => [row.id, hashJSON(JSON.parse(row.line))]));
|
|
210
|
+
}
|
|
211
|
+
vaultLinks(project: string): VaultLink[] {
|
|
212
|
+
const rows = this.options.db.query("SELECT l.id,l.target,l.row_hash AS rowHash,l.project,l.path,l.at,r.line FROM generation_vault_links l JOIN generation_rows r ON r.id=l.target WHERE l.project=? ORDER BY l.at,l.id LIMIT 5001").all(project) as (VaultLink & { line: string })[];
|
|
213
|
+
if (rows.length > 5000) throw new Error("vault scan budget exceeded");
|
|
214
|
+
return rows
|
|
215
|
+
.filter(row => { const note = JSON.parse(row.line) as Entry; return note.op === "note" && note.project === row.project && hashJSON(note) === row.rowHash; })
|
|
216
|
+
.map(({ line: _line, ...row }) => row);
|
|
217
|
+
}
|
|
176
218
|
own(id: string): boolean { return !!(this.options.db.query("SELECT own FROM generation_rows WHERE id=?").get(id) as { own: number } | null)?.own; }
|
|
177
219
|
snapshot(refresh = true): PruneSnapshot {
|
|
178
220
|
if (refresh) this.refresh();
|
|
@@ -191,6 +233,7 @@ export class GenerationIndex {
|
|
|
191
233
|
private pendingPath(local: boolean, machine: string): string { return join(this.profile.dir, "prune-plans", `pending-${local ? "local" : "log"}-${machine}.json`); }
|
|
192
234
|
/** Every ordinary writer must refuse to append unrelated JSON after a prune crash tail. */
|
|
193
235
|
assertAppendable(local: boolean, machine: string): void {
|
|
236
|
+
this.shardFrontier();
|
|
194
237
|
if (readProfileJSON(this.pendingPath(local, machine)) !== undefined) fail();
|
|
195
238
|
const file = join(this.profile.dir, local ? "local" : "log", `${machine}.jsonl`);
|
|
196
239
|
if (!existsSync(file)) return;
|
package/src/memory/generation.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { types } from "node:util";
|
|
2
3
|
import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
+
import { readerAdmissionDatabase } from "./mutation.ts";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
5
6
|
import { canonicalPath, processLiveness, processSignature, realPath } from "./platform.ts";
|
|
6
7
|
|
|
@@ -11,6 +12,13 @@ export interface BoundProfile {
|
|
|
11
12
|
dir: string;
|
|
12
13
|
generation?: string;
|
|
13
14
|
selectorRevision: string;
|
|
15
|
+
/** Sharded profiles read only their own legacy input; the root home remains the machine/selector identity. */
|
|
16
|
+
legacyHome?: string;
|
|
17
|
+
/** Public legacy witnesses stay distinct from same-machine private bridge input. */
|
|
18
|
+
legacyMainHome?: string;
|
|
19
|
+
sharded?: boolean;
|
|
20
|
+
shardKey?: string;
|
|
21
|
+
shardUser?: boolean;
|
|
14
22
|
}
|
|
15
23
|
export type ProfileInput = "shared-v1" | { generation: string };
|
|
16
24
|
export interface ProfileSelector { schema: "agent-mem.profile/v1"; kind: "shared-v1" | "generation"; generation?: string }
|
|
@@ -102,7 +110,7 @@ export async function withProfileLock<T>(memoryDir: string, work: () => T | Prom
|
|
|
102
110
|
const deadline = Date.now() + PROFILE_LOCK_TIMEOUT_MS;
|
|
103
111
|
for (;;) {
|
|
104
112
|
let acquired = false;
|
|
105
|
-
const fence =
|
|
113
|
+
const fence = readerAdmissionDatabase(memoryDir);
|
|
106
114
|
let fenced = false;
|
|
107
115
|
try {
|
|
108
116
|
// Serialize the complete stale-owner probe/unlink/recreate sequence, then release before waiting on a live owner.
|
|
@@ -141,3 +149,34 @@ export async function withProfileLock<T>(memoryDir: string, work: () => T | Prom
|
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
151
|
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
export interface VaultLinkRow {
|
|
155
|
+
schema: "agent-mem.vault-link/v1"; op: "vault_link"; generation: string;
|
|
156
|
+
id: string; at: string; machine: string; actor: "person"; placement: "local";
|
|
157
|
+
target: string; rowHash: string; project: string; path: string;
|
|
158
|
+
}
|
|
159
|
+
export interface VaultLink { id: string; target: string; rowHash: string; project: string; path: string; at: string }
|
|
160
|
+
const invalidVaultLink = (): never => { throw new Error("invalid vault link or unavailable exact entry"); };
|
|
161
|
+
export const vaultLinkId = (body: unknown): string => createHash("sha256").update(canonicalJSON(body)).digest("hex").slice(0, 32);
|
|
162
|
+
|
|
163
|
+
/** Metadata only: this path is never resolved, opened, copied or written by amem. */
|
|
164
|
+
export function validVaultPath(path: unknown): path is string {
|
|
165
|
+
return typeof path === "string" && path.length > 0 && Buffer.byteLength(path) <= 512 && path.endsWith(".md")
|
|
166
|
+
&& !/[\\:\p{Cc}]/u.test(path) && !path.startsWith("/") && path.split("/").every(part => part !== "" && part !== "." && part !== "..");
|
|
167
|
+
}
|
|
168
|
+
export function validateVaultLinkRow(value: unknown, generation: string | undefined, placement: "local" | "synced"): VaultLinkRow {
|
|
169
|
+
if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype || Object.getOwnPropertySymbols(value).length) return invalidVaultLink();
|
|
170
|
+
const expected = ["schema", "op", "generation", "id", "at", "machine", "actor", "placement", "target", "rowHash", "project", "path"].sort();
|
|
171
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
172
|
+
if (canonicalJSON(Object.keys(descriptors).sort()) !== canonicalJSON(expected) || Object.values(descriptors).some(field => !("value" in field) || !field.enumerable || typeof field.value !== "string")) return invalidVaultLink();
|
|
173
|
+
const row = value as VaultLinkRow;
|
|
174
|
+
if (row.schema !== "agent-mem.vault-link/v1" || row.op !== "vault_link" || row.generation !== generation || !/^[a-f0-9]{32}$/.test(row.generation)
|
|
175
|
+
|| row.placement !== "local" || placement !== "local" || row.actor !== "person" || !/^[a-f0-9]{16}$/.test(row.machine)
|
|
176
|
+
|| !/^[a-f0-9]{32}$/.test(row.id) || !/^[a-f0-9]{32}$/.test(row.target) || !/^[a-f0-9]{64}$/.test(row.rowHash)
|
|
177
|
+
|| !row.project.trim() || row.project.length > 400 || /\p{Cc}/u.test(row.project) || !validVaultPath(row.path)
|
|
178
|
+
|| !Number.isFinite(Date.parse(row.at)) || new Date(row.at).toISOString() !== row.at || Buffer.byteLength(canonicalJSON(row)) > 4096) return invalidVaultLink();
|
|
179
|
+
const { id, ...body } = row;
|
|
180
|
+
if (id !== vaultLinkId(body)) return invalidVaultLink();
|
|
181
|
+
return row;
|
|
182
|
+
}
|
package/src/memory/home.ts
CHANGED
|
@@ -8,3 +8,8 @@ export function memoryHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
8
8
|
if (own) return canonicalPath(own);
|
|
9
9
|
return join(canonicalPath(env.AGENTHUB_HOME?.trim() || join(homedir(), ".agenthub")), "memory");
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/** The store's own base convention: `StoreOptions.home` names the parent and gets `/memory` appended. */
|
|
13
|
+
export function memoryBase(home?: string): string {
|
|
14
|
+
return home === undefined ? memoryHome() : join(home, "memory");
|
|
15
|
+
}
|
package/src/memory/import.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { realPath } from "./platform.ts";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
|
|
8
|
+
import { listAliases, resolveAlias } from "./shard.ts";
|
|
9
|
+
import { WorkListStore, type WorkListFields, type WorkListPolicy } from "./work-lists.ts";
|
|
8
10
|
|
|
9
11
|
/** The claude-mem store is another program's: opened read-only, never written, its locks waited on briefly. */
|
|
10
12
|
export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
|
|
@@ -12,6 +14,64 @@ export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
|
|
|
12
14
|
const MODEL_ANSWER = "ahub ask (model answer)";
|
|
13
15
|
|
|
14
16
|
export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number }; more: boolean; cursor: number }
|
|
17
|
+
export interface WorkStateImportResult { read: number; added: number; skipped: { invalid: number; policy: number }; more: boolean; cursor: number }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Import only the source project names explicitly mapped by the person's D2 alias table. A basename or even
|
|
21
|
+
* an exact canonical-key match is not permission to adopt another program's project. Source receipts preserve
|
|
22
|
+
* synced device/id identity across database copies; unsynced rows are scoped to the source's real path.
|
|
23
|
+
*/
|
|
24
|
+
export async function importClaudeMemWorkState(opts: { base: string; project: string; policy: WorkListPolicy; db?: string; limit?: number; restart?: boolean; log?: (line: string) => void }): Promise<WorkStateImportResult> {
|
|
25
|
+
const file = opts.db ?? join(homedir(), ".claude-mem", "claude-mem.db");
|
|
26
|
+
if (!existsSync(file)) throw new Error(`no claude-mem store at ${file}`);
|
|
27
|
+
const limit = opts.limit ?? 10_000;
|
|
28
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
|
|
29
|
+
const project = resolveAlias(opts.base, opts.project);
|
|
30
|
+
const aliases = [...new Map(listAliases(opts.base).map(op => [op.alias, op.key]))]
|
|
31
|
+
.filter(([, key]) => key === project).map(([alias]) => alias).sort();
|
|
32
|
+
const result: WorkStateImportResult = { read: 0, added: 0, skipped: { invalid: 0, policy: 0 }, more: false, cursor: 0 };
|
|
33
|
+
if (!aliases.length) return result;
|
|
34
|
+
const db = new Database(file, { readonly: true });
|
|
35
|
+
let store: WorkListStore | undefined;
|
|
36
|
+
let state: string | undefined, key = "", cursor = 0;
|
|
37
|
+
try {
|
|
38
|
+
db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
|
|
39
|
+
if (!db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'work_state_entries'").get()) return result;
|
|
40
|
+
const columns = new Set((db.query("PRAGMA table_info(work_state_entries)").all() as { name: string }[]).map(row => row.name));
|
|
41
|
+
const origin = columns.has("origin_device_id") && columns.has("origin_local_id");
|
|
42
|
+
const sourceMachine = createHash("sha256").update(realPath(file)).digest("hex");
|
|
43
|
+
store = new WorkListStore({ base: opts.base, project, policy: opts.policy });
|
|
44
|
+
state = join(store.dir, "import-state.json");
|
|
45
|
+
key = createHash("sha256").update(JSON.stringify([sourceMachine, aliases, project])).digest("hex");
|
|
46
|
+
cursor = opts.restart ? 0 : readCursors(state)[key] ?? 0;
|
|
47
|
+
const where = `project IN (${aliases.map(() => "?").join(",")})`;
|
|
48
|
+
const max = db.query(`SELECT MAX(id) AS id FROM work_state_entries WHERE ${where}`).get(...aliases) as { id: number | null };
|
|
49
|
+
if ((max.id ?? 0) < cursor) cursor = 0;
|
|
50
|
+
const rows = db.query(`SELECT id, list_name, fields, created_at_epoch${origin ? ", origin_device_id, origin_local_id" : ", NULL AS origin_device_id, NULL AS origin_local_id"} FROM work_state_entries WHERE ${where} AND id > ? ORDER BY id LIMIT ?`)
|
|
51
|
+
.all(...aliases, cursor, limit) as { id: number; list_name: string; fields: string; created_at_epoch: number; origin_device_id: string | null; origin_local_id: string | null }[];
|
|
52
|
+
result.read = rows.length; result.more = rows.length === limit;
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
try {
|
|
55
|
+
let fields: WorkListFields;
|
|
56
|
+
try { fields = JSON.parse(row.fields) as WorkListFields; } catch { result.skipped.invalid++; cursor = row.id; continue; }
|
|
57
|
+
const synced = typeof row.origin_device_id === "string" && !!row.origin_device_id && typeof row.origin_local_id === "string" && !!row.origin_local_id;
|
|
58
|
+
const written = await store.write(row.list_name, fields, { source: "claude-mem/work_state", id: synced ? row.origin_local_id! : String(row.id), machine: synced ? row.origin_device_id! : sourceMachine, at: row.created_at_epoch });
|
|
59
|
+
if (!written.already) result.added++;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof Error && error.message === "invalid work-list input") result.skipped.invalid++;
|
|
62
|
+
else if (error instanceof Error && error.message === "work-list privacy policy refused the write") result.skipped.policy++;
|
|
63
|
+
else throw error; // Do not advance a receipt conflict or a failed append.
|
|
64
|
+
}
|
|
65
|
+
cursor = row.id;
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
db.close();
|
|
69
|
+
result.cursor = cursor;
|
|
70
|
+
if (state) writeCursor(state, key, cursor, opts.log ?? console.error);
|
|
71
|
+
store?.close();
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
15
75
|
|
|
16
76
|
/** Machine-local progress is disposable; malformed/unreadable state simply replays the idempotent import. */
|
|
17
77
|
function readCursors(file: string): Record<string, number> {
|
package/src/memory/migrate.ts
CHANGED
|
@@ -97,10 +97,17 @@ function writeSources(memoryDir: string, journal: MigrationJournal): void {
|
|
|
97
97
|
{ schema: "agent-mem.migration-sources/v1", sources: journal.sources, legacyCommit: journal.legacyCommit });
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
function refuseShardSelection(memoryDir: string): void {
|
|
101
|
+
try { lstatSync(join(memoryDir, "shards.json")); }
|
|
102
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
|
|
103
|
+
throw new Error("ordinary generation migration is unavailable after shard migration starts; inspect amem status and use amem migrate --shards --resume; the existing selector was retained");
|
|
104
|
+
}
|
|
105
|
+
|
|
100
106
|
export async function migrate(options: MigrateOptions): Promise<MigrationResult> {
|
|
101
107
|
if (options.authorizedPerson !== true || options.tty !== true) throw new Error("migration requires an authorized person in a terminal");
|
|
102
108
|
if (!["dry-run", "apply", "resume", "deactivate"].includes(options.mode) || options.generation !== undefined && !validGeneration(options.generation)) throw new Error("invalid migration request");
|
|
103
109
|
const memoryDir = existsSync(options.memoryDir) ? realPath(options.memoryDir) : canonicalPath(options.memoryDir);
|
|
110
|
+
refuseShardSelection(memoryDir);
|
|
104
111
|
const selection = readSelector(memoryDir);
|
|
105
112
|
// Explicit undo reads only the selector: broken/missing G must not make the escape back to v1 impossible.
|
|
106
113
|
const selectedG = selection.selector?.kind === "generation" ? selection.selector.generation : undefined;
|
|
@@ -111,6 +118,7 @@ export async function migrate(options: MigrateOptions): Promise<MigrationResult>
|
|
|
111
118
|
return { mode: options.mode, profile: initial, counts: await counts(memoryDir, options.generation ?? initial.generation, (options.now ?? Date.now)()), consequence };
|
|
112
119
|
}
|
|
113
120
|
return withProfileLock(memoryDir, async () => {
|
|
121
|
+
refuseShardSelection(memoryDir);
|
|
114
122
|
const selected = readSelector(memoryDir);
|
|
115
123
|
if (selected.revision !== initial.selectorRevision) return changed();
|
|
116
124
|
if (options.mode === "deactivate") {
|
package/src/memory/mutation.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { chmodSync } from "node:fs";
|
|
2
|
+
import { chmodSync, lstatSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
5
|
export const MUTATION_BUSY_TIMEOUT_MS = 3000;
|
|
@@ -20,3 +20,15 @@ export function acquireMutation(dir: string): (() => void) | undefined {
|
|
|
20
20
|
catch { db.close(); return undefined; }
|
|
21
21
|
return () => { try { db.run("ROLLBACK"); } finally { db.close(); } };
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/** Short local admission fence, never held across sync/network or data mutations. */
|
|
25
|
+
export function readerAdmissionDatabase(dir: string): Database {
|
|
26
|
+
const file = join(dir, "reader-admission.db");
|
|
27
|
+
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
28
|
+
const stat = lstatSync(file + suffix, { throwIfNoEntry: false });
|
|
29
|
+
if (stat && (stat.isSymbolicLink() || !stat.isFile())) throw new Error("reader admission fence is not a regular file");
|
|
30
|
+
}
|
|
31
|
+
const db = new Database(file, { create: true });
|
|
32
|
+
try { chmodSync(file, 0o600); db.run(`PRAGMA busy_timeout = ${MUTATION_BUSY_TIMEOUT_MS}`); return db; }
|
|
33
|
+
catch (error) { db.close(); throw error; }
|
|
34
|
+
}
|
|
@@ -118,7 +118,7 @@ export interface GenerationOpContract extends Omit<OpContract, "implemented" | "
|
|
|
118
118
|
implemented: true;
|
|
119
119
|
generationOnly: true;
|
|
120
120
|
targetSemantics: "exact-entry-v1";
|
|
121
|
-
validator: "validatePruneRow";
|
|
121
|
+
validator: "validatePruneRow" | "validateVaultLinkRow";
|
|
122
122
|
validation: readonly string[];
|
|
123
123
|
payloadSchema: {
|
|
124
124
|
version: 1;
|
|
@@ -156,4 +156,21 @@ export const PRUNE_GENERATION_CONTRACT: GenerationOpContract = Object.freeze<Gen
|
|
|
156
156
|
crashBoundary: "fsync exact local sealed plan, mutation fence, revalidate proofs/policy, append+fsync complete row then refresh; per-row atomicity; retry skips durable IDs; incomplete own tail completes only from identical persisted-plan prefix; mismatched tail refuses",
|
|
157
157
|
fixtures: ["test/generation-prune.test.ts#only explicit note expires or demote settle by values can prove expiry", "test/generation-prune.test.ts#exact-entry reducer waits for witnesses and cannot be reversed by clock or later deadlines", "test/generation-prune.test.ts#prune partitions private target or basis locally and public attestations bind only public rows", "test/generation-prune.test.ts#prune row shape hashes canonical time ordering placement and byte bounds are strict"],
|
|
158
158
|
});
|
|
159
|
-
export const
|
|
159
|
+
export const VAULT_LINK_GENERATION_CONTRACT: GenerationOpContract = Object.freeze<GenerationOpContract>({
|
|
160
|
+
status: "truth-changing", implemented: true, generationOnly: true, targetSemantics: "exact-entry-v1", validator: "validateVaultLinkRow",
|
|
161
|
+
validation: ["exact string-valued keys; no additional fields; canonical UTC and bounded metadata", "id is canonical complete payload hash excluding id", "bound generation and local authored machine segment must match", "projection requires exact full target id and authored row hash in the same project"],
|
|
162
|
+
payloadVersion: "agent-mem.vault-link/v1", required: ["schema", "op", "generation", "id", "at", "machine", "actor", "placement", "target", "rowHash", "project", "path"],
|
|
163
|
+
payloadSchema: { version: 1, required: ["schema", "op", "generation", "id", "at", "machine", "actor", "placement", "target", "rowHash", "project", "path"], additionalProperties: false, maxUtf8Bytes: 4096, fields: {
|
|
164
|
+
schema: { type: "string", values: ["agent-mem.vault-link/v1"] }, op: { type: "string", values: ["vault_link"] },
|
|
165
|
+
generation: generationId, id: generationId, at: canonicalUtc, machine: { type: "string", pattern: "^[a-f0-9]{16}$" },
|
|
166
|
+
actor: { type: "string", values: ["person"] }, placement: { type: "string", values: ["local"] }, target: generationId, rowHash: authoredHash,
|
|
167
|
+
project: { type: "string" }, path: { type: "string" },
|
|
168
|
+
} },
|
|
169
|
+
immutableTargets: "full exact note id and SHA-256 of the complete canonical authored row; project must match; no aliases, promotion ancestry or replacements",
|
|
170
|
+
placement: "generation local segment only; vault paths are machine-local metadata and never sync; shard migration carries the row to its exact target owner",
|
|
171
|
+
precedence: "append-only links suppress only their witnessed exact entry from future candidates; independent replacement ids survive; malformed or missing witnesses cannot suppress",
|
|
172
|
+
identity: "sha256(canonical complete vault-link payload excluding id), first 32 hex; generation, target id, full witness hash and path participate",
|
|
173
|
+
crashBoundary: "mutation fence and exact id/hash recheck; refuse an incomplete prior tail; append+fsync then derived refresh; complete rows replay, incomplete tails require person inspection",
|
|
174
|
+
fixtures: ["test/vault.test.ts#vault links refuse shared-v1 and bind exact generation entries, survive rebuild, and never touch v1 logs", "test/vault.test.ts#vault link validation rejects changed payload hashes, foreign generation and public placement", "test/vault.test.ts#vault links retain their exact target through curated shard migration"],
|
|
175
|
+
});
|
|
176
|
+
export const GENERATION_OP_CONTRACTS: Readonly<Record<"prune" | "vault_link", GenerationOpContract>> = Object.freeze({ prune: PRUNE_GENERATION_CONTRACT, vault_link: VAULT_LINK_GENERATION_CONTRACT });
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
import { looksSecret } from "./screen.ts";
|
|
4
|
+
import { isDenied } from "./platform.ts";
|
|
5
|
+
import { nodePath, pathCandidates } from "./project.ts";
|
|
6
|
+
|
|
7
|
+
/** True means the complete value is public. Hosts may narrow, never replace, standalone policy. */
|
|
8
|
+
export interface MemoryPolicy { screen(value: unknown): Promise<boolean> }
|
|
9
|
+
export interface StandalonePolicyOptions {
|
|
10
|
+
denyPaths?: readonly string[];
|
|
11
|
+
/** Case-insensitive literal substrings, not executable regular expressions. */
|
|
12
|
+
piiPatterns?: readonly string[];
|
|
13
|
+
host?: MemoryPolicy;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MAX_DEPTH = 16, MAX_NODES = 4096, MAX_BYTES = 256 * 1024, MAX_STRING_BYTES = 16 * 1024;
|
|
17
|
+
const fold = (text: string) => text.normalize("NFKC").replace(/\p{Default_Ignorable_Code_Point}/gu, "").toLowerCase().replace(/\\/g, "/");
|
|
18
|
+
const flags = new Set(["private", "pii", "privateturn", "isprivate", "ispii"]);
|
|
19
|
+
|
|
20
|
+
/** Inspect descriptors, not getters/toJSON, and build the only value a host is allowed to see. */
|
|
21
|
+
function snapshot(value: unknown): { value: unknown; texts: string[] } | undefined {
|
|
22
|
+
let nodes = 0, bytes = 0;
|
|
23
|
+
const seen = new Set<object>(), texts: string[] = [];
|
|
24
|
+
const add = (text: string) => {
|
|
25
|
+
const length = Buffer.byteLength(text);
|
|
26
|
+
if (length > MAX_STRING_BYTES || (bytes += length) > MAX_BYTES) throw new Error("policy bound");
|
|
27
|
+
texts.push(text);
|
|
28
|
+
};
|
|
29
|
+
const visit = (input: unknown, depth: number): unknown => {
|
|
30
|
+
if (depth > MAX_DEPTH || ++nodes > MAX_NODES) throw new Error("policy bound");
|
|
31
|
+
if (input === null || typeof input === "boolean") { bytes += 5; return input; }
|
|
32
|
+
if (typeof input === "number") { if (!Number.isFinite(input)) throw new Error("policy shape"); bytes += 24; return input; }
|
|
33
|
+
if (typeof input === "string") { add(input); return input; }
|
|
34
|
+
if (!input || typeof input !== "object" || types.isProxy(input) || seen.has(input)) throw new Error("policy shape");
|
|
35
|
+
const array = Array.isArray(input), prototype = Object.getPrototypeOf(input);
|
|
36
|
+
if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) throw new Error("policy shape");
|
|
37
|
+
if (Object.getOwnPropertySymbols(input).length) throw new Error("policy shape");
|
|
38
|
+
seen.add(input);
|
|
39
|
+
try {
|
|
40
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
41
|
+
if (array) {
|
|
42
|
+
const length = descriptors.length?.value;
|
|
43
|
+
if (!Number.isSafeInteger(length) || length > MAX_NODES || Object.keys(descriptors).length !== length + 1) throw new Error("policy shape");
|
|
44
|
+
const output: unknown[] = [];
|
|
45
|
+
for (let index = 0; index < length; index++) {
|
|
46
|
+
const descriptor = descriptors[String(index)];
|
|
47
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) throw new Error("policy shape");
|
|
48
|
+
output.push(visit(descriptor.value, depth + 1));
|
|
49
|
+
}
|
|
50
|
+
return output;
|
|
51
|
+
}
|
|
52
|
+
const output: Record<string, unknown> = Object.create(null);
|
|
53
|
+
for (const [key, descriptor] of Object.entries(descriptors)) {
|
|
54
|
+
if (!("value" in descriptor) || !descriptor.enumerable) throw new Error("policy shape");
|
|
55
|
+
add(key);
|
|
56
|
+
const flag = fold(key).replace(/[_-]/g, "");
|
|
57
|
+
if (flags.has(flag) && descriptor.value !== false) throw new Error("private value");
|
|
58
|
+
const item = visit(descriptor.value, depth + 1);
|
|
59
|
+
// A secret may be split between a metadata key and its scalar value.
|
|
60
|
+
if (typeof item === "string") add(`${key}: ${item}`);
|
|
61
|
+
output[key] = item;
|
|
62
|
+
}
|
|
63
|
+
return output;
|
|
64
|
+
} finally { seen.delete(input); }
|
|
65
|
+
};
|
|
66
|
+
try { const copy = visit(value, 0); return bytes <= MAX_BYTES ? { value: copy, texts } : undefined; }
|
|
67
|
+
catch { return undefined; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function literals(value: unknown, label: string): string[] {
|
|
71
|
+
if (value === undefined) return [];
|
|
72
|
+
if (types.isProxy(value) || !Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || value.length > 64 || Object.getOwnPropertySymbols(value).length) throw new Error(`invalid ${label}`);
|
|
73
|
+
const descriptors = Object.getOwnPropertyDescriptors(value), result: string[] = [];
|
|
74
|
+
if (Object.keys(descriptors).length !== value.length + 1) throw new Error(`invalid ${label}`);
|
|
75
|
+
for (let i = 0; i < value.length; i++) {
|
|
76
|
+
const item = descriptors[String(i)];
|
|
77
|
+
if (!item || !("value" in item) || typeof item.value !== "string" || !item.value.trim() || Buffer.byteLength(item.value) > 256 || /[\x00-\x1f\x7f]/.test(item.value)) throw new Error(`invalid ${label}`);
|
|
78
|
+
const normalized = fold(item.value);
|
|
79
|
+
if (!normalized.trim()) throw new Error(`invalid ${label}`);
|
|
80
|
+
result.push(normalized);
|
|
81
|
+
}
|
|
82
|
+
return [...new Set(result)];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function defaultPii(text: string): boolean {
|
|
86
|
+
if (/\b(?:\d{6}-\d{7}|\d{3}-\d{2}-\d{4})\b/.test(text)) return true;
|
|
87
|
+
// Bound each email candidate before matching; arbitrary user regexes are never run.
|
|
88
|
+
for (const token of text.split(/[\s<>()\[\]{}",;:]+/)) if (token.includes("@")) {
|
|
89
|
+
if (token.length > 320 || /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i.test(token)) return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** No filesystem, model, network or storage work occurs while constructing or screening this policy. */
|
|
95
|
+
export function createStandalonePolicy(options: StandalonePolicyOptions = {}): MemoryPolicy {
|
|
96
|
+
if (!options || typeof options !== "object" || types.isProxy(options) || Object.getPrototypeOf(options) !== Object.prototype || Object.getOwnPropertySymbols(options).length) throw new Error("invalid standalone policy options");
|
|
97
|
+
const descriptors = Object.getOwnPropertyDescriptors(options);
|
|
98
|
+
for (const [key, descriptor] of Object.entries(descriptors)) if (!["denyPaths", "piiPatterns", "host"].includes(key) || !("value" in descriptor)) throw new Error("invalid standalone policy options");
|
|
99
|
+
const deny = literals(descriptors.denyPaths?.value, "deny paths").map(path => posix.normalize(path));
|
|
100
|
+
const pii = literals(descriptors.piiPatterns?.value, "PII patterns");
|
|
101
|
+
const host = descriptors.host?.value as MemoryPolicy | undefined;
|
|
102
|
+
if (host !== undefined && (!host || typeof host.screen !== "function")) throw new Error("invalid host policy");
|
|
103
|
+
return {
|
|
104
|
+
async screen(value: unknown): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
const complete = snapshot(value);
|
|
107
|
+
if (!complete) return false;
|
|
108
|
+
for (const text of complete.texts) {
|
|
109
|
+
if (looksSecret(text) || looksSecret(text.replace(/["']/g, ""))) return false;
|
|
110
|
+
const normalized = fold(text);
|
|
111
|
+
if (defaultPii(normalized) || pii.some(pattern => normalized.includes(pattern))) return false;
|
|
112
|
+
const candidates = normalized.includes("/") || /\.[a-z]/.test(normalized) ? pathCandidates(normalized) : [];
|
|
113
|
+
const paths = [normalized, nodePath(normalized), ...candidates, ...normalized.split(/[\s"'`(){}\[\],;:=<>]+/)];
|
|
114
|
+
for (const path of paths) if (path && isDenied(posix.normalize(path), deny)) return false;
|
|
115
|
+
}
|
|
116
|
+
return host === undefined || await host.screen(complete.value) === true;
|
|
117
|
+
} catch { return false; }
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/memory/project.ts
CHANGED
|
@@ -38,7 +38,8 @@ export function projectChain(cwd: string): string[] {
|
|
|
38
38
|
/**
|
|
39
39
|
* #258: the name hub memory files a project's entries under. The project's `origin` remote, normalized to host and
|
|
40
40
|
* path (`github.com/owner/repo`), is the same on every machine and unique; without one, the git chain plus a hash of
|
|
41
|
-
* the
|
|
41
|
+
* the main working root keeps two unrelated repositories that share a name apart while the main checkout, a
|
|
42
|
+
* subdirectory and a worktree resolve to one key (that key is this machine's only, and root-invoked keys stay stable).
|
|
42
43
|
*/
|
|
43
44
|
export function memoryProjectKey(cwd: string): string {
|
|
44
45
|
const url = memoryGitSync(["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8" }).stdout?.trim() ?? "";
|
|
@@ -56,6 +57,26 @@ export function memoryProjectKey(cwd: string): string {
|
|
|
56
57
|
// GitHub serves one repository under every form, and its owner and repository names ignore case.
|
|
57
58
|
return host === "github.com" ? `${host}/${m[1].replace(/^~\/?/, "").toLowerCase()}` : `${host}/${m[1]}`;
|
|
58
59
|
}
|
|
60
|
+
// No usable remote: hash the main working root, not the cwd or git dir. Root-invoked keys therefore keep their
|
|
61
|
+
// pre-#15 value while the main checkout, a subdirectory and a worktree resolve to ONE key (#15 D2/AC2). A bare
|
|
62
|
+
// repository has no working root: its own directory is the stable identity for it and its worktrees.
|
|
63
|
+
const common = memoryGitSync(["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" }).stdout?.trim() ?? "";
|
|
64
|
+
if (common) {
|
|
65
|
+
let realCommon = common;
|
|
66
|
+
try { realCommon = realPath(common); } catch { /* as given */ }
|
|
67
|
+
const bare = (memoryGitSync(["--git-dir", realCommon, "rev-parse", "--is-bare-repository"], { encoding: "utf8" }).stdout?.trim() ?? "") === "true";
|
|
68
|
+
let root = realCommon;
|
|
69
|
+
if (!bare) {
|
|
70
|
+
const configured = memoryGitSync(["--git-dir", realCommon, "config", "--path", "core.worktree"], { encoding: "utf8" }).stdout?.trim() ?? "";
|
|
71
|
+
const top = memoryGitSync(["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" }).stdout?.trim() ?? "";
|
|
72
|
+
// Only an actual `.git` directory makes its parent the working root. A separate git dir shares its parent
|
|
73
|
+
// with unrelated repositories, so the real worktree root (or, as a last resort, the unique git dir) is safer.
|
|
74
|
+
root = configured ? resolve(realCommon, configured) : basename(realCommon) === ".git" ? dirname(realCommon) : top || realCommon;
|
|
75
|
+
}
|
|
76
|
+
let realRoot = root;
|
|
77
|
+
try { realRoot = realPath(root); } catch { /* as given */ }
|
|
78
|
+
return `${projectChain(realRoot).join("/")}@${createHash("sha256").update(realRoot).digest("hex").slice(0, 8)}`;
|
|
79
|
+
}
|
|
59
80
|
let real = cwd;
|
|
60
81
|
try { real = realPath(cwd); } catch { /* as given */ }
|
|
61
82
|
return `${projectChain(cwd).join("/")}@${createHash("sha256").update(real).digest("hex").slice(0, 8)}`;
|