@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.
Files changed (76) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +15 -0
  3. package/README.md +186 -21
  4. package/bin/amem.js +26 -3
  5. package/hooks/hooks.json +81 -0
  6. package/package.json +3 -1
  7. package/src/cli.ts +492 -39
  8. package/src/commands-table.ts +56 -15
  9. package/src/hook-contract.ts +3 -0
  10. package/src/hook-git.ts +42 -0
  11. package/src/hook-native.ts +92 -0
  12. package/src/hook-project.ts +206 -0
  13. package/src/hook-resources.ts +15 -0
  14. package/src/hook-spool.ts +566 -0
  15. package/src/hooks.ts +253 -0
  16. package/src/index.ts +18 -0
  17. package/src/ingest.ts +351 -0
  18. package/src/mcp-memory.ts +141 -33
  19. package/src/mcp.ts +42 -7
  20. package/src/memory/aliases.ts +87 -0
  21. package/src/memory/bridge.ts +8 -2
  22. package/src/memory/candidate-pending.ts +119 -0
  23. package/src/memory/candidates.ts +144 -47
  24. package/src/memory/capacity-split.ts +76 -0
  25. package/src/memory/capacity.ts +162 -59
  26. package/src/memory/capture-input.ts +36 -0
  27. package/src/memory/capture.ts +153 -39
  28. package/src/memory/catalog.ts +43 -10
  29. package/src/memory/commands.ts +98 -44
  30. package/src/memory/config.ts +167 -0
  31. package/src/memory/cross-project.ts +75 -0
  32. package/src/memory/curated-read.ts +29 -0
  33. package/src/memory/decay-review.ts +136 -0
  34. package/src/memory/doctor.ts +412 -0
  35. package/src/memory/editor.ts +58 -0
  36. package/src/memory/eval-fixture.ts +137 -0
  37. package/src/memory/eval-set.json +443 -0
  38. package/src/memory/eval.ts +150 -0
  39. package/src/memory/gc.ts +97 -0
  40. package/src/memory/generation-index.ts +22 -17
  41. package/src/memory/generation-sync.ts +3 -0
  42. package/src/memory/goal-promotion.ts +71 -0
  43. package/src/memory/home.ts +15 -4
  44. package/src/memory/intent-brief.ts +89 -0
  45. package/src/memory/lifecycle-log.ts +54 -0
  46. package/src/memory/lifecycle.ts +61 -0
  47. package/src/memory/machine.ts +31 -0
  48. package/src/memory/metrics.ts +63 -0
  49. package/src/memory/mutation.ts +6 -4
  50. package/src/memory/note-add.ts +100 -0
  51. package/src/memory/path-glob.ts +94 -0
  52. package/src/memory/plan-guard.ts +47 -0
  53. package/src/memory/platform.ts +9 -4
  54. package/src/memory/policy.ts +44 -13
  55. package/src/memory/private-spans.ts +32 -0
  56. package/src/memory/project-export.ts +122 -0
  57. package/src/memory/project.ts +129 -20
  58. package/src/memory/reader-lease.ts +22 -10
  59. package/src/memory/recall-staleness.ts +56 -0
  60. package/src/memory/recall.ts +74 -0
  61. package/src/memory/repo-scope.ts +404 -0
  62. package/src/memory/shard-sync-format.ts +116 -0
  63. package/src/memory/shard-sync.ts +421 -0
  64. package/src/memory/shard.ts +3 -80
  65. package/src/memory/store.ts +286 -56
  66. package/src/memory/sync-cadence.ts +139 -0
  67. package/src/memory/sync-screen.ts +86 -0
  68. package/src/memory/sync.ts +79 -11
  69. package/src/memory/usage.ts +35 -0
  70. package/src/memory/use-telemetry.ts +51 -0
  71. package/src/memory/work-lists.ts +115 -38
  72. package/src/session-brief-worker.ts +82 -0
  73. package/src/session-brief.ts +261 -0
  74. package/src/setup-owned.ts +120 -0
  75. package/src/setup-toml.ts +291 -0
  76. 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
+ }
@@ -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
- const p = Bun.spawn(memoryGitArgv(["-C", dir, ...args], { credentials: true }), { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv() });
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 = () => { p.kill("SIGTERM"); hard ??= setTimeout(() => p.kill("SIGKILL"), 2_000); };
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 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 }) });
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 = () => { p.kill("SIGTERM"); hard ??= setTimeout(() => p.kill("SIGKILL"), 2_000); };
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
- /** GitHub remotes are asked through `gh`; any other host, or a `gh` that cannot answer, is unknown. */
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 = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(url)?.[1];
110
+ const repo = githubRepoOf(url);
56
111
  if (!repo) return "unknown";
57
112
  try {
58
- const p = Bun.spawn(["gh", "repo", "view", repo, "--json", "visibility", "--jq", ".visibility"], { stdout: "pipe", stderr: "ignore", stdin: "ignore" });
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 ahub memory sync --remote <private git url>";
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; hub memory syncs only to a private remote");
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
+ }