@staix/agent-mem 0.15.0 → 0.17.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/src/setup.ts CHANGED
@@ -2,6 +2,8 @@ import { cliAttribution } from "./memory/cli-attribution.ts";
2
2
  import { configuredPolicy } from "./memory/config.ts";
3
3
  import { SETUP_CONFIG_BYTES, updateTomlArray } from "./setup-toml.ts";
4
4
  import { managedAfter, mcpRecord, putMcpRecord, restoreOwned, sameImage, type ConfigImage } from "./setup-owned.ts";
5
+ import { opencodeServer } from "./setup-opencode.ts";
6
+ import { assertGeminiHome, assertGeminiRuntime, GEMINI_REMOVE, geminiAddCommand, geminiSettingsPath } from "./setup-gemini.ts";
5
7
  import { spawnSync } from "node:child_process";
6
8
  import { createHash, randomUUID } from "node:crypto";
7
9
  import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
@@ -14,13 +16,13 @@ import { memoryHome } from "./memory/home.ts";
14
16
  import { label, PLAIN, singleLine, type Style } from "./memory/style.ts";
15
17
  import { canonicalPath, memoryGitEnv, memoryGitSync, processLiveness, processSignature, realPath } from "./memory/platform.ts";
16
18
 
17
- export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy"] as const;
19
+ export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "opencode", "gemini", "agy"] as const;
18
20
  /** Workspace-scoped Antigravity setup is always selected explicitly. */
19
21
  export const SETUP_ALL_TARGETS = SETUP_TARGETS.filter(target => target !== "agy");
20
22
  export type SetupTarget = typeof SETUP_TARGETS[number];
21
23
  // Only recorded layout verification versions belong here; null means no version was recorded.
22
24
  const SETUP_VERIFIED_VERSIONS: Readonly<Record<SetupTarget, string | null>> = {
23
- claude: null, codex: "0.165.0", kimi: null, pi: null, qwen: "0.26.1", jcode: "0.99.1", agy: null,
25
+ claude: null, codex: "0.165.0", kimi: null, pi: null, qwen: "0.26.1", jcode: "0.99.1", opencode: "1.18.35", gemini: "0.61.0", agy: null,
24
26
  };
25
27
  interface ClientVersion { output: string | null; verified: string | null; matches: boolean | null }
26
28
  type Env = Readonly<Record<string, string | undefined>>;
@@ -145,6 +147,10 @@ function context(options: SetupContext, workspace?: WorkspaceBinding) {
145
147
  qwen: [join(qwen, "settings.json")],
146
148
  // Absolute JCODE_HOME spellings stay literal; unset uses the platform-home default.
147
149
  jcode: [join(root("JCODE_HOME", ".jcode"), "mcp.json")],
150
+ // xdg-basedir 5.1.0: XDG_CONFIG_HOME verbatim when set and non-empty, else ~/.config (OpenCode v1.18.35 packages/core/src/global.ts).
151
+ opencode: [join(opencodeConfigRoot(env, home), "opencode", "opencode.json")],
152
+ // Gemini CLI's own user settings, never Antigravity's <home>/.gemini/config/mcp_config.json.
153
+ gemini: [geminiSettingsPath(env, home)],
148
154
  agy: workspace ? [join(workspace.root, ".agents", "mcp_config.json")] : [],
149
155
  };
150
156
  const requestedBun = options.bun ?? "bun", bun = requestedBun === "bun" ? Bun.which("bun", { PATH: env.PATH ?? process.env.PATH }) : requestedBun;
@@ -158,10 +164,21 @@ function context(options: SetupContext, workspace?: WorkspaceBinding) {
158
164
  return { env, home, paths, bun, entry, workspace, state: join(canonicalPath(memoryHome({ ...env, AGENTHUB_HOME: env.AGENTHUB_HOME?.trim() || join(home, ".agenthub") })), "setup") };
159
165
  }
160
166
  function command(target: SetupTarget, bun: string, entry: string): string[] | null {
161
- if (target === "kimi" || target === "jcode" || target === "agy") return null;
167
+ if (target === "kimi" || target === "jcode" || target === "opencode" || target === "agy") return null;
162
168
  if (target === "qwen") return [target, "mcp", "add", "--scope", "user", "--transport", "stdio", "amem", bun, "--", "--smol", entry, "mcp"];
169
+ if (target === "gemini") return geminiAddCommand(bun, entry);
163
170
  return [target, "mcp", "add", ...(target === "claude" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
164
171
  }
172
+ /**
173
+ * OpenCode 1.18.35 resolves its config root with xdg-basedir 5.1.0, which uses XDG_CONFIG_HOME verbatim when set
174
+ * (no trim) and falls back to ~/.config only when it is unset or empty. A relative value would resolve against
175
+ * OpenCode's own cwd, so setup refuses it rather than guess. An absolute value keeps its literal spelling.
176
+ */
177
+ function opencodeConfigRoot(env: Env, home: string): string {
178
+ const value = env.XDG_CONFIG_HOME;
179
+ // A relative value is refused in assertOpencodeLayout; the path here only has to be well-formed for inventory.
180
+ return value === undefined || value === "" || !isAbsolute(value) ? join(home, ".config") : value;
181
+ }
165
182
  function assertJcodeHome(env: Env): void {
166
183
  if (env.JCODE_HOME !== undefined && !isAbsolute(env.JCODE_HOME)) throw Error("setup requires an absolute JCODE_HOME; unset it to use the default");
167
184
  }
@@ -169,6 +186,15 @@ function assertJcodeRuntime(c: ReturnType<typeof context>): void {
169
186
  assertJcodeHome(c.env);
170
187
  if ([c.bun, c.entry].some(value => value.includes("${"))) throw Error("Jcode interpolates environment placeholders in MCP command paths; setup cannot register this runtime exactly");
171
188
  }
189
+ /** OpenCode merges config.json, opencode.json, then opencode.jsonc from its global directory, and env routes can replace it. */
190
+ function assertOpencodeLayout(c: ReturnType<typeof context>, mode: SetupPlan["mode"]): void {
191
+ const xdg = c.env.XDG_CONFIG_HOME;
192
+ if (xdg !== undefined && xdg !== "" && !isAbsolute(xdg)) throw Error("OpenCode setup requires an absolute XDG_CONFIG_HOME; unset it to use ~/.config");
193
+ if (mode !== "undo") for (const key of ["OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR", "OPENCODE_CONFIG_CONTENT"]) if (c.env[key]?.trim()) throw Error(`OpenCode setup refuses ${key}: it routes configuration away from the global file this setup owns`);
194
+ const dir = dirname(c.paths.opencode[0]!);
195
+ for (const name of ["opencode.jsonc", "config.json"]) if (read(join(dir, name)).bytes !== null) throw Error(`OpenCode setup supports one strict-JSON global opencode.json; ${name} beside it is merged too and is not supported`);
196
+ if ([c.bun, c.entry].some(value => /\{(?:env|file):/.test(value))) throw Error("OpenCode substitutes {env:} and {file:} in configuration; setup cannot register this runtime exactly");
197
+ }
172
198
  function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry, ...(["codex", "kimi", "qwen"].includes(target) ? ["capture-json-v1"] : []), ...(target === "agy" ? [c.workspace!.root] : [])]); }
173
199
  function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, target === "agy" ? `agy-${digest(c.workspace!.root)}.json` : `${target}.json`); }
174
200
  function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
@@ -189,18 +215,21 @@ function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRunt
189
215
  return value as unknown as Receipt;
190
216
  }
191
217
  function plannedHooks(c: ReturnType<typeof context>, target: SetupTarget): PlannedHook[] {
192
- if (target === "jcode" || target === "agy") return [];
218
+ if (target === "jcode" || target === "opencode" || target === "gemini" || target === "agy") return [];
193
219
  const launch = `${quote(c.bun)} --smol ${quote(c.entry)}`;
194
220
  return target === "claude" ? HOOK_EVENTS.map(event => ({ event, command: `${launch} hook ${event}`, timeout: 1 }))
195
221
  : [{ event: target === "pi" ? "session_shutdown" : "SessionEnd", command: `${launch} ingest ${target}${target === "pi" ? "" : " --json"}`, timeout: target === "pi" ? 2.5 : 3 }];
196
222
  }
197
- const lifecycle = (target: SetupTarget) => target === "jcode" || target === "agy" ? "MCP only; native capture unavailable" : target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
223
+ const lifecycle = (target: SetupTarget) => target === "jcode" || target === "opencode" || target === "gemini" || target === "agy" ? "MCP only; native capture unavailable" : target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
198
224
  export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", options: SetupContext = {}): SetupPlan {
199
- if (target !== "all" && !SETUP_TARGETS.includes(target as SetupTarget)) throw Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|agy|all [--apply|--undo]");
225
+ if (target !== "all" && !SETUP_TARGETS.includes(target as SetupTarget)) throw Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all [--apply|--undo]");
200
226
  const targets: SetupTarget[] = target === "all" ? [...SETUP_ALL_TARGETS] : [target as SetupTarget];
201
227
  if (targets.includes("jcode")) assertJcodeHome(options.env ?? process.env);
202
228
  const c = context(options, targets.includes("agy") ? workspaceBinding(options) : undefined);
203
229
  if (mode !== "undo" && targets.includes("jcode")) assertJcodeRuntime(c);
230
+ if (targets.includes("opencode") && (target === "opencode" || available(c, "opencode", options))) assertOpencodeLayout(c, mode);
231
+ if (targets.includes("gemini") && (target === "gemini" || available(c, "gemini", options))) assertGeminiHome(c.env);
232
+ if (mode !== "undo" && targets.includes("gemini")) assertGeminiRuntime(c.bun, c.entry);
204
233
  const pluginNotes: string[] = [];
205
234
  if (targets.includes("claude")) {
206
235
  const settings = json(read(c.paths.claude[1]!));
@@ -212,6 +241,8 @@ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", o
212
241
  }
213
242
  if (targets.includes("qwen")) pluginNotes.push("Qwen capture supports the verified 0.26.1 native format. For custom advanced.runtimeOutputDir, export the matching QWEN_RUNTIME_DIR for manual and SessionEnd ingest.");
214
243
  if (targets.includes("jcode")) pluginNotes.push("Jcode setup registers MCP only. Native saved sessions do not prove per-message project scope, so capture and ingest hooks are unavailable; no hook or config.toml is installed.");
244
+ if (targets.includes("gemini")) pluginNotes.push("Gemini CLI setup registers MCP only, at user scope in ~/.gemini/settings.json (GEMINI_CLI_HOME moves it), through the native gemini mcp add in a private staging HOME. Antigravity's ~/.gemini/config/mcp_config.json is never touched. A workspace .gemini/settings.json defining mcpServers.amem overrides this entry. No hook or ingest is installed: see docs/gemini-cli.md for why transcripts are not ingested.");
245
+ if (targets.includes("opencode")) pluginNotes.push("OpenCode setup registers MCP only in the global strict-JSON opencode.json (mcp.amem, a local server started in the session's project directory). No plugin or ingest hook is installed: see docs/opencode.md for why transcripts are not ingested. A project opencode.json defining mcp.amem overrides this entry.");
215
246
  if (c.workspace) {
216
247
  pluginNotes.push(`Antigravity MCP is pinned to this repository only: ${c.workspace.root}. Restart the CLI/IDE and use one workspace; no global or plugin definitions are replaced, and same-name definitions elsewhere are unsupported. Native capture is unavailable; no hooks are installed. Only strict JSON is supported; JSONC is refused. Native TUI/IDE presence and live behavior are unverified; availability means this direct configuration operation is supported.`);
217
248
  if (!c.workspace.ignored) pluginNotes.push("WARNING: .agents/mcp_config.json is not Git-ignored; it contains absolute local paths. Setup will refuse it if it becomes tracked.");
@@ -222,9 +253,9 @@ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", o
222
253
  const prior = receipt(c, target, false); for (const file of c.paths[target]) read(file);
223
254
  const original = read(c.paths[target][0]!);
224
255
  const document: Record<string, unknown> = target === "agy" ? {} : target === "codex" ? toml(original.bytes === null ? "" : Buffer.from(original.bytes, "base64").toString("utf8")) : json(original);
225
- const hasAmem = target === "jcode" || target === "agy" ? mcpRecord(target, original) !== undefined : Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
256
+ const hasAmem = target === "jcode" || target === "opencode" || target === "agy" ? mcpRecord(target, original) !== undefined : Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
226
257
  const unchanged = !!prior && prior.phase === "applied" && prior.intent === intent(c, target) && prior.changes.every((change, index) => { try { const live = read(change.path); restoreChange(prior, change, index, live); return managedAfter(target, index, change.before, change.after, live); } catch { return false; } });
227
- const add = command(target, c.bun, c.entry), remove = [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"];
258
+ const add = command(target, c.bun, c.entry), remove = target === "gemini" ? [...GEMINI_REMOVE] : [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"];
228
259
  const installed = available(c, target, options), version = installed && target !== "agy" && mode !== "undo" ? probeClientVersion(c, target, options) : undefined;
229
260
  if (version?.matches === false) pluginNotes.push(`WARNING: ${target} version ${version.output}; layout verified on ${version.verified}. Apply remains guarded by native configuration validation.`);
230
261
  return { target, available: installed, ...(version ? { version } : {}), files: c.paths[target], receipt: journal(c, target), ...(target === "agy" ? { cwd: c.workspace!.root } : {}), registration: mode === "undo" ? null : add, lifecycle: mode === "undo" ? "restore original MCP/hook files" : lifecycle(target), hooks: mode === "undo" || unchanged ? [] : plannedHooks(c, target), mcpChange: mode === "undo" ? prior ? "restore" : "unchanged" : unchanged ? "unchanged" : hasAmem ? "replace" : "add", commands: mode === "undo" || prior?.intent === intent(c, target) || !add || !installed ? [] : [...(hasAmem ? [remove] : []), add], state: prior && prior.intent !== intent(c, target) ? "runtime changed; managed entries will update" : prior?.phase ?? "not installed" };
@@ -237,6 +268,10 @@ function stagingEnvironment(c: ReturnType<typeof context>, target: SetupTarget,
237
268
  env.QWEN_RUNTIME_DIR = join(staging, "qwen-runtime");
238
269
  env.QWEN_CODE_SYSTEM_SETTINGS_PATH = join(staging, "qwen-system-settings.json");
239
270
  env.QWEN_CODE_SYSTEM_DEFAULTS_PATH = join(staging, "qwen-system-defaults.json");
271
+ // Gemini CLI: GEMINI_CLI_HOME overrides HOME for all its state; system settings paths are isolated too.
272
+ env.GEMINI_CLI_HOME = staging;
273
+ env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = join(staging, "gemini-system-settings.json");
274
+ env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH = join(staging, "gemini-system-defaults.json");
240
275
  mkdirSync(env.TMPDIR!, { mode: 0o700 });
241
276
  if (c.env.CLAUDE_CONFIG_DIR?.trim()) env.CLAUDE_CONFIG_DIR = join(staging, ".claude"); else delete env.CLAUDE_CONFIG_DIR;
242
277
  // The staging workspace is a trusted private binding, not a Git repository or the real caller cwd.
@@ -266,7 +301,7 @@ function probeClientVersion(c: ReturnType<typeof context>, target: SetupTarget,
266
301
  function verifyNativeMcp(target: SetupTarget, env: Env, cwd: string): void {
267
302
  // In older free-form Pi CLIs, an unknown subcommand can be treated as a model prompt.
268
303
  const help = spawnSync(target, ["--help"], { cwd, env: { ...env }, encoding: "utf8", timeout: 5000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
269
- if (help.status !== 0 || !/^\s+(?:(?:pi|qwen)\s+)?mcp(?:\s|$)/m.test(help.stdout)) throw Error(`installed ${target} CLI does not expose MCP registration; no registration command was run`);
304
+ if (help.status !== 0 || !/^\s+(?:(?:pi|qwen|gemini)\s+)?mcp(?:\s|$)/m.test(help.stdout)) throw Error(`installed ${target} CLI does not expose MCP registration; no registration command was run`);
270
305
  }
271
306
  function native(argv: readonly string[], env: Env, cwd: string): void {
272
307
  const result = spawnSync(argv[0]!, argv.slice(1), { cwd, env: { ...env }, encoding: "utf8", timeout: 10_000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
@@ -303,6 +338,8 @@ function restoreChange(value: Receipt, change: Change, index: number, live: Imag
303
338
  }
304
339
  function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext): Prepared {
305
340
  if (target === "jcode") assertJcodeRuntime(c);
341
+ if (target === "opencode") assertOpencodeLayout(c, "apply");
342
+ if (target === "gemini") assertGeminiHome(c.env);
306
343
  const prior = receipt(c, target, false), expected = c.paths[target].map(path => read(path));
307
344
  const restored = prior ? prior.changes.map((change, index) => restoreChange(prior, change, index, expected[index]!)) : expected;
308
345
  if (prior?.phase === "applied" && prior.intent === intent(c, target) && prior.changes.every((change, index) => managedAfter(target, index, change.before, change.after, expected[index]!))) return { value: prior, expected, unchanged: true };
@@ -319,7 +356,7 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
319
356
  const text = expected[0]!.bytes === null ? "" : Buffer.from(expected[0]!.bytes!, "base64").toString("utf8");
320
357
  const document = target === "codex" ? toml(text) : json(expected[0]!);
321
358
  const servers = document[target === "codex" ? "mcp_servers" : "mcpServers"];
322
- if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)([target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"], env, staging);
359
+ if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)(target === "gemini" ? [...GEMINI_REMOVE] : [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"], env, staging);
323
360
  (options.native ?? native)(argv, env, staging);
324
361
  changes[0]!.after = { ...read(staged.paths[target][0]!), mode: changes[0]!.before.mode };
325
362
  if (changes[0]!.after.bytes === null) throw Error(`${target} native registration produced no configuration`);
@@ -361,6 +398,9 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
361
398
  } else if (target === "jcode") {
362
399
  // Shared native pools use the daemon cwd; amem must bind the calling session's project.
363
400
  changes[0]!.after = putMcpRecord(target, changes[0]!.before, { command: c.bun, args: ["--smol", c.entry, "mcp"], shared: false });
401
+ } else if (target === "opencode") {
402
+ // `opencode mcp add` is interactive for local servers and has no remove; edit the owned entry directly.
403
+ changes[0]!.after = putMcpRecord(target, changes[0]!.before, opencodeServer(c.bun, c.entry));
364
404
  } else {
365
405
  const document = json(changes[0]!.before), servers = document.mcpServers === undefined ? {} : record(document.mcpServers);
366
406
  servers.amem = { command: c.bun, args: ["--smol", c.entry, "mcp"] }; document.mcpServers = servers;
@@ -0,0 +1,15 @@
1
+ .amem { display: flex; flex-direction: column; gap: var(--acon-space-3); min-width: 0; }
2
+ .amem > .btn, .amem-card > .btn { align-self: flex-start; }
3
+ .amem-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--acon-space-2); }
4
+ .amem-bar input[type="search"] { flex: 1; min-width: 0; max-width: 480px; }
5
+ .amem-inline { display: inline-flex; flex-wrap: wrap; gap: var(--acon-space-2); }
6
+ .amem-input { min-height: 32px; min-width: 0; max-width: 100%; padding: 0 10px; border: 1px solid var(--acon-line-2); border-radius: var(--acon-radius-2); background: var(--acon-surface); color: var(--acon-text); font: inherit; }
7
+ .amem-text { width: 100%; min-height: 10em; padding: var(--acon-space-2) 10px; font: var(--acon-text-sm)/1.5 var(--acon-font-mono); resize: vertical; }
8
+ .amem-form { display: flex; flex-direction: column; gap: var(--acon-space-2); }
9
+ .amem-form .btn { align-self: flex-start; }
10
+ .amem-sub { margin: 0; color: var(--acon-text-2); overflow-wrap: anywhere; }
11
+ .amem-card { display: flex; flex-direction: column; gap: var(--acon-space-2); }
12
+ .amem-card h3 { margin: 0; font-size: var(--acon-text-lg); }
13
+ .amem-diff { display: flex; flex-direction: column; gap: var(--acon-space-1); }
14
+ .amem-pre { margin: 0 0 var(--acon-space-2); padding: var(--acon-space-3); max-height: 40vh; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--acon-surface-2); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-2); font: var(--acon-text-sm)/1.5 var(--acon-font-mono); }
15
+ @media (max-width: 600px) { .amem-inline { width: 100%; } .amem-inline .amem-input { flex: 1; } }
@@ -0,0 +1,129 @@
1
+ // amem console panels (acon api 1). Runs under the shell's strict CSP and renders data as text through host.ui only.
2
+ export default (host) => {
3
+ const { ui } = host;
4
+ const { h } = ui;
5
+ const root = h("div", { class: "amem" });
6
+ host.mount.append(root);
7
+ let panel = "review", params = {}, project = null;
8
+ const go = (p, extra = {}) => host.navigate(host.tool, p, { ...(project ? { project } : {}), ...extra });
9
+ const btn = (text, onclick, cls = "btn") => h("button", { type: "button", class: cls, text, onclick });
10
+ const day = (iso) => (typeof iso === "string" ? iso.slice(0, 16).replace("T", " ") : "");
11
+ const line = (text, max = 120) => { const flat = String(text ?? "").replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat; };
12
+ const act = (input) => host.action({ ...input, ...(project ? { project } : {}) });
13
+
14
+ const picker = (s) => {
15
+ const list = s.projects ?? [];
16
+ if (list.length < 2) return list.length ? h("p", { class: "amem-sub", text: `Project ${list[0]}` }) : null;
17
+ const select = h("select", { name: "project", class: "amem-input", "aria-label": "Project", onchange: () => { project = select.value; go(panel); } },
18
+ list.map((key) => { const o = h("option", { value: key, text: key }); if (key === s.project) o.selected = true; return o; }));
19
+ return h("label", { class: "amem-bar" }, h("span", { text: "Project" }), select);
20
+ };
21
+
22
+ const reviewDetail = (r) => {
23
+ const reason = h("input", { type: "text", name: "reason", class: "amem-input", placeholder: "Reason (optional)", "aria-label": "Reject reason (optional)" });
24
+ const actions = [
25
+ r.acceptable ? btn("Accept", () => act({ action: "accept", id: r.id }), "btn primary") : null,
26
+ h("form", { class: "amem-inline", onsubmit: (e) => { e.preventDefault(); act({ action: "reject", id: r.id, ...(reason.value.trim() ? { reason: reason.value.trim() } : {}) }); } },
27
+ reason, h("button", { type: "submit", class: "btn", text: "Reject" })),
28
+ ].filter(Boolean);
29
+ const sections = [];
30
+ if (r.diff) sections.push({ title: "Now and proposed", open: true, body: h("div", { class: "amem-diff" },
31
+ h("strong", { text: "Now" }), h("pre", { class: "amem-pre", text: r.diff.now ?? "(the current note is not visible)" }),
32
+ h("strong", { text: "Proposed" }), h("pre", { class: "amem-pre", text: r.diff.proposed }),
33
+ h("p", { class: "amem-sub", text: `Reason: ${r.diff.reason}` })) });
34
+ const evidence = [...(r.evidence ?? []).map((ref) => ({ title: ref, sub: "evidence reference" })),
35
+ ...(r.targets ?? []).map((t) => ({ title: t.text ? line(t.title ?? t.text) : `mem:${t.id} (not visible)`, sub: t.at ? `mem:${t.id.slice(0, 8)} · ${t.kind} · ${day(t.at)}` : "" }))];
36
+ sections.push({ title: `Evidence (${evidence.length})`, open: evidence.length > 0, body: ui.rows(evidence, (e) => ui.row(e)) ?? ui.empty("No evidence attached", "This kind of candidate carries no references.") });
37
+ sections.push({ title: `Similar memories (${r.overlaps.length})`, open: r.overlaps.length > 0,
38
+ body: ui.rows(r.overlaps, (o) => ui.row({ title: o.title ?? "Untitled note", sub: `mem:${o.id.slice(0, 8)} · overlap ${o.score.toFixed(2)}` })) ?? ui.empty("No similar memories") });
39
+ if (r.edit !== null) {
40
+ const text = h("textarea", { name: "edit", class: "amem-input amem-text", "aria-label": "Edited text" });
41
+ text.value = r.edit;
42
+ sections.push({ title: "Edit, then accept", body: h("form", { class: "amem-form", onsubmit: (e) => { e.preventDefault(); act({ action: "edit_accept", id: r.id, text: text.value }); } },
43
+ text, h("button", { type: "submit", class: "btn", text: "Save edit and accept" })) });
44
+ }
45
+ return ui.detail({ kind: `${r.kind} candidate`, title: line(r.text, 90), state: { text: "pending", tone: "warn" }, whoseMove: "you", actions, summary: r.text,
46
+ provenance: [{ label: "Proposed", value: day(r.createdAt), at: r.createdAt }, { label: "Project", value: project ?? "" }, ...(r.noteKind ? [{ label: "Note kind", value: r.noteKind }] : [])],
47
+ sections, footer: [r.id] });
48
+ };
49
+
50
+ const views = {
51
+ review(s) {
52
+ const rows = s.rows ?? [], picked = rows.find((r) => r.id === s.selected);
53
+ const hidden = s.hidden ? ui.notice(`${s.hidden} candidate${s.hidden === 1 ? " is" : "s are"} withheld by the privacy screen; review ${s.hidden === 1 ? "it" : "them"} with amem review walk.`, "warn") : null;
54
+ if (picked) return [btn("Back to the queue", () => go("review")), reviewDetail(picked)];
55
+ return [hidden, ui.rows(rows, (r) => ui.row({ title: line(r.text), sub: `${r.kind} · ${day(r.createdAt)}${r.overlaps.length ? ` · ${r.overlaps.length} similar` : ""}`,
56
+ onOpen: () => go("review", { id: r.id }),
57
+ verbs: [r.acceptable ? btn("Accept", () => act({ action: "accept", id: r.id }), "btn primary") : null, btn("Reject", () => act({ action: "reject", id: r.id }))].filter(Boolean) }))
58
+ ?? ui.empty("No candidates waiting", "Agents propose memories with amem_remember; they wait here for your decision.")];
59
+ },
60
+ find(s) {
61
+ const q = h("input", { type: "search", name: "q", class: "amem-input", value: s.q ?? "", placeholder: "Search memory", "aria-label": "Search memory" });
62
+ q.value = s.q ?? "";
63
+ const form = h("form", { class: "amem-bar", role: "search", onsubmit: (e) => { e.preventDefault(); go("find", q.value.trim() ? { q: q.value.trim() } : {}); } }, q, h("button", { type: "submit", class: "btn", text: "Search" }));
64
+ const hits = s.hits ?? [];
65
+ return [form, s.q ? ui.rows(hits, (x) => {
66
+ const v = x.view, c = x.capture;
67
+ return ui.row({ title: line(v ? v.title ?? v.text : c?.excerpt), sub: [x.source, v?.kind ?? c?.event, day(v?.at ?? c?.at), v?.stale ? "stale" : ""].filter(Boolean).join(" · ") });
68
+ }) ?? ui.empty("Nothing found", "Search uses the same screened index as amem_search.") : ui.empty("Search this project's memory", "Results are screened exactly like amem_search.")];
69
+ },
70
+ timeline(s) {
71
+ return [ui.rows(s.entries ?? [], (x) => {
72
+ const c = x.capture;
73
+ return c ? ui.row({ title: line(c.excerpt), sub: `capture · ${c.event} · ${day(c.at)}` }) : ui.row({ title: line(x.title ?? x.text), sub: `${x.kind} · ${x.tier} · ${x.peer} · ${day(x.at)}` });
74
+ }) ?? ui.empty("Nothing on the timeline yet")];
75
+ },
76
+ work(s) {
77
+ const lists = s.lists ?? [];
78
+ return lists.length ? lists.map((l) => h("section", { class: "card amem-card" }, h("h3", { text: l.list }),
79
+ h("p", { class: "amem-sub", text: Object.entries(l.fields ?? {}).map(([k, v]) => `${k}: ${v}`).join(" · ") }),
80
+ ui.rows(l.items ?? [], (i) => ui.row({ title: i.task, sub: Object.entries(i.fields ?? {}).map(([k, v]) => `${k}: ${v}`).join(" · ") })) ?? ui.empty("No open items")))
81
+ : [ui.empty("No open work lists", "Agents write them with amem_work_state_write.")];
82
+ },
83
+ goals(s) {
84
+ return [ui.rows(s.goals ?? [], (g) => ui.row({ title: g.title, sub: [g.milestones.length ? `${g.milestones.length} milestones` : "", g.links.length ? `${g.links.length} linked tasks` : "", day(g.at)].filter(Boolean).join(" · "),
85
+ chip: { text: g.state, tone: g.state === "done" ? "ok" : g.state === "blocked" ? "err" : "neutral" } })) ?? ui.empty("No goals", "Create one with amem goal add.")];
86
+ },
87
+ capacity(s) {
88
+ if (s.withheld) return [ui.notice("The capacity report is withheld by the privacy screen.", "warn")];
89
+ const r = s.report;
90
+ const out = [];
91
+ if (r) {
92
+ out.push(h("section", { class: "card amem-card" }, h("h3", { text: "Capture" }),
93
+ h("dl", { class: "ui-provenance" },
94
+ h("dt", { text: "Rows" }), h("dd", { text: `${r.capture.rows} in ${r.capture.shards} shards, ${(r.capture.bytes / 1048576).toFixed(1)} MB` }),
95
+ h("dt", { text: "Growth" }), h("dd", { text: `${Math.round(r.capture.rowsPerDay)} rows/day` }),
96
+ h("dt", { text: "Disk free" }), h("dd", { text: `${(r.disk.freeBytes / 1073741824).toFixed(1)} GB` }),
97
+ h("dt", { text: "Current" }), h("dd", { text: `${r.current.capMb} MB, ${r.current.retentionDays} days` }),
98
+ h("dt", { text: "Proposed" }), h("dd", { text: `${r.proposal.capMb} MB, ${r.proposal.retentionDays} days` })),
99
+ h("p", { class: "amem-sub", text: r.proposal.reason }),
100
+ s.applyNeeded ? btn("Apply proposal...", () => host.action({ action: "capacity_apply" }), "btn primary") : ui.chip("current settings match the proposal", "ok")));
101
+ } else out.push(ui.empty("No memory home yet"));
102
+ const cutoff = () => new Date().toISOString().replace(/\.\d{3}Z$/, ".000Z");
103
+ out.push(h("section", { class: "card amem-card" }, h("h3", { text: "Maintenance" }),
104
+ h("p", { class: "amem-sub", text: "Each action shows what it will change and runs only after you confirm." }),
105
+ h("div", { class: "amem-bar" },
106
+ btn("Evict expired capture...", () => host.action({ action: "gc" })),
107
+ project ? btn("Demote old notes...", () => act({ action: "decay_apply" })) : null,
108
+ project ? btn("Prune expired notes...", () => act({ action: "prune", cutoff: cutoff() })) : null,
109
+ project ? btn("Forget this project...", () => act({ action: "project_forget" }), "btn danger") : null)));
110
+ return out;
111
+ },
112
+ };
113
+
114
+ host.onSnapshot((s) => {
115
+ project = s.project ?? null;
116
+ // A poll re-renders the view: carry typed values and focus over by field name.
117
+ const values = new Map([...root.querySelectorAll("input[name], textarea[name]")].map((el) => [el.name, el.value]));
118
+ const focused = root.contains(document.activeElement) ? document.activeElement.getAttribute("name") : null;
119
+ const body = s.error ? [ui.notice(s.error, "err")] : !s.projects?.length && panel !== "capacity" ? [ui.empty("No memory yet", "Projects appear once an agent captures or proposes memory.")] : views[panel]?.(s) ?? [ui.empty("Unknown panel")];
120
+ root.replaceChildren(...[picker(s), ...body].filter(Boolean));
121
+ for (const el of root.querySelectorAll("input[name], textarea[name]")) if (values.has(el.name) && el.name !== "q") el.value = values.get(el.name);
122
+ if (focused) root.querySelector(`[name="${focused}"]`)?.focus();
123
+ });
124
+ return {
125
+ show(id, p = {}) { panel = id; params = p; root.replaceChildren(); },
126
+ hide() {},
127
+ destroy() { root.remove(); },
128
+ };
129
+ };