@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/.claude-plugin/plugin.json +1 -1
- package/README.md +36 -3
- package/package.json +6 -3
- package/src/cli.ts +41 -5
- package/src/commands-table.ts +1 -1
- package/src/hook-spool.ts +12 -4
- package/src/hooks.ts +28 -5
- package/src/mcp-memory.ts +8 -5
- package/src/memory/candidate-overlaps.ts +87 -0
- package/src/memory/candidates.ts +32 -0
- package/src/memory/capture-input.ts +13 -1
- package/src/memory/capture.ts +29 -8
- package/src/memory/commands.ts +10 -1
- package/src/memory/consolidation-clusters.ts +6 -6
- package/src/memory/doctor.ts +11 -1
- package/src/memory/external-import.ts +15 -3
- package/src/memory/forget-nearby.ts +107 -0
- package/src/memory/gc.ts +22 -5
- package/src/memory/search-fts.ts +4 -2
- package/src/memory/store.ts +8 -0
- package/src/session-brief.ts +2 -0
- package/src/setup-console.ts +50 -0
- package/src/setup-gemini.ts +31 -0
- package/src/setup-opencode.ts +15 -0
- package/src/setup-owned.ts +4 -0
- package/src/setup.ts +50 -10
- package/src/ui/panel.css +15 -0
- package/src/ui/panel.js +129 -0
- package/src/ui/plugin.ts +495 -0
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;
|
package/src/ui/panel.css
ADDED
|
@@ -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; } }
|
package/src/ui/panel.js
ADDED
|
@@ -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
|
+
};
|