@staix/agent-mem 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/package.json +1 -1
- package/src/cli.ts +31 -2
- package/src/commands-table.ts +8 -0
- package/src/index.ts +1 -0
- package/src/memory/candidates.ts +196 -0
- package/src/memory/commands.ts +3 -1
- package/src/memory/mutation.ts +22 -0
- package/src/memory/store.ts +113 -23
- package/src/memory/sync.ts +9 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
|
|
4
4
|
|
|
5
|
-
Version: 0.
|
|
5
|
+
Version: 0.2.0. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -46,6 +46,29 @@ amem sync --remote <private-git-url> --policy-free
|
|
|
46
46
|
|
|
47
47
|
Use `ahub memory` for the policy-applying path. `--policy-free` does not bypass the secret screen or the sync remote's privacy checks. Forgetting a shared note appends an operation; its previous content remains in the remote's git history.
|
|
48
48
|
|
|
49
|
+
## Candidate review
|
|
50
|
+
|
|
51
|
+
`amem review` lists this project's pending machine-local candidates. They are proposals, not memory notes and not evidence. Use JSON to inspect the complete candidate payload before accepting or rejecting its full 64-character ID:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
amem review --json
|
|
55
|
+
amem review list --kind correction --status all --limit 50 --json
|
|
56
|
+
amem review accept <full-candidate-id> --policy-free --json
|
|
57
|
+
amem review reject <full-candidate-id> --json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
All review commands remain person-only; acceptance and rejection require a terminal on stdin. Acceptance additionally requires `--policy-free`, acknowledging that the standalone CLI cannot apply a host's privacy policy; use `ahub memory` for the host policy path. The CLI refuses secret-looking candidate input or proposed note content. Listing does not open the ordinary memory index, and acceptance defers unrelated held-note promotions.
|
|
61
|
+
|
|
62
|
+
Candidates live in `candidates.db` beside the memory logs. This sidecar is machine-local: it is never synced, imported, or read by recall, briefs, ask or graph evidence. Hosts must supply an explicit public privacy verdict before putting candidates or counters; private, PII and unknown verdicts are refused. Library APIs are available, but this phase adds no automatic candidate producer or telemetry consumer. Candidate retention is 500 per project and 30 days; counters retain 10,000 per project and 90 days. Candidate payloads are capped at 16 KiB.
|
|
63
|
+
|
|
64
|
+
Acceptance creates ordinary v1 memory data. A correction retains its target's scope, project and storage reach, writes the corrected note and applies the existing `supersede` operation; its exact current target revision must match. A local target stays short-term; a synced target stays synced, including a demoted target whose effective tier is short. A fail becomes a long-term project note. The accepted text binds the candidate ID, target revision and target ID; a fail proposal without a target records `none` for both target fields. Both paths require the actual MemoryStore promotion screen to return `promote` at acceptance time; `keep`, `skip` or an absent screen refuses acceptance without falling back to short-term storage. Accepted v1 notes and operations follow the existing storage and sync rules; the candidate/counter sidecar remains local and outside evidence. No new v1 operation is introduced. Decay candidates are listing-only and cannot be accepted; a person may separately choose `ahub memory demote` or `forget`. The sidecar receipt, note append and correction's supersede append are separate durable writes. An interrupted acceptance can leave a note without its supersede; it is held for explicit inspection and never replayed automatically.
|
|
65
|
+
|
|
66
|
+
The library entry exports `CandidateStore({ home?, now? })`, `put(input, verdict)`, `recordCounter(key, values, verdict)`, and `accept(id, { project, actor, verdict, store, policy })`. `home` retains the parent-home convention (`<home>/memory`); the default uses `memoryHome()`. The host must authenticate person intent, bind the current project and privacy verdict, and supply a current acceptance policy plus the ordinary store's actual promotion screen. An `actor` string or caller-supplied public verdict alone is not authentication. Counter writes are idempotent snapshots for the same contextual key, not increments: observed `0` stays zero, while `null` means unknown (a later unknown value does not erase an observed value).
|
|
67
|
+
|
|
68
|
+
Open a review-only `MemoryStore` with `deferPromotions: true` to prevent unrelated automatic promotions during construction and reads. Explicit candidate acceptance still runs its promotion screen. This package registers no capture observer or host candidate tools; capture exclusion and authenticated producers must be established by the host before those integrations are enabled.
|
|
69
|
+
|
|
70
|
+
Acceptance normalizes its note once, including timestamp, content ID and short-term expiry, then screens and appends that exact body. The final target-revision check and correction appends hold the same machine-local `mutation-lock.db` writer transaction used by this version's ordinary memory, import and sync writers. This lock is independent of the derived index, so an ordinary write still persists when SQLite is busy. External or older writers that edit segments without this lock do not participate in serialization. The logs remain authoritative after an interrupted acceptance; the next read refreshes the derived index.
|
|
71
|
+
|
|
49
72
|
## Shell completion
|
|
50
73
|
|
|
51
74
|
Generate completion for `zsh`, `bash`, or `fish` with `amem completion <shell>`. Generation prints a script to stdout; it does not install files or edit shell configuration. The following commands are for a person's terminal.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { MemoryStore, looksSecret } from "./memory/store.ts";
|
|
3
3
|
import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
|
|
4
|
+
import { memoryProjectKey } from "./memory/project.ts";
|
|
5
|
+
import { CandidateStore, type CandidateFilter } from "./memory/candidates.ts";
|
|
4
6
|
|
|
5
7
|
import { commandHelp, parseCommand } from "./commands-table.ts";
|
|
6
8
|
import { completionScript } from "./completion.ts";
|
|
@@ -31,12 +33,39 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
31
33
|
throw error;
|
|
32
34
|
}
|
|
33
35
|
const { definition, options, rawArgs: args } = parsed;
|
|
34
|
-
const goal = parsed.path[0] === "goal", sub = goal ? parsed.path[1]! : parsed.path[0]!;
|
|
36
|
+
const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
|
|
35
37
|
const policyFree = options["--policy-free"] === true;
|
|
36
38
|
const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined;
|
|
37
39
|
if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
|
|
38
40
|
if (definition.policyFree && !policyFree) {
|
|
39
|
-
throw new Error(`${sub} requires --policy-free; use ahub memory ${sub} to apply the host's privacy policy`);
|
|
41
|
+
throw new Error(review ? "review accept requires --policy-free; use ahub memory to apply the host's privacy policy" : `${sub} requires --policy-free; use ahub memory ${sub} to apply the host's privacy policy`);
|
|
42
|
+
}
|
|
43
|
+
if (review) {
|
|
44
|
+
const id = parsed.positionals[0];
|
|
45
|
+
if (sub !== "list" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
|
|
46
|
+
const project = memoryProjectKey(context.cwd ?? process.cwd()), candidates = new CandidateStore();
|
|
47
|
+
try {
|
|
48
|
+
if (sub === "list") {
|
|
49
|
+
const rows = candidates.list({ project, ...(options["--status"] ? { status: options["--status"] as CandidateFilter["status"] } : {}),
|
|
50
|
+
...(options["--kind"] ? { kind: options["--kind"] as CandidateFilter["kind"] } : {}), ...(options["--limit"] ? { limit: Number(options["--limit"]) } : {}) });
|
|
51
|
+
return options["--json"] ? JSON.stringify(rows, null, 2) : rows.length ? rows.map(candidate => `${candidate.id} ${candidate.input.kind} ${candidate.status} ${candidate.createdAt}`).join("\n") : "no candidates waiting for review";
|
|
52
|
+
}
|
|
53
|
+
if (sub === "reject") {
|
|
54
|
+
candidates.reject(id!, { project, actor: "person", verdict: "public" });
|
|
55
|
+
return options["--json"] ? JSON.stringify({ candidateId: id, status: "rejected" }, null, 2) : `rejected candidate ${id}`;
|
|
56
|
+
}
|
|
57
|
+
const candidate = candidates.get(id!, project);
|
|
58
|
+
if (!candidate) throw new Error("candidate unavailable");
|
|
59
|
+
if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; use ahub memory demote or forget after review");
|
|
60
|
+
// Candidate acceptance uses the actual secret screen; the core defers unrelated automatic promotions.
|
|
61
|
+
const store = new MemoryStore({ deferPromotions: true, screen: note => looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
|
|
62
|
+
try {
|
|
63
|
+
const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store,
|
|
64
|
+
policy: (proposal, _target, note) => !looksSecret(JSON.stringify(proposal.input)) && !looksSecret(JSON.stringify(note)),
|
|
65
|
+
});
|
|
66
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ordinary v1 note ${result.noteId}`;
|
|
67
|
+
} finally { store.close(); }
|
|
68
|
+
} finally { candidates.close(); }
|
|
40
69
|
}
|
|
41
70
|
// ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
|
|
42
71
|
if (goal && sub === "link") throw new Error("goal link needs host integration; use ahub goal link");
|
package/src/commands-table.ts
CHANGED
|
@@ -47,6 +47,14 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
47
47
|
done: { description: "Complete a goal", args: [{ name: "id" }], mutates: true },
|
|
48
48
|
take: { description: "Take a goal lease", options: { "--force": flag("Override the prior lease") }, args: [{ name: "id" }], mutates: true },
|
|
49
49
|
} },
|
|
50
|
+
review: { description: "Review machine-local non-evidentiary candidates", options: json, defaultCommand: "list", commands: {
|
|
51
|
+
list: { description: "List review candidates", options: {
|
|
52
|
+
"--status": { description: "Candidate status", value: ["pending", "accepting", "accepted", "rejected", "all"] },
|
|
53
|
+
"--kind": { description: "Candidate kind", value: ["correction", "fail", "decay"] }, "--limit": text("Maximum candidates (1..500)"),
|
|
54
|
+
} },
|
|
55
|
+
accept: { description: "Accept one candidate through the current memory policy", options: policy, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
|
|
56
|
+
reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true },
|
|
57
|
+
} },
|
|
50
58
|
completion: { description: "Print static shell completion", args: [{ name: "shell", values: ["zsh", "bash", "fish"] }] },
|
|
51
59
|
help: { description: "Show help" }, version: { description: "Show version" },
|
|
52
60
|
};
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { memoryHome } from "./home.ts";
|
|
6
|
+
import { realPath } from "./platform.ts";
|
|
7
|
+
import { MemoryStore, type NoteInput, type View } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
export const CANDIDATE_DB_BUSY_TIMEOUT_MS = 3000;
|
|
10
|
+
export const CANDIDATE_RETENTION = { count: 500, ageMs: 30 * 86400_000 } as const;
|
|
11
|
+
export const COUNTER_RETENTION = { count: 10_000, ageMs: 90 * 86400_000 } as const;
|
|
12
|
+
export type CandidateVerdict = "public" | "private" | "pii" | "unknown";
|
|
13
|
+
export interface CandidateTarget { id: string; revision: string }
|
|
14
|
+
export type CandidateInput = { project: string } & (
|
|
15
|
+
| { kind: "correction"; target: CandidateTarget; proposedText: string; reason: string; noteKind?: "decision" | "contract" | "fail" }
|
|
16
|
+
| { kind: "fail"; target?: CandidateTarget; approach: string; symptom: string; cause: string; evidenceRefs: string[]; retryWhen: string }
|
|
17
|
+
| { kind: "decay"; target: CandidateTarget; reason: string }
|
|
18
|
+
);
|
|
19
|
+
export type CandidateStatus = "pending" | "accepting" | "accepted" | "rejected";
|
|
20
|
+
export interface Candidate { id: string; createdAt: string; status: CandidateStatus; input: CandidateInput; acceptedNoteId: string | null }
|
|
21
|
+
export interface CandidateFilter { project: string; status?: CandidateStatus | "all"; kind?: CandidateInput["kind"]; limit?: number }
|
|
22
|
+
export interface CounterKey { entryId: string; project: string; task?: string | null; turn?: string | null; session?: string | null; deliveryId: string }
|
|
23
|
+
export interface CounterValues { rendered?: number | null; delivered?: number | null; cited?: number | null }
|
|
24
|
+
export interface Counter extends CounterValues { id: string; key: CounterKey; createdAt: string; rendered: number | null; delivered: number | null; cited: number | null }
|
|
25
|
+
export interface CandidateAcceptance {
|
|
26
|
+
project: string;
|
|
27
|
+
/** The host binds this to authenticated person intent; it is not an authentication token. */
|
|
28
|
+
actor: "person" | "agent";
|
|
29
|
+
verdict: CandidateVerdict;
|
|
30
|
+
store: MemoryStore;
|
|
31
|
+
policy: (candidate: Candidate, target: View | undefined, note: NoteInput) => boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface CandidateAcceptanceResult { candidateId: string; noteId: string; alreadyAccepted: boolean }
|
|
34
|
+
|
|
35
|
+
function canonical(value: unknown): string {
|
|
36
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
37
|
+
if (value && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
38
|
+
return JSON.stringify(value);
|
|
39
|
+
}
|
|
40
|
+
const hash = (value: unknown) => createHash("sha256").update(canonical(value)).digest("hex");
|
|
41
|
+
const text = (value: unknown, max = 4000): value is string => typeof value === "string" && value.trim().length > 0 && value.length <= max && !value.includes("\0");
|
|
42
|
+
const entryId = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
|
|
43
|
+
const candidateId = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
44
|
+
const publicOnly = (verdict: CandidateVerdict) => { if (verdict !== "public") throw new Error("candidate data refused by host privacy verdict"); };
|
|
45
|
+
const personOnly = (actor: string) => { if (actor !== "person") throw new Error("candidate acceptance and rejection require a person"); };
|
|
46
|
+
const validTarget = (value: CandidateTarget | undefined) => value !== undefined && entryId(value.id) && candidateId(value.revision);
|
|
47
|
+
function validate(input: CandidateInput): void {
|
|
48
|
+
if (!input || !text(input.project, 400)) throw new Error("invalid candidate project");
|
|
49
|
+
if (input.kind === "correction") {
|
|
50
|
+
if (!validTarget(input.target) || !text(input.proposedText) || !text(input.reason) || input.noteKind !== undefined && !["decision", "contract", "fail"].includes(input.noteKind)) throw new Error("invalid correction candidate");
|
|
51
|
+
} else if (input.kind === "fail") {
|
|
52
|
+
if (input.target !== undefined && !validTarget(input.target) || ![input.approach, input.symptom, input.cause, input.retryWhen].every(x => text(x)) || !Array.isArray(input.evidenceRefs) || input.evidenceRefs.length < 1 || input.evidenceRefs.length > 16 || !input.evidenceRefs.every(x => text(x, 400))) throw new Error("invalid fail candidate");
|
|
53
|
+
} else if (input.kind === "decay") {
|
|
54
|
+
if (!validTarget(input.target) || !text(input.reason)) throw new Error("invalid decay candidate");
|
|
55
|
+
} else throw new Error("invalid candidate kind");
|
|
56
|
+
if (Buffer.byteLength(canonical(input), "utf8") > 16_384) throw new Error("candidate payload exceeds 16 KiB");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Includes current lifecycle state, so a changed promotion, supersession or demotion makes a proposal stale. */
|
|
60
|
+
export function candidateTargetRevision(target: View): string {
|
|
61
|
+
return hash({ ...target, about: [...target.about].sort(), supersedes: [...target.supersedes].sort() });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function counterKey(key: CounterKey): CounterKey {
|
|
65
|
+
if (!key || !entryId(key.entryId) || !text(key.project, 400) || !text(key.deliveryId, 200)) throw new Error("invalid counter key");
|
|
66
|
+
for (const value of [key.task, key.turn, key.session]) if (value !== undefined && value !== null && !text(value, 400)) throw new Error("invalid counter context");
|
|
67
|
+
return { entryId: key.entryId, project: key.project, task: key.task ?? null, turn: key.turn ?? null, session: key.session ?? null, deliveryId: key.deliveryId };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Explicitly opened machine-local data. Importing this module never opens a store or registers a consumer. */
|
|
71
|
+
export class CandidateStore {
|
|
72
|
+
readonly dir: string;
|
|
73
|
+
readonly path: string;
|
|
74
|
+
private readonly db: Database;
|
|
75
|
+
private readonly now: () => number;
|
|
76
|
+
constructor(options: { home?: string; now?: () => number } = {}) {
|
|
77
|
+
this.dir = options.home === undefined ? memoryHome() : join(options.home, "memory");
|
|
78
|
+
this.now = options.now ?? Date.now;
|
|
79
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 }); chmodSync(this.dir, 0o700);
|
|
80
|
+
this.path = join(this.dir, "candidates.db");
|
|
81
|
+
this.db = new Database(this.path, { create: true });
|
|
82
|
+
this.db.run(`PRAGMA busy_timeout = ${CANDIDATE_DB_BUSY_TIMEOUT_MS}`);
|
|
83
|
+
chmodSync(this.path, 0o600); this.db.run("PRAGMA journal_mode = WAL");
|
|
84
|
+
this.db.run("CREATE TABLE IF NOT EXISTS candidates (id TEXT PRIMARY KEY, project TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, at INTEGER NOT NULL, status TEXT NOT NULL, note_id TEXT)");
|
|
85
|
+
this.db.run("CREATE TABLE IF NOT EXISTS counters (id TEXT PRIMARY KEY, project TEXT NOT NULL, payload TEXT NOT NULL, at INTEGER NOT NULL, rendered INTEGER, delivered INTEGER, cited INTEGER)");
|
|
86
|
+
this.prune();
|
|
87
|
+
}
|
|
88
|
+
close(): void { this.db.close(); }
|
|
89
|
+
private prune(): void {
|
|
90
|
+
this.db.transaction(() => {
|
|
91
|
+
for (const [table, retention] of [["candidates", CANDIDATE_RETENTION], ["counters", COUNTER_RETENTION]] as const) {
|
|
92
|
+
this.db.query(`DELETE FROM ${table} WHERE at <= ?`).run(this.now() - retention.ageMs);
|
|
93
|
+
this.db.query(`DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY at DESC, rowid DESC) AS rank FROM ${table}) WHERE rank > ?)`).run(retention.count);
|
|
94
|
+
}
|
|
95
|
+
}).immediate();
|
|
96
|
+
}
|
|
97
|
+
private decode(row: { id: string; payload: string; at: number; status: CandidateStatus; note_id: string | null }): Candidate {
|
|
98
|
+
const input = JSON.parse(row.payload) as CandidateInput; validate(input);
|
|
99
|
+
if (hash({ schema: "agent-mem.candidate/v1", input }) !== row.id) throw new Error("candidate content hash mismatch");
|
|
100
|
+
return { id: row.id, createdAt: new Date(row.at).toISOString(), status: row.status, input, acceptedNoteId: row.note_id };
|
|
101
|
+
}
|
|
102
|
+
put(input: CandidateInput, verdict: CandidateVerdict): string {
|
|
103
|
+
publicOnly(verdict); validate(input); this.prune();
|
|
104
|
+
const payload = canonical(input), id = hash({ schema: "agent-mem.candidate/v1", input });
|
|
105
|
+
this.db.query("INSERT OR IGNORE INTO candidates (id, project, kind, payload, at, status) VALUES (?, ?, ?, ?, ?, 'pending')").run(id, input.project, input.kind, payload, this.now());
|
|
106
|
+
this.prune(); return id;
|
|
107
|
+
}
|
|
108
|
+
get(id: string, project: string): Candidate | undefined {
|
|
109
|
+
if (!candidateId(id) || !text(project, 400)) throw new Error("invalid candidate identity");
|
|
110
|
+
this.prune();
|
|
111
|
+
const row = this.db.query("SELECT * FROM candidates WHERE id = ? AND project = ?").get(id, project) as Parameters<CandidateStore["decode"]>[0] | null;
|
|
112
|
+
return row ? this.decode(row) : undefined;
|
|
113
|
+
}
|
|
114
|
+
list(filter: CandidateFilter): Candidate[] {
|
|
115
|
+
if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay"].includes(filter.kind)) throw new Error("invalid candidate filter");
|
|
116
|
+
const limit = filter.limit ?? CANDIDATE_RETENTION.count;
|
|
117
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500");
|
|
118
|
+
this.prune();
|
|
119
|
+
const where = ["project = ?"], bind: (string | number)[] = [filter.project];
|
|
120
|
+
if (filter.status === undefined) where.push("status IN ('pending', 'accepting')"); else if (filter.status !== "all") { where.push("status = ?"); bind.push(filter.status); }
|
|
121
|
+
if (filter.kind) { where.push("kind = ?"); bind.push(filter.kind); }
|
|
122
|
+
return (this.db.query(`SELECT * FROM candidates WHERE ${where.join(" AND ")} ORDER BY at DESC, rowid DESC LIMIT ?`).all(...bind, limit) as Parameters<CandidateStore["decode"]>[0][]).map(row => this.decode(row));
|
|
123
|
+
}
|
|
124
|
+
recordCounter(key: CounterKey, values: CounterValues, verdict: CandidateVerdict): string {
|
|
125
|
+
publicOnly(verdict); const normalized = counterKey(key);
|
|
126
|
+
for (const value of [values.rendered, values.delivered, values.cited]) if (value !== undefined && value !== null && (!Number.isSafeInteger(value) || value < 0)) throw new Error("counter values must be nonnegative integers or unknown");
|
|
127
|
+
this.prune(); const id = hash({ schema: "agent-mem.counter/v1", key: normalized });
|
|
128
|
+
this.db.query(`INSERT INTO counters (id, project, payload, at, rendered, delivered, cited) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
129
|
+
ON CONFLICT(id) DO UPDATE SET rendered = coalesce(excluded.rendered, counters.rendered), delivered = coalesce(excluded.delivered, counters.delivered), cited = coalesce(excluded.cited, counters.cited)`)
|
|
130
|
+
.run(id, normalized.project, canonical(normalized), this.now(), values.rendered ?? null, values.delivered ?? null, values.cited ?? null);
|
|
131
|
+
this.prune(); return id;
|
|
132
|
+
}
|
|
133
|
+
counters(project: string, entry?: string): Counter[] {
|
|
134
|
+
if (!text(project, 400) || entry !== undefined && !entryId(entry)) throw new Error("invalid counter filter");
|
|
135
|
+
this.prune();
|
|
136
|
+
const rows = this.db.query("SELECT * FROM counters WHERE project = ? ORDER BY at DESC, rowid DESC").all(project) as { id: string; payload: string; at: number; rendered: number | null; delivered: number | null; cited: number | null }[];
|
|
137
|
+
return rows.map(row => ({ id: row.id, key: JSON.parse(row.payload) as CounterKey, createdAt: new Date(row.at).toISOString(), rendered: row.rendered, delivered: row.delivered, cited: row.cited })).filter(row => !entry || row.key.entryId === entry);
|
|
138
|
+
}
|
|
139
|
+
reject(id: string, context: { project: string; actor: "person" | "agent"; verdict: CandidateVerdict }): void {
|
|
140
|
+
personOnly(context.actor); publicOnly(context.verdict);
|
|
141
|
+
this.db.transaction(() => {
|
|
142
|
+
const candidate = this.get(id, context.project);
|
|
143
|
+
if (!candidate) throw new Error("candidate unavailable");
|
|
144
|
+
if (candidate.status === "accepted" || candidate.status === "accepting") throw new Error("candidate acceptance already started; inspect its ordinary note before any further action");
|
|
145
|
+
this.db.query("UPDATE candidates SET status = 'rejected' WHERE id = ? AND project = ? AND status = 'pending'").run(id, context.project);
|
|
146
|
+
}).immediate();
|
|
147
|
+
}
|
|
148
|
+
accept(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
149
|
+
personOnly(context.actor); publicOnly(context.verdict);
|
|
150
|
+
if (realPath(context.store.dir) !== realPath(this.dir) || typeof context.policy !== "function") throw new Error("candidate acceptance requires its memory store and a current host policy");
|
|
151
|
+
return context.store.withDeferredPromotions(() => this.acceptDeferred(id, context));
|
|
152
|
+
}
|
|
153
|
+
private acceptDeferred(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
154
|
+
const candidate = this.get(id, context.project);
|
|
155
|
+
if (!candidate) throw new Error("candidate unavailable");
|
|
156
|
+
if (candidate.status === "accepted") return { candidateId: id, noteId: candidate.acceptedNoteId!, alreadyAccepted: true };
|
|
157
|
+
if (candidate.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
|
|
158
|
+
if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; no automatic mutation is available");
|
|
159
|
+
const resolve = () => {
|
|
160
|
+
const target = candidate.input.target;
|
|
161
|
+
if (!target) return undefined;
|
|
162
|
+
const current = context.store.get(target.id, context.project);
|
|
163
|
+
if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale");
|
|
164
|
+
return current;
|
|
165
|
+
};
|
|
166
|
+
const target = resolve(), data = candidate.input;
|
|
167
|
+
const body = data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}`
|
|
168
|
+
: `Approach: ${data.approach}\nSymptom: ${data.symptom}\nCause: ${data.cause}\nEvidence: ${data.evidenceRefs.join(", ")}\nRetry when: ${data.retryWhen}`;
|
|
169
|
+
const note: NoteInput = { op: "note", project: data.kind === "correction" ? target!.project : context.project,
|
|
170
|
+
scope: data.kind === "correction" ? target!.scope : "project",
|
|
171
|
+
// A demoted synced target is logically short, but replacing it locally would narrow its reach (X04).
|
|
172
|
+
tier: data.kind === "correction" && target!.local ? "short" : "long",
|
|
173
|
+
kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: "person", source: "person", about: [...target?.about ?? []],
|
|
174
|
+
text: `Accepted candidate: ${id}\nTarget revision: ${data.target?.revision ?? "none"}\nTarget entry: ${data.target?.id ?? "none"}\n\n${body}` };
|
|
175
|
+
if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit");
|
|
176
|
+
if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy");
|
|
177
|
+
return context.store.withAcceptedNote(note, append => {
|
|
178
|
+
resolve(); // Recheck under the same mutation lock as the note and supersede appends.
|
|
179
|
+
const started = this.db.transaction(() => {
|
|
180
|
+
const current = this.get(id, context.project);
|
|
181
|
+
if (!current) throw new Error("candidate unavailable");
|
|
182
|
+
if (current.status === "accepted") return current.acceptedNoteId!;
|
|
183
|
+
if (current.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
|
|
184
|
+
this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project = ?").run(id, context.project);
|
|
185
|
+
return undefined;
|
|
186
|
+
}).immediate();
|
|
187
|
+
if (started) return { candidateId: id, noteId: started, alreadyAccepted: true };
|
|
188
|
+
// ponytail: the sidecar receipt and v1 append are not one transaction. An interrupted 'accepting' row refuses
|
|
189
|
+
// automatic replay; add explicit person-audited recovery before offering background acceptance retries.
|
|
190
|
+
const noteId = append();
|
|
191
|
+
if (data.kind === "correction" && !context.store.supersede(target!.id, noteId, "person", context.project)) throw new Error("candidate replacement refused; inspect the accepted note before recovery");
|
|
192
|
+
this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project = ?").run(noteId, id, context.project);
|
|
193
|
+
return { candidateId: id, noteId, alreadyAccepted: false };
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/memory/commands.ts
CHANGED
|
@@ -55,7 +55,9 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
55
55
|
// Screened first, then cut to the limit: withheld entries never crowd out readable ones.
|
|
56
56
|
const readable = opts.readable(all), rows = readable.slice(0, limit ? Number(limit) : 50);
|
|
57
57
|
if (json) return JSON.stringify(rows, null, 2);
|
|
58
|
-
|
|
58
|
+
// Preserve project-wide header semantics, but count only rows the host policy lets this reader see.
|
|
59
|
+
const counted = opts.readable(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }));
|
|
60
|
+
const c = { machine: store.machine, long: counted.filter(v => v.tier === "long").length, short: counted.filter(v => v.tier === "short").length };
|
|
59
61
|
const withheld = all.length - readable.length;
|
|
60
62
|
return [`hub memory for ${project} on machine ${c.machine.label}: ${c.long} long-term, ${c.short} short-term`, ...(rows.length ? rows.map(one) : ["(nothing)"]),
|
|
61
63
|
...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { chmodSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const MUTATION_BUSY_TIMEOUT_MS = 3000;
|
|
6
|
+
// ponytail: older hubs (before consuming this agent-mem), raw/legacy log writers and any segment editor
|
|
7
|
+
// that bypasses this lock are not serialized. Require every writer to take it, or make segment appends API-only.
|
|
8
|
+
/** An empty, machine-local SQLite file used only for crash-safe writer exclusion, never for memory data. */
|
|
9
|
+
export function mutationDatabase(dir: string): Database {
|
|
10
|
+
const file = join(dir, "mutation-lock.db"), db = new Database(file, { create: true });
|
|
11
|
+
chmodSync(file, 0o600);
|
|
12
|
+
db.run(`PRAGMA busy_timeout = ${MUTATION_BUSY_TIMEOUT_MS}`);
|
|
13
|
+
return db;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Sync holds a separate connection across awaits; an unrelated call on the same MemoryStore cannot bypass it. */
|
|
17
|
+
export function acquireMutation(dir: string): (() => void) | undefined {
|
|
18
|
+
const db = mutationDatabase(dir);
|
|
19
|
+
try { db.run("BEGIN IMMEDIATE"); }
|
|
20
|
+
catch { db.close(); return undefined; }
|
|
21
|
+
return () => { try { db.run("ROLLBACK"); } finally { db.close(); } };
|
|
22
|
+
}
|
package/src/memory/store.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, join, normalize } from "node:path";
|
|
5
5
|
import { isDenied } from "./platform.ts";
|
|
6
|
+
import { mutationDatabase } from "./mutation.ts";
|
|
6
7
|
import { memoryHome } from "./home.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -173,15 +174,17 @@ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
|
|
|
173
174
|
* `screen`: applied to every promotion. Without one, automatic promotions (a settlement, a held note whose replacement
|
|
174
175
|
* went) wait for a process that has one; a person's explicit promote goes through.
|
|
175
176
|
*/
|
|
176
|
-
export interface StoreOptions { home?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen }
|
|
177
|
+
export interface StoreOptions { home?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen; deferPromotions?: boolean }
|
|
177
178
|
|
|
178
179
|
export class MemoryStore {
|
|
179
180
|
readonly dir: string;
|
|
180
181
|
readonly machine: Machine;
|
|
181
182
|
private readonly db: Database;
|
|
183
|
+
private readonly mutationDb: Database;
|
|
182
184
|
private readonly ttl: number;
|
|
183
185
|
private readonly now: () => number;
|
|
184
186
|
private readonly log: (line: string) => void;
|
|
187
|
+
private readonly deferPromotions: boolean;
|
|
185
188
|
|
|
186
189
|
constructor(opts: StoreOptions = {}) {
|
|
187
190
|
this.dir = opts.home === undefined ? memoryHome() : join(opts.home, "memory");
|
|
@@ -193,6 +196,7 @@ export class MemoryStore {
|
|
|
193
196
|
this.now = opts.now ?? Date.now;
|
|
194
197
|
this.log = opts.log ?? (() => {});
|
|
195
198
|
this.screen = opts.screen;
|
|
199
|
+
this.deferPromotions = opts.deferPromotions ?? false;
|
|
196
200
|
const db = new Database(join(this.dir, "index.db"), { create: true });
|
|
197
201
|
db.run(`PRAGMA busy_timeout = ${MEMORY_DB_BUSY_TIMEOUT_MS}`);
|
|
198
202
|
db.run("PRAGMA journal_mode = WAL");
|
|
@@ -210,11 +214,51 @@ export class MemoryStore {
|
|
|
210
214
|
db.run("CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst))");
|
|
211
215
|
db.run("CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst)");
|
|
212
216
|
this.db = db;
|
|
217
|
+
this.mutationDb = mutationDatabase(this.dir);
|
|
213
218
|
try { chmodSync(join(this.dir, "index.db"), 0o600); } catch { /* best effort */ }
|
|
214
219
|
this.refresh();
|
|
215
220
|
}
|
|
216
221
|
|
|
217
|
-
close(): void { this.db.close(); }
|
|
222
|
+
close(): void { this.db.close(); this.mutationDb.close(); }
|
|
223
|
+
|
|
224
|
+
/** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */
|
|
225
|
+
withDeferredPromotions<T>(work: () => T): T {
|
|
226
|
+
const previous = this.promoting;
|
|
227
|
+
this.promoting = true;
|
|
228
|
+
try { return work(); } finally { this.promoting = previous; }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private withMutation<T>(work: () => T): T {
|
|
232
|
+
return this.mutationDb.transaction(work).immediate();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Serialize current-store writers, screen one normalized body, and expose a one-shot append of that body. */
|
|
236
|
+
withAcceptedNote<T>(note: NoteInput, work: (append: () => string) => T): T {
|
|
237
|
+
try { return this.withMutation(() => {
|
|
238
|
+
// Ordinary writes may defer indexing, but accepting an exact target revision must have a current index.
|
|
239
|
+
this.db.transaction(() => this.refresh()).immediate();
|
|
240
|
+
const at = new Date(this.now()).toISOString();
|
|
241
|
+
const normalized = structuredClone({ ...note, text: note.text.slice(0, MAX_TEXT),
|
|
242
|
+
...(note.title ? { title: note.title.slice(0, MAX_TITLE) } : {}),
|
|
243
|
+
...(note.tier === "short" && !note.expires ? { expires: new Date(Date.parse(at) + this.ttl).toISOString() } : {}) });
|
|
244
|
+
const body = { schema: MEMORY_SCHEMA, at, machine: this.machine.id, ...normalized } as const;
|
|
245
|
+
const view: View = { ...normalized, id: entryId(body), at, machine: body.machine, title: normalized.title ?? null,
|
|
246
|
+
task: normalized.task ?? null, about: [...normalized.about], supersededBy: null,
|
|
247
|
+
supersedes: normalized.supersedes ? [normalized.supersedes] : [], promotedFrom: normalized.promotedFrom ?? null,
|
|
248
|
+
expiresAt: normalized.expires ?? null, local: normalized.tier === "short" };
|
|
249
|
+
if (this.screen?.(view) !== "promote") throw new Error("candidate refused by current promotion screen");
|
|
250
|
+
let appended = false;
|
|
251
|
+
return work(() => {
|
|
252
|
+
if (appended) throw new Error("accepted note already appended");
|
|
253
|
+
appended = true;
|
|
254
|
+
return this.appendBody(body, normalized.tier === "short");
|
|
255
|
+
});
|
|
256
|
+
}); } catch (error) {
|
|
257
|
+
// A callback can fail after a durable JSONL append; ensure the derived index catches up.
|
|
258
|
+
this.stale = true;
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
218
262
|
|
|
219
263
|
/** When a short-term entry written now leaves the index. */
|
|
220
264
|
private until(): string { return new Date(this.now() + this.ttl).toISOString(); }
|
|
@@ -223,12 +267,18 @@ export class MemoryStore {
|
|
|
223
267
|
private append(entry: NoteInput | OpInput | GoalInput, local: boolean, at = new Date(this.now()).toISOString(), machine = this.machine.id): string {
|
|
224
268
|
const capped = entry.op === "note" ? { ...entry, text: entry.text.slice(0, MAX_TEXT), ...(entry.title ? { title: entry.title.slice(0, MAX_TITLE) } : {}) } : entry;
|
|
225
269
|
const body = { schema: MEMORY_SCHEMA, at, machine, ...capped } as Omit<Entry, "id">;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return
|
|
270
|
+
return this.appendBody(body, local);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private appendBody(body: Omit<Entry, "id">, local: boolean): string {
|
|
274
|
+
// Lock the authoritative log independently: a busy derived index must never prevent an ordinary append.
|
|
275
|
+
return this.withMutation(() => {
|
|
276
|
+
const id = entryId(body);
|
|
277
|
+
appendFileSync(join(this.dir, local ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
|
|
278
|
+
// The line is durable even if indexing fails; the next refresh catches up from the authoritative log.
|
|
279
|
+
try { this.refresh(); } catch (e) { this.stale = true; this.log(`hub memory index not updated yet: ${(e as Error).message}`); }
|
|
280
|
+
return id;
|
|
281
|
+
});
|
|
232
282
|
}
|
|
233
283
|
|
|
234
284
|
/** A note: short-term ones stay on this machine, long-term ones go to the synced log. The caller has screened it. */
|
|
@@ -244,11 +294,13 @@ export class MemoryStore {
|
|
|
244
294
|
// A fixed machine: the same observation imported on two machines is one entry once they sync.
|
|
245
295
|
const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: n.text.slice(0, MAX_TEXT), ...(n.title ? { title: n.title.slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
|
|
246
296
|
const id = entryId(body);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
297
|
+
return this.withMutation(() => {
|
|
298
|
+
// The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
|
|
299
|
+
const known = this.db.query("SELECT id FROM entries WHERE id = ? OR (source = 'claude-mem' AND project = ? AND at = ? AND text = ?)").get(id, n.project, at, n.text.slice(0, MAX_TEXT)) as { id: string } | null;
|
|
300
|
+
if (known) return { id: known.id, added: false };
|
|
301
|
+
appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
|
|
302
|
+
return { id, added: true };
|
|
303
|
+
});
|
|
252
304
|
}
|
|
253
305
|
|
|
254
306
|
/** A person's or the hub's op on an entry; it goes where the entry lives, so forgetting a long-term entry syncs. */
|
|
@@ -368,7 +420,7 @@ export class MemoryStore {
|
|
|
368
420
|
* settlement a crash cut short, or a replacement that was forgotten or expired).
|
|
369
421
|
*/
|
|
370
422
|
private promoteHeld(): void {
|
|
371
|
-
if (this.promoting) return;
|
|
423
|
+
if (this.promoting || this.deferPromotions) return;
|
|
372
424
|
this.promoting = true;
|
|
373
425
|
try {
|
|
374
426
|
const settled = new Set((this.db.query("SELECT target FROM ops WHERE op = 'settle'").all() as { target: string }[]).map((r) => r.target));
|
|
@@ -487,11 +539,20 @@ export class MemoryStore {
|
|
|
487
539
|
|
|
488
540
|
/**
|
|
489
541
|
* Every live note with its derived state; an expired one is left out as soon as its deadline passes, before any
|
|
490
|
-
* prune.
|
|
542
|
+
* prune. Lifecycle relations still fold globally; SQL selects full rows before View/JSON materialization.
|
|
543
|
+
* ponytail: lifecycle metadata is still scanned on every read; measure before adding a revision cache.
|
|
491
544
|
*/
|
|
492
|
-
private views(expired = false): View[] {
|
|
493
|
-
|
|
494
|
-
|
|
545
|
+
private views(expired = false, selection?: Filter, expiredOnly = false): View[] {
|
|
546
|
+
// Metadata, ops and candidate bodies must describe one WAL snapshot, even if another process indexes a copy.
|
|
547
|
+
return this.db.transaction(() => this.viewRows(expired, selection, expiredOnly)).deferred();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private viewRows(expired: boolean, selection: Filter | undefined, expiredOnly: boolean): View[] {
|
|
551
|
+
const now = this.now(), selecting = selection !== undefined || expiredOnly;
|
|
552
|
+
// Relations may cross projects and promoted copies: fold their small metadata globally, never filtered bodies.
|
|
553
|
+
const rows = this.db.query(selecting
|
|
554
|
+
? "SELECT id, at, tier, kind, task, expires, supersedes, promoted_from FROM entries"
|
|
555
|
+
: "SELECT * FROM entries").all() as Record<string, string | null>[];
|
|
495
556
|
const ops = this.db.query("SELECT op, at, target, by FROM ops ORDER BY at, id").all() as { op: string; at: string; target: string; by: string | null }[];
|
|
496
557
|
const forgotten = new Set(ops.filter((o) => o.op === "forget").map((o) => o.target));
|
|
497
558
|
// Deadlines were fixed when the entries were written, so every process (whatever its own TTL) prunes the same rows.
|
|
@@ -550,8 +611,37 @@ export class MemoryStore {
|
|
|
550
611
|
}
|
|
551
612
|
const supersedes = new Map<string, string[]>(); // the reverse: what each newer entry replaced
|
|
552
613
|
for (const [old, newer] of superseded) supersedes.set(newer, [...(supersedes.get(newer) ?? []), old]);
|
|
614
|
+
let candidates = rows;
|
|
615
|
+
if (selecting) {
|
|
616
|
+
const hidden: string[] = [], expiredIds: string[] = [];
|
|
617
|
+
for (const r of rows) {
|
|
618
|
+
const id = r.id!;
|
|
619
|
+
if (forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) { hidden.push(id); continue; }
|
|
620
|
+
const at = expiry(r);
|
|
621
|
+
if (at !== null && Date.parse(at) <= now) expiredIds.push(id);
|
|
622
|
+
}
|
|
623
|
+
const where: string[] = [], bind: string[] = [];
|
|
624
|
+
const ids = (values: Iterable<string>, exclude: boolean) => {
|
|
625
|
+
const entries = [...values];
|
|
626
|
+
if (entries.length || !exclude) { where.push(`id ${exclude ? "NOT IN" : "IN"} (SELECT value FROM json_each(?))`); bind.push(JSON.stringify(entries)); }
|
|
627
|
+
};
|
|
628
|
+
ids(hidden, true);
|
|
629
|
+
if (expiredOnly) ids(expiredIds, false); else if (!expired) ids(expiredIds, true);
|
|
630
|
+
if (selection) {
|
|
631
|
+
if (selection.project) { where.push("(project = ? OR scope = 'shared')"); bind.push(selection.project); }
|
|
632
|
+
if (selection.tier) {
|
|
633
|
+
if (demoted.size) { where.push("CASE WHEN id IN (SELECT value FROM json_each(?)) THEN 'short' ELSE tier END = ?"); bind.push(JSON.stringify([...demoted.keys()]), selection.tier); }
|
|
634
|
+
else { where.push("tier = ?"); bind.push(selection.tier); }
|
|
635
|
+
}
|
|
636
|
+
for (const key of ["kind", "peer", "machine"] as const) if (selection[key]) { where.push(`${key} = ?`); bind.push(selection[key]!); }
|
|
637
|
+
if (!selection.includeSuperseded) ids(superseded.keys(), true);
|
|
638
|
+
}
|
|
639
|
+
// No SQL LIMIT: a host may screen the complete matching set before its own caps or visible counts.
|
|
640
|
+
// Date parsing, Unicode query matching, locale order and slice semantics stay in JavaScript.
|
|
641
|
+
candidates = this.db.query(`SELECT * FROM entries${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`).all(...bind) as Record<string, string | null>[];
|
|
642
|
+
}
|
|
553
643
|
const out: View[] = [];
|
|
554
|
-
for (const r of
|
|
644
|
+
for (const r of candidates) {
|
|
555
645
|
const id = r.id!;
|
|
556
646
|
// A forgotten entry is gone, and so is its promoted copy's source or copy: forgetting either forgets both.
|
|
557
647
|
if (forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) continue;
|
|
@@ -571,7 +661,7 @@ export class MemoryStore {
|
|
|
571
661
|
*/
|
|
572
662
|
prune(): number {
|
|
573
663
|
const now = this.now();
|
|
574
|
-
const expired = this.views(true).filter((v) => v.expiresAt !== null && Date.parse(v.expiresAt) <= now);
|
|
664
|
+
const expired = this.views(true, undefined, true).filter((v) => v.expiresAt !== null && Date.parse(v.expiresAt) <= now);
|
|
575
665
|
// A copy that expires (it was demoted) takes its whole hidden promotion ancestry with it, or an ancestor would reappear.
|
|
576
666
|
const parent = new Map((this.db.query("SELECT id, promoted_from FROM entries WHERE promoted_from IS NOT NULL").all() as { id: string; promoted_from: string }[]).map((r) => [r.id, r.promoted_from]));
|
|
577
667
|
const gone = new Set<string>();
|
|
@@ -591,9 +681,9 @@ export class MemoryStore {
|
|
|
591
681
|
* What a read sees: rows that expired and promotions that became due while the store sat idle are settled first, so a
|
|
592
682
|
* read, and the index after it, never differ from a rebuild.
|
|
593
683
|
*/
|
|
594
|
-
private live(): View[] {
|
|
684
|
+
private live(selection?: Filter): View[] {
|
|
595
685
|
this.ensureFresh();
|
|
596
|
-
this.prune(); this.promoteHeld(); return this.views();
|
|
686
|
+
this.prune(); this.promoteHeld(); return this.views(false, selection);
|
|
597
687
|
}
|
|
598
688
|
/** A write whose index update failed: the next read refreshes first. */
|
|
599
689
|
private stale = false;
|
|
@@ -601,7 +691,7 @@ export class MemoryStore {
|
|
|
601
691
|
/** Entries visible from `project` (its own, and `shared` ones of any project), newest first, filtered. */
|
|
602
692
|
list(f: Filter = {}): View[] {
|
|
603
693
|
const q = f.q?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
|
|
604
|
-
return this.live().filter((v) => (!f.project || v.project === f.project || v.scope === "shared") && (!f.tier || v.tier === f.tier) && (!f.kind || v.kind === f.kind)
|
|
694
|
+
return this.live(f).filter((v) => (!f.project || v.project === f.project || v.scope === "shared") && (!f.tier || v.tier === f.tier) && (!f.kind || v.kind === f.kind)
|
|
605
695
|
&& (!f.peer || v.peer === f.peer) && (!f.machine || v.machine === f.machine) && (!f.since || Date.parse(v.at) >= Date.parse(f.since)) && (f.includeSuperseded || !v.supersededBy)
|
|
606
696
|
&& q.every((w) => `${v.title ?? ""}\n${v.text}\n${v.about.join(" ")}`.toLowerCase().includes(w)))
|
|
607
697
|
.sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, f.limit ?? 200);
|
package/src/memory/sync.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "./platform.ts";
|
|
4
|
+
import { acquireMutation } from "./mutation.ts";
|
|
4
5
|
import type { MemoryStore } from "./store.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -78,7 +79,10 @@ export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Pr
|
|
|
78
79
|
if (!opts.remote && !existsSync(join(dir, ".git"))) return fail(noRemote); // never set up: nothing to do, nothing created
|
|
79
80
|
const release = lock(dir);
|
|
80
81
|
if (!release) return fail("another sync is running on this machine");
|
|
82
|
+
let releaseMutation: (() => void) | undefined;
|
|
81
83
|
try {
|
|
84
|
+
releaseMutation = acquireMutation(dir);
|
|
85
|
+
if (!releaseMutation) return fail("another memory mutation is running on this machine");
|
|
82
86
|
// Holding sync.lock, no other git runs in this repository: what a killed one left behind is stale.
|
|
83
87
|
rmSync(join(dir, ".git", "index.lock"), { force: true });
|
|
84
88
|
if (existsSync(join(dir, ".git", "MERGE_HEAD"))) await git(dir, ["merge", "--abort"]);
|
|
@@ -136,9 +140,11 @@ export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Pr
|
|
|
136
140
|
pushed = true;
|
|
137
141
|
}
|
|
138
142
|
if (opts.signal?.aborted) return fail("stopped");
|
|
139
|
-
store.
|
|
140
|
-
|
|
143
|
+
return store.withDeferredPromotions(() => {
|
|
144
|
+
store.refresh();
|
|
145
|
+
return { ok: true, pushed, entries: store.counts().long, ...(warning ? { warning } : {}) };
|
|
146
|
+
});
|
|
141
147
|
} catch (e) {
|
|
142
148
|
return fail((e as Error).message.slice(0, 200));
|
|
143
|
-
} finally { release(); }
|
|
149
|
+
} finally { try { releaseMutation?.(); } finally { release(); } }
|
|
144
150
|
}
|