@staix/agent-mem 0.6.0 → 0.8.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 +12 -2
- package/README.md +143 -18
- package/package.json +1 -1
- package/src/cli.ts +537 -39
- package/src/commands-table.ts +60 -16
- package/src/hook-spool.ts +24 -2
- package/src/hooks.ts +9 -5
- package/src/index.ts +20 -0
- package/src/ingest.ts +3 -2
- package/src/mcp-memory.ts +168 -33
- package/src/mcp.ts +43 -7
- package/src/memory/aliases.ts +4 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +228 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +139 -11
- package/src/memory/capture-input.ts +8 -3
- package/src/memory/capture.ts +30 -5
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/consolidate.ts +78 -0
- package/src/memory/consolidation-clusters.ts +56 -0
- package/src/memory/consolidation-model.ts +97 -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 +415 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +145 -0
- package/src/memory/eval-set-ko.json +90 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +152 -0
- package/src/memory/evidence.ts +76 -0
- package/src/memory/external-import.ts +267 -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/metrics.ts +2 -2
- 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 +12 -1
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +157 -0
- package/src/memory/project-import.ts +147 -0
- 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/store.ts +344 -35
- package/src/memory/sync-cadence.ts +149 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +91 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +10 -4
- package/src/session-brief-worker.ts +3 -2
- package/src/session-brief.ts +74 -15
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +296 -0
- package/src/setup.ts +327 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { generationDir, readSelector } from "./generation.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* F16, `amem sync --if-due`: the person's own shell (an EXIT trap) asks amem to sync when it is due; amem itself runs nothing between sessions and never
|
|
7
|
+
* registers a hook for it. Like every mutating command it needs a terminal, so an unattended scheduler (cron, launchd) is not a caller (a hook may not do network work: AGENTS.md, test/hooks-network.test.ts). The only state is one small stamp beside the logs.
|
|
8
|
+
* The stamp holds times and a failure count, never an error text (a git error can carry a remote URL).
|
|
9
|
+
*/
|
|
10
|
+
export interface SyncState { lastAttemptAt: string | null; lastOkAt: string | null; failures: number }
|
|
11
|
+
export const SYNC_STATE_FILE = "sync-state.json";
|
|
12
|
+
/** After a success, the next attempt is due this long after the last one. */
|
|
13
|
+
export const SYNC_INTERVAL_MS = 60 * 60_000;
|
|
14
|
+
/** After a failure the wait starts here and doubles with each further failure. */
|
|
15
|
+
export const SYNC_BACKOFF_BASE_MS = 15 * 60_000;
|
|
16
|
+
export const SYNC_BACKOFF_CAP_MS = 24 * 60 * 60_000;
|
|
17
|
+
const MAX_FAILURES = 30, MAX_BYTES = 4096;
|
|
18
|
+
const EMPTY: SyncState = { lastAttemptAt: null, lastOkAt: null, failures: 0 };
|
|
19
|
+
const iso = (value: unknown): string | null => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value ? value : null;
|
|
20
|
+
|
|
21
|
+
/** Never throws: a missing, oversized or malformed stamp is "no history", which makes a sync due. */
|
|
22
|
+
export function readSyncState(home: string): SyncState {
|
|
23
|
+
let fd: number | undefined;
|
|
24
|
+
try {
|
|
25
|
+
fd = openSync(join(home, SYNC_STATE_FILE), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
26
|
+
const stat = fstatSync(fd);
|
|
27
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) return { ...EMPTY };
|
|
28
|
+
const buffer = Buffer.alloc(stat.size), size = readSync(fd, buffer, 0, buffer.length, 0);
|
|
29
|
+
const value = JSON.parse(buffer.subarray(0, size).toString("utf8")) as Record<string, unknown>;
|
|
30
|
+
const failures = Number.isSafeInteger(value.failures) && (value.failures as number) >= 0 ? Math.min(value.failures as number, MAX_FAILURES) : 0;
|
|
31
|
+
return { lastAttemptAt: iso(value.lastAttemptAt), lastOkAt: iso(value.lastOkAt), failures };
|
|
32
|
+
} catch { return { ...EMPTY }; }
|
|
33
|
+
finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to close */ } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The wait after the last attempt: the plain interval after a success, 15 minutes doubling to a day after failures. */
|
|
37
|
+
export function syncWaitMs(failures: number): number {
|
|
38
|
+
return failures <= 0 ? SYNC_INTERVAL_MS : Math.min(SYNC_BACKOFF_BASE_MS * 2 ** (failures - 1), SYNC_BACKOFF_CAP_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The earliest next attempt, or null when none was recorded (due now). */
|
|
42
|
+
export function nextSyncAt(state: SyncState): number | null {
|
|
43
|
+
return state.lastAttemptAt === null ? null : Date.parse(state.lastAttemptAt) + syncWaitMs(state.failures);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `force` (`--force`) ignores the schedule and the backoff. */
|
|
47
|
+
export function syncDue(state: SyncState, now: number, force = false): boolean {
|
|
48
|
+
const next = nextSyncAt(state);
|
|
49
|
+
return force || next === null || now >= next;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `--if-due` stops a running git step after this long, so a shell that runs it on exit is held for a bounded time. */
|
|
53
|
+
export const SYNC_IF_DUE_DEADLINE_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The stop signal of one `--if-due` run: the 30 second deadline, or any way the person ends it: Ctrl-C (SIGINT), SIGTERM, SIGHUP (the terminal closed, which is also how an
|
|
57
|
+
* EXIT trap's shell can go away) and SIGQUIT. git runs in its own process group while a stop signal is given (so the stop reaches its transport children), which also means a
|
|
58
|
+
* terminal's signals no longer reach git by themselves: amem turns each of them into the abort. `on`, not `once`: a second Ctrl-C must not fall back to the default action
|
|
59
|
+
* (which would end amem and strand git) while the first is still stopping git; the handlers stay until `dispose`. A process that dies some other way is covered by the exit
|
|
60
|
+
* handler in sync.ts, which signals every live git group. The emitter is a seam for tests; the CLI passes `process`.
|
|
61
|
+
*/
|
|
62
|
+
export const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"] as const;
|
|
63
|
+
export type StopCause = "person" | "deadline";
|
|
64
|
+
export function stopSignal(ms: number, emitter: { on(name: string, fn: () => void): unknown; off(name: string, fn: () => void): unknown }, names: readonly string[] = STOP_SIGNALS): { signal: AbortSignal; dispose: () => void; cause: () => StopCause | undefined; signalName: () => string | undefined } {
|
|
65
|
+
const controller = new AbortController(), deadline = AbortSignal.timeout(ms);
|
|
66
|
+
let cause: StopCause | undefined, named: string | undefined;
|
|
67
|
+
// The first thing that ends the run is its cause: a person who closes the terminal after the deadline already fired did not stop it.
|
|
68
|
+
const handlers = names.map(name => [name, () => { if (cause === undefined) { cause = "person"; named = name; } controller.abort(); }] as const);
|
|
69
|
+
deadline.addEventListener("abort", () => { cause ??= "deadline"; }, { once: true });
|
|
70
|
+
for (const [name, handler] of handlers) emitter.on(name, handler);
|
|
71
|
+
return { signal: AbortSignal.any([controller.signal, deadline]), dispose: () => { for (const [name, handler] of handlers) emitter.off(name, handler); }, cause: () => cause, signalName: () => named };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* End this process the way the signal would have, once amem's own handlers are removed (a handler suppresses the default action, so a run a person ended would otherwise exit 1
|
|
76
|
+
* instead of 130, 143, 129). SIGQUIT's default action is a core dump, and Bun honours it: a re-raised SIGQUIT on Linux writes a core file the size of the process image (measured
|
|
77
|
+
* 5.9 GB, taking seconds, in a container with `ulimit -c unlimited`) while the shell waits for the exit, so it exits with the status that death would have had (128 + 3) at once.
|
|
78
|
+
*/
|
|
79
|
+
export function endLikeSignal(name: string): void {
|
|
80
|
+
if (name === "SIGQUIT") process.exit(128 + 3);
|
|
81
|
+
process.kill(process.pid, name as NodeJS.Signals);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The repository the active profile syncs through: a curated split keeps a private cache in `shard-sync/objects.git`, a generation home keeps its own `.git` inside the
|
|
86
|
+
* generation directory (the memory home's own `.git` is then only the legacy input), and shared-v1 keeps `.git` in the memory home. Undefined when the profile cannot be
|
|
87
|
+
* resolved (an unreadable selector): no remote is then reported rather than a guess.
|
|
88
|
+
*/
|
|
89
|
+
function syncRepository(home: string): string | undefined {
|
|
90
|
+
try {
|
|
91
|
+
if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
|
|
92
|
+
const { selector } = readSelector(home);
|
|
93
|
+
if (selector?.kind === "generation") return join(generationDir(home, selector.generation!), ".git", "config");
|
|
94
|
+
return join(home, ".git", "config");
|
|
95
|
+
} catch { return undefined; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether the active profile has a sync remote configured, from its repository's own config file: no git process, and never the URL (it can carry a credential).
|
|
100
|
+
*/
|
|
101
|
+
export function remoteConfigured(home: string): boolean {
|
|
102
|
+
const file = syncRepository(home);
|
|
103
|
+
if (file === undefined) return false;
|
|
104
|
+
let fd: number | undefined;
|
|
105
|
+
try {
|
|
106
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
107
|
+
const stat = fstatSync(fd);
|
|
108
|
+
if (!stat.isFile() || stat.size > 65536) return false;
|
|
109
|
+
const buffer = Buffer.alloc(stat.size), size = readSync(fd, buffer, 0, buffer.length, 0);
|
|
110
|
+
return /^\s*\[remote "origin"\]/m.test(buffer.subarray(0, size).toString("utf8"));
|
|
111
|
+
} catch { return false; } // no such repository
|
|
112
|
+
finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to close */ } }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A stamp from the future (a clock set back, a hand edit) would block the sync until then plus the wait: it counts as no history, and so does a success it claims. */
|
|
116
|
+
export function usableState(state: SyncState, now: number): SyncState {
|
|
117
|
+
const future = (value: string | null) => value !== null && Date.parse(value) > now;
|
|
118
|
+
if (future(state.lastAttemptAt)) return { lastAttemptAt: null, lastOkAt: future(state.lastOkAt) ? null : state.lastOkAt, failures: 0 };
|
|
119
|
+
return future(state.lastOkAt) ? { ...state, lastOkAt: null } : state;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a finished sync means for the schedule. `ok` clears the backoff. `local` is a refusal that the schedule cannot fix and that no remote ever saw, or a run its person ended (another sync or
|
|
124
|
+
* mutation holds the lock, no remote is configured, the configured policy declines a note, the remote is public, a usage error): it is not stamped, so it neither
|
|
125
|
+
* backs the schedule off nor makes a contended run look like a failed one. Everything else is a `failed` transport or git step, and an error this module does not
|
|
126
|
+
* recognise counts as failed (the safe side: it waits instead of retrying every minute).
|
|
127
|
+
*/
|
|
128
|
+
export type SyncOutcome = "ok" | "failed" | "local";
|
|
129
|
+
const LOCAL = /^(usage:|another (sync|memory mutation|generation|shard sync)|no (generation )?sync remote|the configured privacy policy|the sync remote is public|this (machine's|store's) long-term|a line of this machine's long-term|more than \d+ long-term)/;
|
|
130
|
+
export function syncOutcome(error: string | undefined, cause?: StopCause): SyncOutcome {
|
|
131
|
+
if (error === undefined) return "ok";
|
|
132
|
+
// A run its person ended (Ctrl-C, a closing terminal) is not a failure of the remote: the same Ctrl-C reads "fetch failed" or "stopped" depending on which step it hit, so the cause,
|
|
133
|
+
// not the text, decides. Only the deadline (a remote that did not answer in time) stays a failure to back off from.
|
|
134
|
+
if (cause === "person") return "local";
|
|
135
|
+
return LOCAL.test(error.replace(/^amem sync: /, "")) ? "local" : "failed";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Records one attempt. A stamp that cannot be written never fails the sync it describes. */
|
|
139
|
+
export function recordSyncAttempt(home: string, ok: boolean, at: number): SyncState {
|
|
140
|
+
const prior = readSyncState(home), when = new Date(at).toISOString();
|
|
141
|
+
const next: SyncState = { lastAttemptAt: when, lastOkAt: ok ? when : prior.lastOkAt, failures: ok ? 0 : Math.min(prior.failures + 1, MAX_FAILURES) };
|
|
142
|
+
const file = join(home, SYNC_STATE_FILE), temp = `${file}.${process.pid}.tmp`;
|
|
143
|
+
try {
|
|
144
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
145
|
+
writeFileSync(temp, JSON.stringify(next) + "\n", { mode: 0o600 });
|
|
146
|
+
renameSync(temp, file);
|
|
147
|
+
} catch { try { rmSync(temp, { force: true }); } catch { /* best effort */ } }
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
@@ -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,47 @@ 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
|
-
|
|
48
|
+
let signalled = false;
|
|
49
|
+
const kill = () => { signalled = true; send("SIGTERM"); hard ??= setTimeout(() => send("SIGKILL"), 2_000); };
|
|
25
50
|
const timer = setTimeout(kill, ms);
|
|
26
51
|
signal?.addEventListener("abort", kill, { once: true });
|
|
27
52
|
try {
|
|
28
53
|
const [code, out, err] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
29
54
|
return { code, out: out.trim(), err: err.trim() };
|
|
30
|
-
} finally {
|
|
55
|
+
} finally {
|
|
56
|
+
// git is gone, but a transport child in its group that ignored SIGTERM (a credential helper, a proxy) may not be, and the timer that would have killed it is cleared below. Once the
|
|
57
|
+
// group was signalled, SIGKILL whatever is left of it (ESRCH when nothing is) before it is forgotten: it would otherwise live on at ppid 1 holding the repository.
|
|
58
|
+
if (group && signalled) try { process.kill(-p.pid, "SIGKILL"); } catch { /* the group is already gone */ }
|
|
59
|
+
unregister(); clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill);
|
|
60
|
+
}
|
|
31
61
|
}
|
|
32
62
|
|
|
33
63
|
export { git as syncGit };
|
|
@@ -37,29 +67,70 @@ export async function generationSyncGit(dir: string, args: string[], signal?: Ab
|
|
|
37
67
|
if (signal?.aborted) return { code: -1, out: "", err: "stopped" };
|
|
38
68
|
// A fixed child-only umask keeps Git-created metadata private without changing the daemon's process umask.
|
|
39
69
|
// Args are positional shell parameters, never interpolated or evaluated as shell code.
|
|
40
|
-
const
|
|
70
|
+
const group = signal !== undefined; // see `git` above: own process group when the caller has a deadline
|
|
71
|
+
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 } : {}) });
|
|
72
|
+
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); };
|
|
73
|
+
const unregister = group ? register(p.pid) : () => {};
|
|
41
74
|
// SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period.
|
|
42
75
|
let hard: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
-
|
|
76
|
+
let signalled = false;
|
|
77
|
+
const kill = () => { signalled = true; send("SIGTERM"); hard ??= setTimeout(() => send("SIGKILL"), 2_000); };
|
|
44
78
|
const timer = setTimeout(kill, ms);
|
|
45
79
|
signal?.addEventListener("abort", kill, { once: true });
|
|
46
80
|
try {
|
|
47
81
|
const [code, out, err] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
48
82
|
return { code, out: out.trim(), err: err.trim() };
|
|
49
|
-
} finally {
|
|
83
|
+
} finally {
|
|
84
|
+
// git is gone, but a transport child in its group that ignored SIGTERM (a credential helper, a proxy) may not be, and the timer that would have killed it is cleared below. Once the
|
|
85
|
+
// group was signalled, SIGKILL whatever is left of it (ESRCH when nothing is) before it is forgotten: it would otherwise live on at ppid 1 holding the repository.
|
|
86
|
+
if (group && signalled) try { process.kill(-p.pid, "SIGKILL"); } catch { /* the group is already gone */ }
|
|
87
|
+
unregister(); clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill);
|
|
88
|
+
}
|
|
50
89
|
}
|
|
51
90
|
|
|
52
91
|
|
|
53
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* 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
|
|
94
|
+
* `[git@]github.com:`; anything else is undefined, so it is `unknown` and never asked. An unanchored match let `https://notgithub.com/acme/notes`,
|
|
95
|
+
* `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
|
|
96
|
+
* 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.
|
|
97
|
+
*/
|
|
98
|
+
export function githubRepoOf(url: string): string | undefined {
|
|
99
|
+
const text = url.trim();
|
|
100
|
+
if (!text || /[\s\u0000-\u001f\u007f\\]/.test(text) || text.length > 512) return undefined;
|
|
101
|
+
let host: string, path: string;
|
|
102
|
+
const scp = /^(?:([A-Za-z0-9._-]+)@)?([^:/@]+):([^/].*)$/.exec(text); // `[user@]host:path`, never a URL with `://`
|
|
103
|
+
if (!text.includes("://") && scp) {
|
|
104
|
+
if (scp[1] !== undefined && scp[1] !== "git") return undefined;
|
|
105
|
+
host = scp[2]!; path = scp[3]!;
|
|
106
|
+
} else {
|
|
107
|
+
let parsed: URL;
|
|
108
|
+
try { parsed = new URL(text); } catch { return undefined; }
|
|
109
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "ssh:") return undefined;
|
|
110
|
+
if (parsed.port) return undefined;
|
|
111
|
+
if (parsed.search || parsed.hash) return undefined;
|
|
112
|
+
if (parsed.protocol === "ssh:" && parsed.username !== "git") return undefined;
|
|
113
|
+
host = parsed.hostname; path = parsed.pathname.replace(/^\//, "");
|
|
114
|
+
}
|
|
115
|
+
if (host.toLowerCase() !== "github.com") return undefined;
|
|
116
|
+
const match = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?\/?$/.exec(path);
|
|
117
|
+
return match && match[2] !== "." && match[2] !== ".." ? `${match[1]}/${match[2]}` : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** GitHub remotes are asked through `gh`, pinned to github.com; any other host, or a `gh` that cannot answer, is unknown. */
|
|
54
121
|
export async function remoteVisibility(url: string): Promise<Visibility> {
|
|
55
|
-
const repo =
|
|
122
|
+
const repo = githubRepoOf(url);
|
|
56
123
|
if (!repo) return "unknown";
|
|
57
124
|
try {
|
|
58
|
-
|
|
125
|
+
// 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.
|
|
126
|
+
const env: Record<string, string | undefined> = { ...process.env, GH_HOST: "github.com" };
|
|
127
|
+
delete env.GH_ENTERPRISE_TOKEN; delete env.GITHUB_ENTERPRISE_TOKEN;
|
|
128
|
+
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
129
|
const timer = setTimeout(() => p.kill("SIGKILL"), 15_000);
|
|
60
130
|
const [code, out] = await Promise.all([p.exited, new Response(p.stdout).text()]);
|
|
61
131
|
clearTimeout(timer);
|
|
62
132
|
const v = out.trim().toLowerCase();
|
|
133
|
+
// 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
134
|
return code === 0 && (v === "private" || v === "internal") ? "private" : code === 0 && v === "public" ? "public" : "unknown";
|
|
64
135
|
} catch { return "unknown"; }
|
|
65
136
|
}
|
|
@@ -88,16 +159,25 @@ export interface SyncOptions {
|
|
|
88
159
|
/** Set (or replace) the remote first: a person's choice. */
|
|
89
160
|
remote?: string;
|
|
90
161
|
visibility?: (url: string) => Promise<Visibility>;
|
|
162
|
+
/** An extra screen over each outbound row's text, on top of the machine's configured policy (the CLI passes the repository-narrowed policy). */
|
|
163
|
+
screen?: RowScreen;
|
|
91
164
|
/** A stopping hub aborts: the running git step is killed and the sync ends without touching the store. */
|
|
92
165
|
signal?: AbortSignal;
|
|
93
166
|
}
|
|
94
167
|
|
|
95
168
|
export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
|
|
169
|
+
// 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).
|
|
170
|
+
const pass = outboundScreen(store.profile.home, opts.screen);
|
|
171
|
+
for (const target of store.openedSyncTargets()) {
|
|
172
|
+
const screened = target.profile.kind === "generation" ? screenOutboundRows(target, pass) : screenOwnSegment(target.dir, target.machine.id, pass);
|
|
173
|
+
if (!screened.ok) return { ok: false, pushed: false, entries: 0, error: screened.reason };
|
|
174
|
+
}
|
|
175
|
+
if (store.profile.sharded) return (await import("./shard-sync.ts")).syncShardedMemory(store, opts);
|
|
96
176
|
if (store.profile.kind === "generation") return syncGenerationMemory(store, opts);
|
|
97
177
|
const dir = store.dir;
|
|
98
178
|
const fail = (error: string): SyncResult => ({ ok: false, pushed: false, entries: 0, error });
|
|
99
179
|
if (opts.signal?.aborted) return fail("stopped");
|
|
100
|
-
const noRemote = "no sync remote: a person sets one with
|
|
180
|
+
const noRemote = "no sync remote: a person sets one with `amem sync --remote <private git url>`";
|
|
101
181
|
if (!opts.remote && !existsSync(join(dir, ".git"))) return fail(noRemote); // never set up: nothing to do, nothing created
|
|
102
182
|
const release = lock(dir);
|
|
103
183
|
if (!release) return fail("another sync is running on this machine");
|
|
@@ -124,7 +204,7 @@ export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Pr
|
|
|
124
204
|
if (!url) return fail(noRemote);
|
|
125
205
|
// Long-term entries hold note text: a public remote would publish them.
|
|
126
206
|
const visibility = await (opts.visibility ?? remoteVisibility)(url);
|
|
127
|
-
if (visibility === "public") return fail("the sync remote is public;
|
|
207
|
+
if (visibility === "public") return fail("the sync remote is public; memory syncs only to a private remote");
|
|
128
208
|
const warning = visibility === "unknown" ? "the sync remote's visibility is unknown; keep it private" : undefined;
|
|
129
209
|
|
|
130
210
|
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
|
+
}
|
package/src/memory/work-lists.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
|
7
7
|
import { mutationDatabase } from "./mutation.ts";
|
|
8
8
|
import { canonicalPath } from "./platform.ts";
|
|
9
9
|
import { looksSecret } from "./screen.ts";
|
|
10
|
+
import { stripPrivateDeep, stripPrivateSpans } from "./private-spans.ts";
|
|
10
11
|
import { ensureShardDescriptor, resolveAlias, shardDir } from "./shard.ts";
|
|
11
12
|
import { machineId } from "./machine.ts";
|
|
12
13
|
|
|
@@ -337,9 +338,12 @@ export class WorkListStore {
|
|
|
337
338
|
};
|
|
338
339
|
}
|
|
339
340
|
|
|
340
|
-
async write(
|
|
341
|
+
async write(rawList: string, input: WorkListFields, source?: WorkListSource): Promise<{ id: string; already: boolean }> {
|
|
342
|
+
if (!bounded(rawList, 300)) return invalid();
|
|
343
|
+
// The list NAME is text too: a `<private>` span in it is never stored (a name that is nothing but a span is invalid), and the MCP write and the claude-mem import both land here.
|
|
344
|
+
const list = stripPrivateSpans(rawList).trim(); // trimmed so "queue <private>x</private>" and "queue" are one list
|
|
341
345
|
if (!bounded(list, 300)) return invalid();
|
|
342
|
-
const fields = fieldsOf(input), origin = sourceOf(source);
|
|
346
|
+
const fields = fieldsOf(stripPrivateDeep(input)), origin = sourceOf(source); // `<private>` spans are never stored
|
|
343
347
|
const at = origin?.at ?? this.locked(() => {
|
|
344
348
|
this.refresh();
|
|
345
349
|
const previous = this.db.query("SELECT max(at) AS at FROM work_ops WHERE machine = ?").get(this.machine) as { at: number | null };
|
|
@@ -369,9 +373,11 @@ export class WorkListStore {
|
|
|
369
373
|
});
|
|
370
374
|
}
|
|
371
375
|
|
|
372
|
-
async read(
|
|
376
|
+
async read(rawList?: string, includeClosed = false): Promise<WorkListView[]> {
|
|
373
377
|
this.checkBudget();
|
|
374
|
-
if (
|
|
378
|
+
if (rawList !== undefined && !bounded(rawList, 300) || typeof includeClosed !== "boolean") return invalid();
|
|
379
|
+
const list = rawList === undefined ? undefined : stripPrivateSpans(rawList).trim(); // the same name a write stored
|
|
380
|
+
if (list !== undefined && !bounded(list, 300)) return invalid();
|
|
375
381
|
const rows = this.merged();
|
|
376
382
|
const views = new Map<string, { fields: WorkListFields; items: Map<string, WorkListFields> }>();
|
|
377
383
|
// ponytail: memory scales with the current distinct lists/items/fields, not historical operations. A caller
|
|
@@ -6,6 +6,7 @@ import { resolveAlias } from "./memory/aliases.ts";
|
|
|
6
6
|
import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
|
|
7
7
|
import { drainHookCaptures, hookCaptureScope, quarantineHookScope } from "./hook-spool.ts";
|
|
8
8
|
import { createStandalonePolicy } from "./memory/policy.ts";
|
|
9
|
+
import { configuredPolicyOptions } from "./memory/config.ts";
|
|
9
10
|
|
|
10
11
|
async function readOptions(): Promise<string> {
|
|
11
12
|
const reader = Bun.stdin.stream().getReader(), chunks: Uint8Array[] = []; let bytes = 0;
|
|
@@ -45,7 +46,7 @@ if (import.meta.main) {
|
|
|
45
46
|
const remaining = () => options.deadlineAt - (performance.timeOrigin + performance.now());
|
|
46
47
|
if (remaining() <= 0) stop();
|
|
47
48
|
setTimeout(stop, remaining()).unref();
|
|
48
|
-
const policy = createStandalonePolicy(options.policy), event = options.event;
|
|
49
|
+
const policy = createStandalonePolicy(configuredPolicyOptions(options.base!, options.cwd, options.policy)), event = options.event;
|
|
49
50
|
if (!(HOOK_EVENTS as readonly string[]).includes(event)) stop();
|
|
50
51
|
const expectedScope = hookCaptureScope(options.cwd, options.base!);
|
|
51
52
|
let project: string;
|
|
@@ -62,7 +63,7 @@ if (import.meta.main) {
|
|
|
62
63
|
let store: CaptureStore | undefined;
|
|
63
64
|
try {
|
|
64
65
|
const drained = await drainHookCaptures({ ...options, base: options.base!, project, expectedScope, timeRemaining: remaining }, (input, receipt) => {
|
|
65
|
-
store ??= new CaptureStore({ memoryDir: options.base!, project, busyTimeoutMs: 0 });
|
|
66
|
+
store ??= new CaptureStore({ memoryDir: options.base!, project, busyTimeoutMs: 0, cwd: options.cwd });
|
|
66
67
|
return store.append(input, { receipt });
|
|
67
68
|
});
|
|
68
69
|
captured = drained.captured.includes(options.current);
|