@staix/agent-mem 0.5.0 → 0.7.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/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +15 -0
- package/README.md +186 -21
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +492 -39
- package/src/commands-table.ts +56 -15
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +566 -0
- package/src/hooks.ts +253 -0
- package/src/index.ts +18 -0
- package/src/ingest.ts +351 -0
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +87 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +162 -59
- package/src/memory/capture-input.ts +36 -0
- package/src/memory/capture.ts +153 -39
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +9 -4
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/shard.ts +3 -80
- package/src/memory/store.ts +286 -56
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +115 -38
- package/src/session-brief-worker.ts +82 -0
- package/src/session-brief.ts +261 -0
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadAmemConfig } from "./config.ts";
|
|
4
|
+
import { createStandalonePolicy } from "./policy.ts";
|
|
5
|
+
import type { MemoryStore } from "./store.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* F4 (agent-mem#40): what leaves this machine is screened by the person's configured policy. Sync pushes this machine's own long-term segment
|
|
9
|
+
* (`log/<machine>.jsonl`) to a private remote. Before it does, every note in that segment is read through the configured standalone policy (secrets,
|
|
10
|
+
* personal data, the configured PII patterns and deny paths). One declined entry refuses the whole sync and names the ids (never the text), because a
|
|
11
|
+
* partial push would silently drop it while it stays in the log.
|
|
12
|
+
*
|
|
13
|
+
* What is read is what goes out. A single-log home pushes this machine's own segment, so that file is read. A generation or sharded home publishes rows from the
|
|
14
|
+
* verified snapshot (this machine's own rows and its bridged legacy rows, wherever they sit on disk, e.g. under `legacy/log/` after a shard migration), so those
|
|
15
|
+
* rows are read, for the canonical shard and its user overlay alike. The other machines' rows came from the remote and were screened where they were written.
|
|
16
|
+
* The policy is the machine's configured one AND whatever the caller adds (the CLI adds the repository-narrowed policy), so a repository's `.amem/config` binds sync too.
|
|
17
|
+
* Reads only (the log is append-only). Bounded: over `MAX_ROWS` rows or `MAX_BYTES` it refuses, since an unscreened row must not pass because the
|
|
18
|
+
* scan gave up.
|
|
19
|
+
* ponytail: a person clears a declined entry by removing the offending pattern or editing the policy; sync does not skip a row, and `amem forget` is an
|
|
20
|
+
* append-only op that leaves the original row in the log. Upgrade: a per-row placement that keeps a declined note local without refusing the sync.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_BYTES = 8 * 1024 * 1024, MAX_ROWS = 20_000;
|
|
23
|
+
|
|
24
|
+
export type SyncScreen = { ok: true } | { ok: false; reason: string };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What a row says: everything except the fields the library itself generates (ids, hashes, times, the op and enum names, machine and actor ids). Left IN on purpose
|
|
28
|
+
* are `project`, `peer`, `task`, `about`, `title`, `text`, `milestones` and any field this file has never heard of: an unknown text field must be screened, never skipped.
|
|
29
|
+
*/
|
|
30
|
+
const GENERATED = new Set(["id", "schema", "op", "at", "machine", "actor", "target", "promotedFrom", "supersedes", "expires", "by", "tier", "scope", "kind", "source"]);
|
|
31
|
+
function textOf(row: Record<string, unknown>): Record<string, unknown> {
|
|
32
|
+
const out: Record<string, unknown> = {};
|
|
33
|
+
for (const [key, value] of Object.entries(row)) if (!GENERATED.has(key)) out[key] = value;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A row passes when the machine's configured policy and the caller's extra screen (if any) both accept its text. */
|
|
38
|
+
export type RowScreen = (row: Record<string, unknown>) => boolean;
|
|
39
|
+
export function outboundScreen(home: string, extra?: RowScreen): RowScreen {
|
|
40
|
+
const { config } = loadAmemConfig(home);
|
|
41
|
+
const policy = createStandalonePolicy({ denyPaths: config.denyPaths, piiPatterns: config.piiPatterns });
|
|
42
|
+
return row => { const text = textOf(row); return policy.screenStandalone(text) && (extra?.(text) ?? true); };
|
|
43
|
+
}
|
|
44
|
+
const refusal = (declined: string[]): SyncScreen => declined.length ? { ok: false, reason: `the configured privacy policy declines ${declined.length} long-term entr${declined.length === 1 ? "y" : "ies"} (${declined.slice(0, 8).join(", ")}${declined.length > 8 ? ", ..." : ""}); fix the entry or the policy, then sync again` } : { ok: true };
|
|
45
|
+
const idOf = (row: Record<string, unknown>) => typeof row.id === "string" ? row.id.slice(0, 8) : "(no id)";
|
|
46
|
+
|
|
47
|
+
/** Screen the rows a generation or sharded store would publish: its snapshot's synced rows that are this machine's own or bridged from its legacy input. */
|
|
48
|
+
export function screenOutboundRows(store: MemoryStore, pass: RowScreen): SyncScreen {
|
|
49
|
+
const declined: string[] = [];
|
|
50
|
+
let rows = 0;
|
|
51
|
+
let snapshot: ReturnType<ReturnType<MemoryStore["prunePort"]>["snapshot"]>;
|
|
52
|
+
// A bootstrap-only handle has no snapshot: that is a refusal like any unreadable source, never an exception out of sync (which promises a result).
|
|
53
|
+
try { snapshot = store.prunePort().snapshot(); } catch { return { ok: false, reason: "this store's long-term rows cannot be read, so they were not screened" }; }
|
|
54
|
+
for (const source of snapshot.rows) {
|
|
55
|
+
if (source.placement !== "synced" || !(source.own || source.legacy)) continue;
|
|
56
|
+
if (++rows > MAX_ROWS) return { ok: false, reason: `more than ${MAX_ROWS} long-term rows, so they were not screened` };
|
|
57
|
+
if (!pass(source.row as unknown as Record<string, unknown>)) declined.push(idOf(source.row as unknown as Record<string, unknown>));
|
|
58
|
+
}
|
|
59
|
+
return refusal(declined);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Screen one machine's own long-term segment under `dir`. */
|
|
63
|
+
export function screenOwnSegment(dir: string, machineId: string, pass: RowScreen): SyncScreen {
|
|
64
|
+
const file = join(dir, "log", `${machineId}.jsonl`);
|
|
65
|
+
let fd: number;
|
|
66
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
67
|
+
catch (error) {
|
|
68
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true }; // nothing authored here: nothing to push
|
|
69
|
+
return { ok: false, reason: "this machine's long-term segment cannot be read, so it was not screened" };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const stat = fstatSync(fd);
|
|
73
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) return { ok: false, reason: `this machine's long-term segment is not a plain file within ${MAX_BYTES} bytes, so it was not screened` };
|
|
74
|
+
const declined: string[] = [];
|
|
75
|
+
let rows = 0;
|
|
76
|
+
for (const line of readFileSync(fd, "utf8").split("\n")) {
|
|
77
|
+
if (!line.trim()) continue;
|
|
78
|
+
if (++rows > MAX_ROWS) return { ok: false, reason: `more than ${MAX_ROWS} long-term rows, so they were not screened` };
|
|
79
|
+
let row: Record<string, unknown>;
|
|
80
|
+
try { row = JSON.parse(line) as Record<string, unknown>; } catch { return { ok: false, reason: "a line of this machine's long-term segment does not parse, so it was not screened" }; }
|
|
81
|
+
// Every row kind is read, not only notes: a goal's title and milestones sync too, and so does any text an op adds later (see `textOf`).
|
|
82
|
+
if (!pass(row)) declined.push(idOf(row));
|
|
83
|
+
}
|
|
84
|
+
return refusal(declined);
|
|
85
|
+
} finally { closeSync(fd); }
|
|
86
|
+
}
|
package/src/memory/sync.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "
|
|
|
4
4
|
import { acquireMutation } from "./mutation.ts";
|
|
5
5
|
import type { MemoryStore } from "./store.ts";
|
|
6
6
|
import { syncGenerationMemory } from "./generation-sync.ts";
|
|
7
|
+
import { outboundScreen, screenOutboundRows, screenOwnSegment, type RowScreen } from "./sync-screen.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* #258 phase 2: hub memory between a person's machines through a git remote they configure. The memory directory is a
|
|
@@ -16,18 +17,41 @@ export interface SyncResult { ok: boolean; pushed: boolean; entries: number; war
|
|
|
16
17
|
const GITIGNORE = "/*\n!/.gitignore\n!/log/\n/log/*\n!/log/*.jsonl\n";
|
|
17
18
|
const STEP_MS = 60_000;
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Git runs in its own process group when the caller passes a stop signal (see `git` below), so that group no longer receives what the terminal sends to amem's own. Every
|
|
22
|
+
* such group is registered here while it lives, and a process that exits for any reason (a second Ctrl-C, SIGHUP when the terminal closes, SIGQUIT, a throw) signals each one
|
|
23
|
+
* before it goes: a git left at ppid 1 with no deadline would hold `sync.lock`'s repository while the next sync removes its `index.lock`. The exit handler is synchronous and
|
|
24
|
+
* installed once, only when the first group is registered, so a process that never runs a deadline git does not get one. SIGKILL of amem itself cannot be handled by anyone.
|
|
25
|
+
*/
|
|
26
|
+
const liveGroups = new Set<number>();
|
|
27
|
+
let exitHandler = false;
|
|
28
|
+
function signalGroups(name: "SIGTERM" | "SIGKILL"): void { for (const pid of liveGroups) { try { process.kill(-pid, name); } catch { /* the group is already gone */ } } }
|
|
29
|
+
function register(pid: number): () => void {
|
|
30
|
+
if (!exitHandler) { exitHandler = true; process.on("exit", () => signalGroups("SIGKILL")); }
|
|
31
|
+
liveGroups.add(pid);
|
|
32
|
+
return () => { liveGroups.delete(pid); };
|
|
33
|
+
}
|
|
34
|
+
/** Test seam: how many deadline-git groups are alive right now, and a way to stop them as the exit handler would. */
|
|
35
|
+
export const syncGroupsForTest = { live: () => liveGroups.size, stopAll: () => signalGroups("SIGKILL") };
|
|
36
|
+
|
|
19
37
|
async function git(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS): Promise<{ code: number; out: string; err: string }> {
|
|
20
38
|
if (signal?.aborted) return { code: -1, out: "", err: "stopped" };
|
|
21
|
-
|
|
39
|
+
// A caller that passes a stop signal (`amem sync --if-due`, a stopping hub) gets git in its own process group, so a stop reaches the transport children git started (ssh, a
|
|
40
|
+
// credential helper): killing only git's pid left them running. The price is that a terminal Ctrl-C no longer reaches git by itself, so such a caller must turn its own
|
|
41
|
+
// interrupt into the signal (`stopSignal` in sync-cadence.ts does). A call with no signal keeps the unchanged spawn.
|
|
42
|
+
const group = signal !== undefined;
|
|
43
|
+
const p = Bun.spawn(memoryGitArgv(["-C", dir, ...args], { credentials: true }), { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(), ...(group ? { detached: true } : {}) });
|
|
44
|
+
const send = (name: "SIGTERM" | "SIGKILL") => { if (group) { try { process.kill(-p.pid, name); return; } catch { /* no group left: fall through to the pid */ } } p.kill(name); };
|
|
45
|
+
const unregister = group ? register(p.pid) : () => {};
|
|
22
46
|
// SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period.
|
|
23
47
|
let hard: ReturnType<typeof setTimeout> | undefined;
|
|
24
|
-
const kill = () => {
|
|
48
|
+
const kill = () => { send("SIGTERM"); hard ??= setTimeout(() => send("SIGKILL"), 2_000); };
|
|
25
49
|
const timer = setTimeout(kill, ms);
|
|
26
50
|
signal?.addEventListener("abort", kill, { once: true });
|
|
27
51
|
try {
|
|
28
52
|
const [code, out, err] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
29
53
|
return { code, out: out.trim(), err: err.trim() };
|
|
30
|
-
} finally { clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); }
|
|
54
|
+
} finally { unregister(); clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); }
|
|
31
55
|
}
|
|
32
56
|
|
|
33
57
|
export { git as syncGit };
|
|
@@ -37,29 +61,64 @@ export async function generationSyncGit(dir: string, args: string[], signal?: Ab
|
|
|
37
61
|
if (signal?.aborted) return { code: -1, out: "", err: "stopped" };
|
|
38
62
|
// A fixed child-only umask keeps Git-created metadata private without changing the daemon's process umask.
|
|
39
63
|
// Args are positional shell parameters, never interpolated or evaluated as shell code.
|
|
40
|
-
const
|
|
64
|
+
const group = signal !== undefined; // see `git` above: own process group when the caller has a deadline
|
|
65
|
+
const p = Bun.spawn(["/bin/sh", "-c", 'umask 077; exec "$@"', "amem-git", ...memoryGitArgv(["-C", dir, ...args], { credentials: true })], { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(process.env, { repositoryIsolation: true }), ...(group ? { detached: true } : {}) });
|
|
66
|
+
const send = (name: "SIGTERM" | "SIGKILL") => { if (group) { try { process.kill(-p.pid, name); return; } catch { /* no group left: fall through to the pid */ } } p.kill(name); };
|
|
67
|
+
const unregister = group ? register(p.pid) : () => {};
|
|
41
68
|
// SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period.
|
|
42
69
|
let hard: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
-
const kill = () => {
|
|
70
|
+
const kill = () => { send("SIGTERM"); hard ??= setTimeout(() => send("SIGKILL"), 2_000); };
|
|
44
71
|
const timer = setTimeout(kill, ms);
|
|
45
72
|
signal?.addEventListener("abort", kill, { once: true });
|
|
46
73
|
try {
|
|
47
74
|
const [code, out, err] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
48
75
|
return { code, out: out.trim(), err: err.trim() };
|
|
49
|
-
} finally { clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); }
|
|
76
|
+
} finally { unregister(); clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); }
|
|
50
77
|
}
|
|
51
78
|
|
|
52
79
|
|
|
53
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* The `owner/repo` of a remote URL whose host is EXACTLY `github.com` (case-insensitive) over `https://`, `ssh://git@github.com/` or the scp form
|
|
82
|
+
* `[git@]github.com:`; anything else is undefined, so it is `unknown` and never asked. An unanchored match let `https://notgithub.com/acme/notes`,
|
|
83
|
+
* `git@evil-github.com:acme/notes` and `https://git.example.org/mirror/github.com/acme/notes` resolve to `acme/notes` (task #121 review round 1; published 0.6.0
|
|
84
|
+
* has the unanchored form). `http://` and `git://` are not verifiable as private transports and an explicit port is not github.com's, so both are unknown.
|
|
85
|
+
*/
|
|
86
|
+
export function githubRepoOf(url: string): string | undefined {
|
|
87
|
+
const text = url.trim();
|
|
88
|
+
if (!text || /[\s\u0000-\u001f\u007f\\]/.test(text) || text.length > 512) return undefined;
|
|
89
|
+
let host: string, path: string;
|
|
90
|
+
const scp = /^(?:([A-Za-z0-9._-]+)@)?([^:/@]+):([^/].*)$/.exec(text); // `[user@]host:path`, never a URL with `://`
|
|
91
|
+
if (!text.includes("://") && scp) {
|
|
92
|
+
if (scp[1] !== undefined && scp[1] !== "git") return undefined;
|
|
93
|
+
host = scp[2]!; path = scp[3]!;
|
|
94
|
+
} else {
|
|
95
|
+
let parsed: URL;
|
|
96
|
+
try { parsed = new URL(text); } catch { return undefined; }
|
|
97
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "ssh:") return undefined;
|
|
98
|
+
if (parsed.port) return undefined;
|
|
99
|
+
if (parsed.search || parsed.hash) return undefined;
|
|
100
|
+
if (parsed.protocol === "ssh:" && parsed.username !== "git") return undefined;
|
|
101
|
+
host = parsed.hostname; path = parsed.pathname.replace(/^\//, "");
|
|
102
|
+
}
|
|
103
|
+
if (host.toLowerCase() !== "github.com") return undefined;
|
|
104
|
+
const match = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?\/?$/.exec(path);
|
|
105
|
+
return match && match[2] !== "." && match[2] !== ".." ? `${match[1]}/${match[2]}` : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** GitHub remotes are asked through `gh`, pinned to github.com; any other host, or a `gh` that cannot answer, is unknown. */
|
|
54
109
|
export async function remoteVisibility(url: string): Promise<Visibility> {
|
|
55
|
-
const repo =
|
|
110
|
+
const repo = githubRepoOf(url);
|
|
56
111
|
if (!repo) return "unknown";
|
|
57
112
|
try {
|
|
58
|
-
|
|
113
|
+
// GH_HOST (and the enterprise token variables) would send `gh repo view` to a different server: pin it, so the answer is about github.com's repository.
|
|
114
|
+
const env: Record<string, string | undefined> = { ...process.env, GH_HOST: "github.com" };
|
|
115
|
+
delete env.GH_ENTERPRISE_TOKEN; delete env.GITHUB_ENTERPRISE_TOKEN;
|
|
116
|
+
const p = Bun.spawn(["gh", "repo", "view", repo, "--json", "visibility", "--jq", ".visibility"], { stdout: "pipe", stderr: "ignore", stdin: "ignore", env: env as Record<string, string> });
|
|
59
117
|
const timer = setTimeout(() => p.kill("SIGKILL"), 15_000);
|
|
60
118
|
const [code, out] = await Promise.all([p.exited, new Response(p.stdout).text()]);
|
|
61
119
|
clearTimeout(timer);
|
|
62
120
|
const v = out.trim().toLowerCase();
|
|
121
|
+
// ponytail: `internal` (GitHub Enterprise Cloud: visible to the enterprise's members, not the public) counts as private, as `amem sync` always has. A stricter policy would map it to unknown.
|
|
63
122
|
return code === 0 && (v === "private" || v === "internal") ? "private" : code === 0 && v === "public" ? "public" : "unknown";
|
|
64
123
|
} catch { return "unknown"; }
|
|
65
124
|
}
|
|
@@ -88,16 +147,25 @@ export interface SyncOptions {
|
|
|
88
147
|
/** Set (or replace) the remote first: a person's choice. */
|
|
89
148
|
remote?: string;
|
|
90
149
|
visibility?: (url: string) => Promise<Visibility>;
|
|
150
|
+
/** An extra screen over each outbound row's text, on top of the machine's configured policy (the CLI passes the repository-narrowed policy). */
|
|
151
|
+
screen?: RowScreen;
|
|
91
152
|
/** A stopping hub aborts: the running git step is killed and the sync ends without touching the store. */
|
|
92
153
|
signal?: AbortSignal;
|
|
93
154
|
}
|
|
94
155
|
|
|
95
156
|
export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
|
|
157
|
+
// F4: before anything is staged or pushed, this machine's own long-term notes pass the person's configured policy (every profile: single log, generation, shards).
|
|
158
|
+
const pass = outboundScreen(store.profile.home, opts.screen);
|
|
159
|
+
for (const target of store.openedSyncTargets()) {
|
|
160
|
+
const screened = target.profile.kind === "generation" ? screenOutboundRows(target, pass) : screenOwnSegment(target.dir, target.machine.id, pass);
|
|
161
|
+
if (!screened.ok) return { ok: false, pushed: false, entries: 0, error: screened.reason };
|
|
162
|
+
}
|
|
163
|
+
if (store.profile.sharded) return (await import("./shard-sync.ts")).syncShardedMemory(store, opts);
|
|
96
164
|
if (store.profile.kind === "generation") return syncGenerationMemory(store, opts);
|
|
97
165
|
const dir = store.dir;
|
|
98
166
|
const fail = (error: string): SyncResult => ({ ok: false, pushed: false, entries: 0, error });
|
|
99
167
|
if (opts.signal?.aborted) return fail("stopped");
|
|
100
|
-
const noRemote = "no sync remote: a person sets one with
|
|
168
|
+
const noRemote = "no sync remote: a person sets one with `amem sync --remote <private git url>`";
|
|
101
169
|
if (!opts.remote && !existsSync(join(dir, ".git"))) return fail(noRemote); // never set up: nothing to do, nothing created
|
|
102
170
|
const release = lock(dir);
|
|
103
171
|
if (!release) return fail("another sync is running on this machine");
|
|
@@ -124,7 +192,7 @@ export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Pr
|
|
|
124
192
|
if (!url) return fail(noRemote);
|
|
125
193
|
// Long-term entries hold note text: a public remote would publish them.
|
|
126
194
|
const visibility = await (opts.visibility ?? remoteVisibility)(url);
|
|
127
|
-
if (visibility === "public") return fail("the sync remote is public;
|
|
195
|
+
if (visibility === "public") return fail("the sync remote is public; memory syncs only to a private remote");
|
|
128
196
|
const warning = visibility === "unknown" ? "the sync remote's visibility is unknown; keep it private" : undefined;
|
|
129
197
|
|
|
130
198
|
const own = `log/${store.machine.id}.jsonl`;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readCatalogFresh, type CatalogProject } from "./catalog.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* #15 T5 / D3: `amem usage`. Totals and the largest shards, derived from the shards through the catalog scan (never a number of its own). A forgotten
|
|
7
|
+
* project is out of the default view and the totals; `includeForgotten` shows it. A curated count the catalog could not read is `null`, never zero.
|
|
8
|
+
*/
|
|
9
|
+
export interface UsageTotals { shards: number; captureRows: number; captureBytes: number; curatedEntries: number | null; diskBytes: number }
|
|
10
|
+
/** `curatedSplit`: curated notes are counted per project only once the home is split into shards (`amem migrate --shards`); before that they live in one shared store and no per-project count exists, so it is unavailable, never 0. */
|
|
11
|
+
export interface UsageReport { projects: CatalogProject[]; totals: UsageTotals; largest: CatalogProject[]; curatedSplit: boolean }
|
|
12
|
+
|
|
13
|
+
export function usageReport(base: string, opts: { limit?: number; includeForgotten?: boolean } = {}): UsageReport {
|
|
14
|
+
const limit = opts.limit ?? 5;
|
|
15
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error("--limit takes a number from 1 to 100");
|
|
16
|
+
const projects = readCatalogFresh(base).filter(project => opts.includeForgotten === true || project.state !== "forgotten");
|
|
17
|
+
const curatedSplit = existsSync(join(base, "shards.json"));
|
|
18
|
+
const curated = curatedSplit && projects.every(project => project.curatedEntries !== null) ? projects.reduce((sum, project) => sum + (project.curatedEntries ?? 0), 0) : null;
|
|
19
|
+
const totals: UsageTotals = { shards: projects.length, captureRows: projects.reduce((s, p) => s + p.captureRows, 0), captureBytes: projects.reduce((s, p) => s + p.captureBytes, 0),
|
|
20
|
+
curatedEntries: curated, diskBytes: projects.reduce((s, p) => s + p.diskBytes, 0) };
|
|
21
|
+
const largest = [...projects].sort((a, b) => b.diskBytes - a.diskBytes || b.captureBytes - a.captureBytes || a.key.localeCompare(b.key)).slice(0, limit);
|
|
22
|
+
return { projects, totals, largest, curatedSplit };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MB = 1048576;
|
|
26
|
+
const mb = (n: number): string => `${(n / MB).toFixed(1)} MB`;
|
|
27
|
+
|
|
28
|
+
/** The non-JSON rendering of `amem usage`. */
|
|
29
|
+
export function usageText(report: UsageReport): string {
|
|
30
|
+
const t = report.totals;
|
|
31
|
+
return [
|
|
32
|
+
`${t.shards} project shard(s): ${t.captureRows} capture rows (${mb(t.captureBytes)}), ${t.curatedEntries === null ? `curated count unavailable${report.curatedSplit ? "" : " (home not split into shards)"}` : `${t.curatedEntries} curated entries`}, ${mb(t.diskBytes)} on disk`,
|
|
33
|
+
...(report.largest.length ? ["largest:", ...report.largest.map(p => ` ${mb(p.diskBytes).padStart(10)} ${p.key}${p.state === "active" ? "" : ` (${p.state})`} ${p.captureRows} rows`)] : []),
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CandidateStore, Counter, CounterKey, CounterValues } from "./candidates.ts";
|
|
2
|
+
|
|
3
|
+
export type MemoryUsePort = Pick<CandidateStore, "recordCounter" | "counters">;
|
|
4
|
+
export interface MemoryUseContext { allowed?: () => boolean; assertCurrent?: () => void; aliases?: readonly string[] }
|
|
5
|
+
export interface MemoryUseSummary { state: "known" | "unknown"; records: number | null; rendered: number | null; delivered: number | null; cited: number | null }
|
|
6
|
+
const unknown = (): MemoryUseSummary => ({ state: "unknown", records: null, rendered: null, delivered: null, cited: null });
|
|
7
|
+
const entry = (id: string): string => { const normalized = id.replace(/^mem:/, ""); if (!/^[a-f0-9]{32}$/.test(normalized)) throw Error("invalid memory use entry"); return normalized; };
|
|
8
|
+
const projectKey = (project: string): string => { if (typeof project !== "string" || !project.trim() || project.length > 400 || project.includes("\0")) throw Error("invalid memory use project"); return project; };
|
|
9
|
+
function guard(context: MemoryUseContext): void { if (context.allowed?.() === false) throw Error("memory use unavailable"); context.assertCurrent?.(); }
|
|
10
|
+
const count = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
|
|
11
|
+
/** Counts are explicit per-delivery totals, never increments or inferred delivery/citation events. */
|
|
12
|
+
export function recordMemoryUse(port: MemoryUsePort, key: CounterKey, values: CounterValues, context: MemoryUseContext = {}): boolean {
|
|
13
|
+
try {
|
|
14
|
+
guard(context);
|
|
15
|
+
const normalized = { ...key, project: projectKey(key.project), entryId: entry(key.entryId) };
|
|
16
|
+
if (typeof key.deliveryId !== "string" || !key.deliveryId.trim() || key.deliveryId.length > 200 || key.deliveryId.includes("\0")) return false;
|
|
17
|
+
for (const metric of [values.rendered, values.delivered, values.cited]) if (metric !== undefined && metric !== null && !count(metric)) return false;
|
|
18
|
+
guard(context); port.recordCounter(normalized, { ...values }, "public"); guard(context); return true;
|
|
19
|
+
} catch { return false; }
|
|
20
|
+
}
|
|
21
|
+
/** Unknown individual metrics stay unknown across the complete matching delivery set. */
|
|
22
|
+
export function readMemoryUse(port: MemoryUsePort, project: string, id: string, context: MemoryUseContext = {}): MemoryUseSummary {
|
|
23
|
+
try {
|
|
24
|
+
guard(context); const normalized = entry(id), projects = [...new Set([projectKey(project), ...(context.aliases ?? []).map(projectKey)])];
|
|
25
|
+
const rows = new Map<string, Counter>();
|
|
26
|
+
for (const scope of projects) {
|
|
27
|
+
guard(context); const found = port.counters(scope, normalized); guard(context);
|
|
28
|
+
if (!Array.isArray(found)) return unknown();
|
|
29
|
+
for (const row of found) {
|
|
30
|
+
if (!row?.key || !projects.includes(row.key.project) || row.key.entryId !== normalized) continue;
|
|
31
|
+
if (typeof row.id !== "string" || !row.id) return unknown();
|
|
32
|
+
const prior = rows.get(row.id);
|
|
33
|
+
if (prior && JSON.stringify(prior) !== JSON.stringify(row)) return unknown();
|
|
34
|
+
rows.set(row.id, row);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const metric = (name: keyof CounterValues): number | null => {
|
|
38
|
+
if (!rows.size) return null;
|
|
39
|
+
let total = 0;
|
|
40
|
+
for (const row of rows.values()) { const value = row[name]; if (!count(value) || !Number.isSafeInteger(total + value)) return null; total += value; }
|
|
41
|
+
return total;
|
|
42
|
+
};
|
|
43
|
+
const result: MemoryUseSummary = { state: "known", records: rows.size, rendered: metric("rendered"), delivered: metric("delivered"), cited: metric("cited") };
|
|
44
|
+
guard(context); return result;
|
|
45
|
+
} catch { return unknown(); }
|
|
46
|
+
}
|
|
47
|
+
/** Creating callbacks does nothing. Each explicit callback merges only its named metric using the same delivery key. */
|
|
48
|
+
export function memoryUseCallbacks(port: MemoryUsePort, key: CounterKey, context: MemoryUseContext = {}): { rendered: (count?: number) => boolean; delivered: (count?: number) => boolean; cited: (count?: number) => boolean } {
|
|
49
|
+
const bound = { ...key };
|
|
50
|
+
return { rendered: (value = 1) => recordMemoryUse(port, bound, { rendered: value }, context), delivered: (value = 1) => recordMemoryUse(port, bound, { delivered: value }, context), cited: (value = 1) => recordMemoryUse(port, bound, { cited: value }, context) };
|
|
51
|
+
}
|