@staix/agent-mem 0.17.0 → 0.19.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 +26 -5
- package/package.json +4 -2
- package/src/cli.ts +27 -4
- package/src/commands-table.ts +3 -1
- package/src/hook-contract.ts +9 -0
- package/src/hook-jcode.ts +56 -0
- package/src/hook-native.ts +66 -13
- package/src/hook-project.ts +27 -4
- package/src/hook-resources.ts +54 -12
- package/src/hook-rss-wire.ts +85 -0
- package/src/hook-spool.ts +80 -20
- package/src/hook-transcript.ts +189 -0
- package/src/hooks.ts +132 -61
- package/src/mcp-memory.ts +13 -3
- package/src/memory/candidates.ts +13 -4
- package/src/memory/capture-input.ts +34 -0
- package/src/memory/capture.ts +46 -0
- package/src/memory/catalog.ts +2 -1
- package/src/memory/cross-project.ts +2 -1
- package/src/memory/doctor.ts +88 -17
- package/src/memory/formats.ts +65 -0
- package/src/memory/index-file.ts +15 -0
- package/src/memory/metrics.ts +4 -3
- package/src/memory/public-prune-activation.ts +2 -1
- package/src/memory/search-fts.ts +63 -15
- package/src/memory/search-score.ts +14 -0
- package/src/memory/shard.ts +2 -1
- package/src/memory/store.ts +29 -6
- package/src/memory/sync.ts +2 -0
- package/src/memory/work-lists.ts +2 -0
- package/src/session-brief-worker.ts +38 -37
- package/src/session-brief.ts +26 -12
- package/src/setup-drift.ts +217 -0
- package/src/setup-jcode.ts +65 -0
- package/src/setup-owned.ts +18 -1
- package/src/setup-paths.ts +76 -0
- package/src/setup.ts +92 -47
- package/src/ui/panel.js +25 -0
- package/src/ui/plugin.ts +17 -5
package/src/setup-jcode.ts
CHANGED
|
@@ -1,7 +1,72 @@
|
|
|
1
1
|
import type { ConfigImage } from "./setup-owned.ts";
|
|
2
2
|
import { createJsonMcpCodec } from "./setup-json.ts";
|
|
3
|
+
import { setTomlPath } from "./setup-toml.ts";
|
|
3
4
|
|
|
4
5
|
const codec = createJsonMcpCodec({ canonicalKey: "servers", aliases: ["mcpServers"], label: "Jcode" });
|
|
5
6
|
export function readJcodeMcp(image: ConfigImage): unknown { return codec.read(image); }
|
|
6
7
|
export function putJcodeMcp(image: ConfigImage, value: unknown): ConfigImage { return codec.put(image, value); }
|
|
7
8
|
export function restoreJcodeMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage { return codec.restore(before, after, current); }
|
|
9
|
+
|
|
10
|
+
// #203: jcode v0.99.1 `[hooks]` in <JCODE_HOME>/config.toml (crates/jcode-config-types HooksConfig). Each event accepts a
|
|
11
|
+
// command string or an ordered array of strings; observers in an array all run. amem owns only its own command element.
|
|
12
|
+
export const JCODE_CAPTURE_EVENTS = ["post_tool", "turn_end"] as const;
|
|
13
|
+
const tomlText = (image: ConfigImage) => image.bytes === null ? "" : new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(image.bytes, "base64"));
|
|
14
|
+
const tomlImage = (value: string, mode: number): ConfigImage => ({ bytes: Buffer.from(value).toString("base64"), mode });
|
|
15
|
+
function hooksTable(text: string): Record<string, unknown> | undefined {
|
|
16
|
+
let document: unknown;
|
|
17
|
+
try { document = Bun.TOML.parse(text); } catch { throw Error("Jcode config.toml does not parse as TOML"); }
|
|
18
|
+
const hooks = (document as Record<string, unknown>).hooks;
|
|
19
|
+
if (hooks === undefined) return undefined;
|
|
20
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) throw Error("Jcode [hooks] must be a table");
|
|
21
|
+
return hooks as Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
function commands(value: unknown): string[] {
|
|
24
|
+
if (value === undefined) return [];
|
|
25
|
+
if (typeof value === "string") return value.trim() ? [value] : [];
|
|
26
|
+
if (Array.isArray(value) && value.every(item => typeof item === "string")) return value as string[];
|
|
27
|
+
throw Error("Jcode hook commands must be a string or an array of strings");
|
|
28
|
+
}
|
|
29
|
+
/** Adds `command` to each capture event, keeping any existing user command ahead of it (array form). */
|
|
30
|
+
export function putJcodeHooks(before: ConfigImage, command: string): ConfigImage {
|
|
31
|
+
let text = tomlText(before);
|
|
32
|
+
const hooks = hooksTable(text) ?? {};
|
|
33
|
+
for (const event of JCODE_CAPTURE_EVENTS) {
|
|
34
|
+
const existing = commands(hooks[event]);
|
|
35
|
+
if (existing.includes(command)) continue;
|
|
36
|
+
text = setTomlPath(text, ["hooks", event], existing.length ? [...existing, command] : command);
|
|
37
|
+
}
|
|
38
|
+
return tomlImage(text, before.mode);
|
|
39
|
+
}
|
|
40
|
+
/** amem's command elements, per event, that `after` added over `before` (the receipt proves ownership, not the runtime). */
|
|
41
|
+
function ownedCommands(before: ConfigImage, after: ConfigImage): Map<string, string[]> {
|
|
42
|
+
const b = hooksTable(tomlText(before)) ?? {}, a = hooksTable(tomlText(after)) ?? {}, owned = new Map<string, string[]>();
|
|
43
|
+
for (const event of JCODE_CAPTURE_EVENTS) { const prior = commands(b[event]); owned.set(event, commands(a[event]).filter(item => !prior.includes(item))); }
|
|
44
|
+
return owned;
|
|
45
|
+
}
|
|
46
|
+
export function jcodeHooksManaged(before: ConfigImage, after: ConfigImage, current: ConfigImage): boolean {
|
|
47
|
+
try { const hooks = hooksTable(tomlText(current)) ?? {}; return [...ownedCommands(before, after)].every(([event, owned]) => owned.every(item => commands(hooks[event]).includes(item))); }
|
|
48
|
+
catch { return false; }
|
|
49
|
+
}
|
|
50
|
+
/** Exact restore when untouched; otherwise removes only amem's command elements, keeping user commands and later edits. */
|
|
51
|
+
export function restoreJcodeHooks(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
|
|
52
|
+
if (current.bytes === after.bytes) return { ...before, mode: current.mode };
|
|
53
|
+
if (current.bytes === before.bytes) return current;
|
|
54
|
+
const original = hooksTable(tomlText(before)) ?? {}, owned = ownedCommands(before, after);
|
|
55
|
+
let text = tomlText(current);
|
|
56
|
+
const hooks = hooksTable(text) ?? {};
|
|
57
|
+
for (const event of JCODE_CAPTURE_EVENTS) {
|
|
58
|
+
const live = commands(hooks[event]), mine = owned.get(event) ?? [];
|
|
59
|
+
if (!live.some(item => mine.includes(item))) continue;
|
|
60
|
+
const kept = live.filter(item => !mine.includes(item)), prior = original[event];
|
|
61
|
+
// Return the user's original spelling when only amem's element was added; otherwise keep the remaining array.
|
|
62
|
+
const value = !kept.length ? (prior !== undefined && !commands(prior).length ? prior : undefined)
|
|
63
|
+
: kept.length === 1 && typeof prior === "string" && prior === kept[0] ? prior : kept;
|
|
64
|
+
text = setTomlPath(text, ["hooks", event], value);
|
|
65
|
+
}
|
|
66
|
+
if (hooksTable(tomlText(before)) === undefined) {
|
|
67
|
+
const left = hooksTable(text);
|
|
68
|
+
if (left !== undefined && !Object.keys(left).length) text = setTomlPath(text, ["hooks"], undefined);
|
|
69
|
+
}
|
|
70
|
+
if (before.bytes === null && !Object.keys(Bun.TOML.parse(text) as object).length) return { ...before, mode: current.mode };
|
|
71
|
+
return tomlImage(text, current.mode);
|
|
72
|
+
}
|
package/src/setup-owned.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
import { SETUP_CONFIG_BYTES, setTomlPath, updateTomlArray } from "./setup-toml.ts";
|
|
3
|
-
import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp } from "./setup-jcode.ts";
|
|
3
|
+
import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp, jcodeHooksManaged, restoreJcodeHooks } from "./setup-jcode.ts";
|
|
4
4
|
import { readAgyMcp, putAgyMcp, restoreAgyMcp } from "./setup-agy.ts";
|
|
5
5
|
import { readOpencodeMcp, putOpencodeMcp, restoreOpencodeMcp } from "./setup-opencode.ts";
|
|
6
6
|
export interface ConfigImage { bytes: string | null; mode: number }
|
|
@@ -119,6 +119,7 @@ export function restoreOwned(target: string, index: number, before: ConfigImage,
|
|
|
119
119
|
if (target === "agy" && index === 0) return restoreAgyMcp(before, after, current);
|
|
120
120
|
if (target === "opencode" && index === 0) return restoreOpencodeMcp(before, after, current);
|
|
121
121
|
if (target === "jcode" && index === 0) return restoreJcodeMcp(before, after, current);
|
|
122
|
+
if (target === "jcode" && index === 1) return restoreJcodeHooks(before, after, current);
|
|
122
123
|
if (sameImage(current, after)) return before;
|
|
123
124
|
if (sameImage(current, before)) return current;
|
|
124
125
|
// Qwen keeps both managed surfaces in one settings file and one receipt image.
|
|
@@ -136,6 +137,7 @@ export function managedAfter(target: string, index: number, before: ConfigImage,
|
|
|
136
137
|
return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current)) && needed.every(row => live.some(value => value.id === row.id));
|
|
137
138
|
}
|
|
138
139
|
if (index === 0) return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current));
|
|
140
|
+
if (target === "jcode") return jcodeHooksManaged(before, after, current);
|
|
139
141
|
if (target === "claude" || target === "codex") {
|
|
140
142
|
const needed = added(hooks(json(before)), hooks(json(after)), row => row.id), live = hooks(json(current));
|
|
141
143
|
return needed.every(row => live.some(value => value.id === row.id));
|
|
@@ -143,3 +145,18 @@ export function managedAfter(target: string, index: number, before: ConfigImage,
|
|
|
143
145
|
if (target === "kimi") return added(kimiHooks(before), kimiHooks(after), kimiKey).every(row => kimiHooks(current).some(value => kimiKey(value) === kimiKey(row)));
|
|
144
146
|
return current.bytes === after.bytes;
|
|
145
147
|
}
|
|
148
|
+
|
|
149
|
+
/** Read-only receipt diagnostics; use the same hook identity rules as semantic undo. */
|
|
150
|
+
export function managedHookEntries(target: string, before: ConfigImage, after: ConfigImage, current: ConfigImage,
|
|
151
|
+
expected: readonly { event: string; command: string }[]): { entry: string; present: boolean }[] {
|
|
152
|
+
if (target === "kimi") {
|
|
153
|
+
const original = kimiHooks(before), installed = kimiHooks(after), live = kimiHooks(current);
|
|
154
|
+
const ours = new Set(added(original, installed, kimiKey).map(kimiKey));
|
|
155
|
+
return installed.filter(row => ours.has(kimiKey(row)) || expected.some(value => value.event === row.event && value.command === row.command))
|
|
156
|
+
.map(row => ({ entry: `hooks.${String(row.event)}`, present: live.some(value => kimiKey(value) === kimiKey(row)) }));
|
|
157
|
+
}
|
|
158
|
+
const strict = target === "qwen", original = hooks(json(before), strict), installed = hooks(json(after), strict), live = hooks(json(current), strict);
|
|
159
|
+
const ours = new Set(added(original, installed, row => row.id).map(row => row.id));
|
|
160
|
+
return installed.filter(row => ours.has(row.id) || expected.some(value => value.event === row.event && value.command === row.command))
|
|
161
|
+
.map(row => ({ entry: `hooks.${row.event}`, present: live.some(value => value.id === row.id) }));
|
|
162
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
4
|
+
import { geminiSettingsPath } from "./setup-gemini.ts";
|
|
5
|
+
import type { SetupTarget } from "./setup.ts";
|
|
6
|
+
|
|
7
|
+
export type SetupEnvironment = Readonly<Record<string, string | undefined>>;
|
|
8
|
+
export interface SetupRedirect {
|
|
9
|
+
target: "claude" | "codex";
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
}
|
|
14
|
+
export interface SetupPaths {
|
|
15
|
+
home: string;
|
|
16
|
+
paths: Record<SetupTarget, string[]>;
|
|
17
|
+
/** Pre-redirect targets, only for validating receipts made by an older setup version. */
|
|
18
|
+
legacyPaths: Record<SetupTarget, string[]>;
|
|
19
|
+
redirects: SetupRedirect[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Same fallback as setup inventory; assertOpencodeLayout separately rejects relative XDG_CONFIG_HOME. */
|
|
23
|
+
export function opencodeConfigRoot(env: SetupEnvironment, home: string): string {
|
|
24
|
+
const value = env.XDG_CONFIG_HOME;
|
|
25
|
+
return value === undefined || value === "" || !isAbsolute(value) ? join(home, ".config") : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Path recognition only: no app import, probe, directory creation or config read. */
|
|
29
|
+
function orcaHome(root: string, kind: "claude" | "codex", env: SetupEnvironment, home: string): boolean {
|
|
30
|
+
const userData = canonicalPath(resolve(env.ORCA_USER_DATA_PATH?.trim() || join(home, "Library", "Application Support", "orca")));
|
|
31
|
+
const parts = relative(userData, canonicalPath(root)).split(sep);
|
|
32
|
+
return kind === "claude"
|
|
33
|
+
? parts.length === 3 && parts[0] === "claude-profiles" && !!parts[1] && parts[2] === "home"
|
|
34
|
+
: parts.length === 3 && parts[0] === "codex-accounts" && !!parts[1] && parts[2] === "home"
|
|
35
|
+
|| parts.length === 2 && parts[0] === "codex-runtime-home" && parts[1] === "home";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Shared setup/doctor resolution. Publication still applies setup's named-directory/link guards. */
|
|
39
|
+
export function resolveSetupPaths(env: SetupEnvironment = process.env, givenHome?: string, workspaceRoot?: string): SetupPaths {
|
|
40
|
+
const home = givenHome ?? realPath(resolve(env.HOME?.trim() || homedir()));
|
|
41
|
+
const root = (key: string, fallback: string) => {
|
|
42
|
+
const setting = env[key];
|
|
43
|
+
const present = key === "JCODE_HOME" ? setting !== undefined : key === "QWEN_HOME" ? !!setting : !!setting?.trim();
|
|
44
|
+
if (!present) return join(home, fallback);
|
|
45
|
+
const expanded = key === "QWEN_HOME" && /^~(?:[/\\]|$)/.test(setting!) ? join(home, ...setting!.slice(2).split(/[/\\]/)) : setting!;
|
|
46
|
+
const given = resolve(expanded), withinHome = relative(resolve(env.HOME?.trim() || homedir()), given);
|
|
47
|
+
return withinHome !== ".." && !withinHome.startsWith("../") && !isAbsolute(withinHome) ? join(home, withinHome) : given;
|
|
48
|
+
};
|
|
49
|
+
const claude = root("CLAUDE_CONFIG_DIR", ".claude"), codex = root("CODEX_HOME", ".codex"), kimi = root("KIMI_CODE_HOME", ".kimi-code");
|
|
50
|
+
const pi = root("PI_CODING_AGENT_DIR", ".pi/agent"), qwen = root("QWEN_HOME", ".qwen");
|
|
51
|
+
const paths: SetupPaths["paths"] = {
|
|
52
|
+
claude: [env.CLAUDE_CONFIG_DIR?.trim() ? join(claude, ".claude.json") : join(home, ".claude.json"), join(claude, "settings.json")],
|
|
53
|
+
codex: [join(codex, "config.toml"), join(codex, "hooks.json")],
|
|
54
|
+
kimi: [join(kimi, "mcp.json"), join(kimi, "config.toml")],
|
|
55
|
+
pi: [join(pi, "mcp.json"), join(pi, "extensions", "amem-session.ts")],
|
|
56
|
+
qwen: [join(qwen, "settings.json")],
|
|
57
|
+
// #203: config.toml carries the jcode [hooks] capture entries.
|
|
58
|
+
jcode: [join(root("JCODE_HOME", ".jcode"), "mcp.json"), join(root("JCODE_HOME", ".jcode"), "config.toml")],
|
|
59
|
+
opencode: [join(opencodeConfigRoot(env, home), "opencode", "opencode.json")],
|
|
60
|
+
gemini: [geminiSettingsPath(env, home)],
|
|
61
|
+
agy: workspaceRoot ? [join(workspaceRoot, ".agents", "mcp_config.json")] : [],
|
|
62
|
+
};
|
|
63
|
+
const redirects: SetupRedirect[] = [];
|
|
64
|
+
const legacyPaths = Object.fromEntries(Object.entries(paths).map(([target, files]) => [target, [...files]])) as SetupPaths["paths"];
|
|
65
|
+
const redirect = (target: SetupRedirect["target"], index: number, to: string) => {
|
|
66
|
+
const from = paths[target][index]!;
|
|
67
|
+
paths[target][index] = to;
|
|
68
|
+
redirects.push({ target, from, to, reason: "Orca re-syncs this managed file from the personal source" });
|
|
69
|
+
};
|
|
70
|
+
if (orcaHome(claude, "claude", env, home)) redirect("claude", 1, join(home, ".claude", "settings.json"));
|
|
71
|
+
if (orcaHome(codex, "codex", env, home)) {
|
|
72
|
+
redirect("codex", 0, join(home, ".codex", "config.toml"));
|
|
73
|
+
redirect("codex", 1, join(home, ".codex", "hooks.json"));
|
|
74
|
+
}
|
|
75
|
+
return { home, paths, legacyPaths, redirects };
|
|
76
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -3,7 +3,11 @@ 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
5
|
import { opencodeServer } from "./setup-opencode.ts";
|
|
6
|
-
import { assertGeminiHome, assertGeminiRuntime, GEMINI_REMOVE, geminiAddCommand
|
|
6
|
+
import { assertGeminiHome, assertGeminiRuntime, GEMINI_REMOVE, geminiAddCommand } from "./setup-gemini.ts";
|
|
7
|
+
import { resolveSetupPaths, opencodeConfigRoot } from "./setup-paths.ts";
|
|
8
|
+
import { JCODE_CAPTURE_EVENTS, putJcodeHooks } from "./setup-jcode.ts";
|
|
9
|
+
/** jcode parses hook command lines shell-style and runs them directly (no shell), so single quotes group each path. */
|
|
10
|
+
const jcodeHookCommand = (bun: string, entry: string) => `${quote(bun)} --smol ${quote(entry)} hook jcode`;
|
|
7
11
|
import { spawnSync } from "node:child_process";
|
|
8
12
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
13
|
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -33,6 +37,8 @@ interface DirectoryIdentity { dev: number; ino: number }
|
|
|
33
37
|
interface Receipt { createdDirectory?: DirectoryIdentity; version: 1; target: SetupTarget; intent: string; phase: "prepared" | "applied"; changes: Change[] }
|
|
34
38
|
export interface SetupContext {
|
|
35
39
|
env?: Env; bun?: string; entry?: string; stdinTTY?: boolean; cwd?: string;
|
|
40
|
+
/** `--root`: an absolute amem install root to register instead of the running one (#199). Ignored when `entry` is given. */
|
|
41
|
+
root?: string;
|
|
36
42
|
/** @deprecated Retained for source compatibility; explicit apply/undo modes do not prompt. */
|
|
37
43
|
confirm?: (plan: SetupPlan) => Promise<boolean>;
|
|
38
44
|
/** Host-only race fixture, called after temporary-file fsync and before the final comparison/rename. */
|
|
@@ -41,7 +47,7 @@ export interface SetupContext {
|
|
|
41
47
|
native?: (argv: readonly string[], env: Env, cwd: string) => void;
|
|
42
48
|
}
|
|
43
49
|
export interface SetupPlan {
|
|
44
|
-
targets: SetupTarget[]; mode: "preview" | "apply" | "undo";
|
|
50
|
+
targets: SetupTarget[]; mode: "preview" | "apply" | "undo"; root?: InstallRoot;
|
|
45
51
|
actions: { target: SetupTarget; version?: ClientVersion; files: string[]; receipt: string; cwd?: string; registration: string[] | null; lifecycle: string; available: boolean; hooks: PlannedHook[]; mcpChange: "add" | "replace" | "restore" | "unchanged"; commands: string[][]; state: string }[];
|
|
46
52
|
notes: string[];
|
|
47
53
|
}
|
|
@@ -129,39 +135,48 @@ function workspaceBinding(options: SetupContext): WorkspaceBinding {
|
|
|
129
135
|
}
|
|
130
136
|
function context(options: SetupContext, workspace?: WorkspaceBinding) {
|
|
131
137
|
const env = options.env ?? process.env, home = realPath(resolve(env.HOME?.trim() || homedir()));
|
|
132
|
-
const
|
|
133
|
-
const setting = env[key];
|
|
134
|
-
const present = key === "JCODE_HOME" ? setting !== undefined : key === "QWEN_HOME" ? !!setting : !!setting?.trim();
|
|
135
|
-
if (!present) return join(home, fallback);
|
|
136
|
-
const expanded = key === "QWEN_HOME" && /^~(?:[/\\]|$)/.test(setting!) ? join(home, ...setting!.slice(2).split(/[/\\]/)) : setting!;
|
|
137
|
-
const given = resolve(expanded), withinHome = relative(resolve(env.HOME?.trim() || homedir()), given);
|
|
138
|
-
// Canonicalize the HOME alias only; named configuration-directory symlinks must still be refused.
|
|
139
|
-
return withinHome !== ".." && !withinHome.startsWith("../") && !isAbsolute(withinHome) ? join(home, withinHome) : given;
|
|
140
|
-
};
|
|
141
|
-
const claude = root("CLAUDE_CONFIG_DIR", ".claude"), codex = root("CODEX_HOME", ".codex"), kimi = root("KIMI_CODE_HOME", ".kimi-code"), pi = root("PI_CODING_AGENT_DIR", ".pi/agent"), qwen = root("QWEN_HOME", ".qwen");
|
|
142
|
-
const paths: Record<SetupTarget, string[]> = {
|
|
143
|
-
claude: [env.CLAUDE_CONFIG_DIR?.trim() ? join(claude, ".claude.json") : join(home, ".claude.json"), join(claude, "settings.json")],
|
|
144
|
-
codex: [join(codex, "config.toml"), join(codex, "hooks.json")],
|
|
145
|
-
kimi: [join(kimi, "mcp.json"), join(kimi, "config.toml")],
|
|
146
|
-
pi: [join(pi, "mcp.json"), join(pi, "extensions", "amem-session.ts")],
|
|
147
|
-
qwen: [join(qwen, "settings.json")],
|
|
148
|
-
// Absolute JCODE_HOME spellings stay literal; unset uses the platform-home default.
|
|
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)],
|
|
154
|
-
agy: workspace ? [join(workspace.root, ".agents", "mcp_config.json")] : [],
|
|
155
|
-
};
|
|
138
|
+
const { paths, legacyPaths, redirects } = resolveSetupPaths(env, home, workspace?.root);
|
|
156
139
|
const requestedBun = options.bun ?? "bun", bun = requestedBun === "bun" ? Bun.which("bun", { PATH: env.PATH ?? process.env.PATH }) : requestedBun;
|
|
157
|
-
const
|
|
140
|
+
const install = options.entry === undefined && options.root !== undefined ? installRoot(options.root) : undefined;
|
|
141
|
+
const entry = options.entry ?? (install ? join(install.path, "bin", "amem.js") : fileURLToPath(new URL("../bin/amem.js", import.meta.url)));
|
|
158
142
|
if (!bun || !isAbsolute(bun) || !isAbsolute(entry)) throw Error("setup needs a PATH-resolved absolute Bun runtime and an absolute installed entry");
|
|
159
143
|
// The trusted native fixture may use a temporary package; ordinary CLI installation must outlive bunx's cache.
|
|
160
144
|
if (!options.native) {
|
|
161
145
|
const roots = [resolve(tmpdir()), realPath(tmpdir())], entries = [resolve(entry), realPath(entry)];
|
|
162
146
|
if (roots.some(root => entries.some(file => { const part = relative(root, file); return part !== ".." && !part.startsWith("../") && !isAbsolute(part); }))) throw Error("setup refuses a temporary package entry; install amem in a persistent location first");
|
|
163
147
|
}
|
|
164
|
-
return { env, home, paths, bun, entry, workspace, state: join(canonicalPath(memoryHome({ ...env, AGENTHUB_HOME: env.AGENTHUB_HOME?.trim() || join(home, ".agenthub") })), "setup") };
|
|
148
|
+
return { env, home, paths, legacyPaths, redirects, bun, entry, root: install, workspace, state: join(canonicalPath(memoryHome({ ...env, AGENTHUB_HOME: env.AGENTHUB_HOME?.trim() || join(home, ".agenthub") })), "setup") };
|
|
149
|
+
}
|
|
150
|
+
export interface InstallRoot { path: string; version: string }
|
|
151
|
+
const PACKAGE_JSON_MAX = 64 * 1024;
|
|
152
|
+
/**
|
|
153
|
+
* An amem install root to register, such as `<install>/releases/<version>/` (#199). It is resolved to its real path, so a `current` link
|
|
154
|
+
* registers the release it names, and replacing another install never changes what the registered hooks and MCP server load. The root must
|
|
155
|
+
* hold a regular `package.json` named `@staix/agent-mem` (read on one non-following, non-blocking descriptor, at most 64 KiB) and a regular
|
|
156
|
+
* `bin/amem.js`.
|
|
157
|
+
*/
|
|
158
|
+
export function installRoot(root: string): InstallRoot {
|
|
159
|
+
if (!isAbsolute(root)) throw Error("setup --root must be an absolute path");
|
|
160
|
+
const refused = (why: string) => Error(`setup --root ${root}: ${why}`);
|
|
161
|
+
let path: string;
|
|
162
|
+
try { path = realPath(root); } catch { throw refused("not found"); }
|
|
163
|
+
let fd: number;
|
|
164
|
+
try { fd = openSync(join(path, "package.json"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { throw refused("no readable package.json"); }
|
|
165
|
+
let text: string;
|
|
166
|
+
try {
|
|
167
|
+
const stat = fstatSync(fd);
|
|
168
|
+
if (!stat.isFile() || stat.size > PACKAGE_JSON_MAX) throw refused("package.json is not a regular file of at most 64 KiB");
|
|
169
|
+
const data = Buffer.alloc(PACKAGE_JSON_MAX + 1); let size = 0, n: number;
|
|
170
|
+
while (size < data.length && (n = readSync(fd, data, size, data.length - size, null)) > 0) size += n;
|
|
171
|
+
if (size > PACKAGE_JSON_MAX) throw refused("package.json is not a regular file of at most 64 KiB");
|
|
172
|
+
text = data.subarray(0, size).toString("utf8");
|
|
173
|
+
} finally { closeSync(fd); }
|
|
174
|
+
let pkg: unknown;
|
|
175
|
+
try { pkg = JSON.parse(text); } catch { throw refused("package.json is not JSON"); }
|
|
176
|
+
const { name, version } = (pkg && typeof pkg === "object" ? pkg : {}) as { name?: unknown; version?: unknown };
|
|
177
|
+
if (name !== "@staix/agent-mem" || typeof version !== "string") throw refused("not an @staix/agent-mem install root");
|
|
178
|
+
if (!lstatSync(join(path, "bin", "amem.js"), { throwIfNoEntry: false })?.isFile()) throw refused("no regular bin/amem.js");
|
|
179
|
+
return { path, version };
|
|
165
180
|
}
|
|
166
181
|
function command(target: SetupTarget, bun: string, entry: string): string[] | null {
|
|
167
182
|
if (target === "kimi" || target === "jcode" || target === "opencode" || target === "agy") return null;
|
|
@@ -174,11 +189,6 @@ function command(target: SetupTarget, bun: string, entry: string): string[] | nu
|
|
|
174
189
|
* (no trim) and falls back to ~/.config only when it is unset or empty. A relative value would resolve against
|
|
175
190
|
* OpenCode's own cwd, so setup refuses it rather than guess. An absolute value keeps its literal spelling.
|
|
176
191
|
*/
|
|
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
|
-
}
|
|
182
192
|
function assertJcodeHome(env: Env): void {
|
|
183
193
|
if (env.JCODE_HOME !== undefined && !isAbsolute(env.JCODE_HOME)) throw Error("setup requires an absolute JCODE_HOME; unset it to use the default");
|
|
184
194
|
}
|
|
@@ -195,14 +205,15 @@ function assertOpencodeLayout(c: ReturnType<typeof context>, mode: SetupPlan["mo
|
|
|
195
205
|
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
206
|
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
207
|
}
|
|
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] : [])]); }
|
|
208
|
+
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 === "jcode" ? ["jcode-hooks-v1"] : []), ...(target === "agy" ? [c.workspace!.root] : [])]); }
|
|
199
209
|
function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, target === "agy" ? `agy-${digest(c.workspace!.root)}.json` : `${target}.json`); }
|
|
200
210
|
function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
|
|
201
211
|
const raw = read(journal(c, target), JOURNAL_MAX); if (raw.bytes === null) return;
|
|
202
212
|
const value = record(JSON.parse(Buffer.from(raw.bytes, "base64").toString("utf8")));
|
|
203
|
-
if (value.version !== 1 || value.target !== target || typeof value.intent !== "string" || !/^[a-f0-9]{64}$/.test(value.intent) || requireRuntime && value.intent !== intent(c, target) || !["prepared", "applied"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== c.paths[target].length) throw Error("setup receipt does not match this installation");
|
|
213
|
+
if (value.version !== 1 || value.target !== target || typeof value.intent !== "string" || !/^[a-f0-9]{64}$/.test(value.intent) || requireRuntime && value.intent !== intent(c, target) || !["prepared", "applied"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== c.paths[target].length && !(target === "jcode" && value.changes.length === 1)) throw Error("setup receipt does not match this installation");
|
|
204
214
|
for (let i = 0; i < value.changes.length; i++) {
|
|
205
|
-
const change = record(value.changes[i]);
|
|
215
|
+
const change = record(value.changes[i]); const currentPath = c.paths[target][i], legacyRedirect = c.redirects.some(row => row.target === target && row.from === change.path && row.to === currentPath);
|
|
216
|
+
if (change.path !== currentPath && !legacyRedirect || change.key !== `${target}:${i}`) throw Error("setup receipt target mismatch");
|
|
206
217
|
for (const key of ["before", "after", ...(change.start !== undefined ? ["start"] : [])]) {
|
|
207
218
|
const v = record(change[key]);
|
|
208
219
|
if (!Number.isInteger(v.mode) || Number(v.mode) < 0 || Number(v.mode) > 0o777 || v.bytes !== null && (typeof v.bytes !== "string" || v.bytes.length > 2 * MAX || Buffer.from(v.bytes, "base64").toString("base64") !== v.bytes)) throw Error("setup receipt invalid");
|
|
@@ -215,12 +226,13 @@ function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRunt
|
|
|
215
226
|
return value as unknown as Receipt;
|
|
216
227
|
}
|
|
217
228
|
function plannedHooks(c: ReturnType<typeof context>, target: SetupTarget): PlannedHook[] {
|
|
218
|
-
if (target === "jcode"
|
|
229
|
+
if (target === "jcode") return JCODE_CAPTURE_EVENTS.map(event => ({ event, command: jcodeHookCommand(c.bun, c.entry), timeout: 1 }));
|
|
230
|
+
if (target === "opencode" || target === "gemini" || target === "agy") return [];
|
|
219
231
|
const launch = `${quote(c.bun)} --smol ${quote(c.entry)}`;
|
|
220
232
|
return target === "claude" ? HOOK_EVENTS.map(event => ({ event, command: `${launch} hook ${event}`, timeout: 1 }))
|
|
221
233
|
: [{ event: target === "pi" ? "session_shutdown" : "SessionEnd", command: `${launch} ingest ${target}${target === "pi" ? "" : " --json"}`, timeout: target === "pi" ? 2.5 : 3 }];
|
|
222
234
|
}
|
|
223
|
-
const lifecycle = (target: SetupTarget) => target === "
|
|
235
|
+
const lifecycle = (target: SetupTarget) => target === "opencode" || target === "gemini" || target === "agy" ? "MCP only; native capture unavailable" : target === "jcode" ? "post_tool and turn_end capture hooks" : target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
|
|
224
236
|
export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", options: SetupContext = {}): SetupPlan {
|
|
225
237
|
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]");
|
|
226
238
|
const targets: SetupTarget[] = target === "all" ? [...SETUP_ALL_TARGETS] : [target as SetupTarget];
|
|
@@ -231,34 +243,55 @@ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", o
|
|
|
231
243
|
if (targets.includes("gemini") && (target === "gemini" || available(c, "gemini", options))) assertGeminiHome(c.env);
|
|
232
244
|
if (mode !== "undo" && targets.includes("gemini")) assertGeminiRuntime(c.bun, c.entry);
|
|
233
245
|
const pluginNotes: string[] = [];
|
|
246
|
+
for (const row of c.redirects.filter(row => targets.includes(row.target))) {
|
|
247
|
+
pluginNotes.push(`${row.target}: ${row.from} -> ${row.to}. ${row.reason}; start a new Orca session to receive the change.`);
|
|
248
|
+
}
|
|
234
249
|
if (targets.includes("claude")) {
|
|
235
|
-
const
|
|
250
|
+
const settingsPath = mode === "undo" ? receipt(c, "claude", false)?.changes[1]?.path ?? c.paths.claude[1]! : c.paths.claude[1]!;
|
|
251
|
+
const settings = json(read(settingsPath));
|
|
236
252
|
// The settings-derived ids are computed outside the registry guard, so a broken installed_plugins.json cannot hide them.
|
|
237
253
|
const ids = new Set<string>();
|
|
238
254
|
try { for (const key of Object.keys(record(settings.enabledPlugins ?? {}))) ids.add(key); } catch { /* Optional plugin metadata must never prevent setup or recovery. */ }
|
|
239
|
-
try { for (const key of Object.keys(record(json(read(join(dirname(
|
|
255
|
+
try { for (const key of Object.keys(record(json(read(join(dirname(settingsPath), "plugins", "installed_plugins.json"))).plugins ?? {}))) ids.add(key); } catch { /* Optional plugin metadata must never prevent setup or recovery; authoritative settings/receipts keep their guards. */ }
|
|
240
256
|
for (const id of [...ids].filter(key => /^agent-mem(?:@[A-Za-z0-9._-]+)?$/.test(key))) pluginNotes.push(`Installed Claude plugin ${id} detected: if enabled, direct setup would duplicate its MCP server and seven hooks. Choose plugin installation or direct setup; disable/uninstall the plugin before applying direct setup.`);
|
|
241
257
|
}
|
|
242
258
|
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.");
|
|
243
|
-
if (targets.includes("jcode")) pluginNotes.push("Jcode setup registers MCP
|
|
259
|
+
if (targets.includes("jcode")) pluginNotes.push("Jcode setup registers MCP and adds `amem hook jcode` to the post_tool and turn_end lifecycle hooks in JCODE_HOME/config.toml, keeping any existing command for those events (array form). Each event is scoped by its own JCODE_HOOK_CWD; events with no cwd, or outside a project, are skipped. Saved sessions and journals are still not ingested (no per-message origin), and user prompts are not captured.");
|
|
244
260
|
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
261
|
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.");
|
|
246
262
|
if (c.workspace) {
|
|
247
263
|
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.`);
|
|
248
264
|
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.");
|
|
249
265
|
}
|
|
266
|
+
if (c.root) pluginNotes.push(`Registers the install root ${c.root.path} (amem ${c.root.version}) for hooks and MCP; current registrations change only on --apply.`);
|
|
250
267
|
if (target === "all") pluginNotes.push("Antigravity is repository-scoped and excluded from all; run amem setup agy explicitly inside its repository.");
|
|
251
268
|
if (targets.includes("jcode")) pluginNotes.push("Launch Jcode with JCODE_DISABLE_CLAUDE_MCP=1 and without a same-name project override: imported Claude stdio entries can replace this private amem binding with a shared daemon-cwd server. Use a new workspace-bound session when changing projects.");
|
|
252
|
-
return { targets, mode, actions: targets.map(target => {
|
|
253
|
-
const prior = receipt(c, target, false)
|
|
254
|
-
const
|
|
269
|
+
return { targets, mode, ...(c.root ? { root: c.root } : {}), actions: targets.map(target => {
|
|
270
|
+
const prior = receipt(c, target, false), files = mode === "undo" && prior ? prior.changes.map(change => change.path) : c.paths[target];
|
|
271
|
+
for (const file of files) read(file);
|
|
272
|
+
if (mode === "undo" && prior?.changes.some((change, index) => change.path !== c.paths[target][index])) {
|
|
273
|
+
pluginNotes.push(`${target}: undo restores the historical receipt files, not the new personal source: ${prior.changes.map(change => change.path).join(", ")}`);
|
|
274
|
+
}
|
|
275
|
+
const original = read(files[0]!);
|
|
255
276
|
const document: Record<string, unknown> = target === "agy" ? {} : target === "codex" ? toml(original.bytes === null ? "" : Buffer.from(original.bytes, "base64").toString("utf8")) : json(original);
|
|
256
277
|
const hasAmem = target === "jcode" || target === "opencode" || target === "agy" ? mcpRecord(target, original) !== undefined : Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
|
|
278
|
+
if (!prior && mode !== "undo" && (target === "claude" || target === "codex")) {
|
|
279
|
+
try {
|
|
280
|
+
const server = mcpRecord(target, original);
|
|
281
|
+
const sameBinding = server && typeof server === "object" && !Array.isArray(server)
|
|
282
|
+
&& (server as Record<string, unknown>).command === c.bun
|
|
283
|
+
&& isDeepStrictEqual((server as Record<string, unknown>).args, ["--smol", c.entry, "mcp"])
|
|
284
|
+
&& (server as Record<string, unknown>).enabled !== false;
|
|
285
|
+
const hookFile = json(read(c.paths[target][1]!)), groups = record(hookFile.hooks ?? {});
|
|
286
|
+
const sameHook = plannedHooks(c, target).some(row => Array.isArray(groups[row.event]) && hasHook(groups[row.event] as unknown[], row.command, row.timeout));
|
|
287
|
+
if (sameBinding || sameHook) pluginNotes.push(`${target}: matching amem entries are already present without a setup receipt; apply will not duplicate matching hook commands, and undo preserves the original entries.`);
|
|
288
|
+
} catch { /* Advisory only: preparing the real change retains the existing configuration validation. */ }
|
|
289
|
+
}
|
|
257
290
|
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; } });
|
|
258
291
|
const add = command(target, c.bun, c.entry), remove = target === "gemini" ? [...GEMINI_REMOVE] : [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"];
|
|
259
292
|
const installed = available(c, target, options), version = installed && target !== "agy" && mode !== "undo" ? probeClientVersion(c, target, options) : undefined;
|
|
260
293
|
if (version?.matches === false) pluginNotes.push(`WARNING: ${target} version ${version.output}; layout verified on ${version.verified}. Apply remains guarded by native configuration validation.`);
|
|
261
|
-
return { target, available: installed, ...(version ? { version } : {}), files
|
|
294
|
+
return { target, available: installed, ...(version ? { version } : {}), files, 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" };
|
|
262
295
|
}), notes: [...pluginNotes, ...(/\/[^/]*\d+\.\d+[^/]*\/(?:bin\/)?bun$/.test(c.bun) ? ["this bun path is version-specific; rerun setup --apply after changing Bun"] : []), "Preview only unless --apply or --undo is passed; explicit modes apply without a prompt.", "Native registration commands run in a private staging HOME; only the listed configuration files are published.", "Original file bytes restore exactly when untouched; otherwise undo preserves CLI-owned changes and restores only managed entries, refusing edits to those entries.", "No AGENTS.md, CLAUDE.md or rule files are edited.", `A private ${join(c.state, "setup.lock")} serializes mutations; temporary ${join(realPath(tmpdir()), "amem-setup-*")} staging homes are removed.`, "Codex requires its native /hooks review before newly registered hooks run; setup does not grant hook trust. Runtime registrations use the PATH-resolved absolute Bun path without resolving its stable symlink; setup all reports and skips missing clients."] };
|
|
263
296
|
}
|
|
264
297
|
/** Native probes and registration share one private HOME, cwd and configuration layout. */
|
|
@@ -312,12 +345,20 @@ function native(argv: readonly string[], env: Env, cwd: string): void {
|
|
|
312
345
|
throw Error(`${argv[0]} MCP registration ${reason} in staging; no native output disclosed`);
|
|
313
346
|
}
|
|
314
347
|
}
|
|
348
|
+
function hasHook(rows: readonly unknown[], command: string, timeout: number): boolean {
|
|
349
|
+
return rows.some(row => {
|
|
350
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return false;
|
|
351
|
+
const group = record(row);
|
|
352
|
+
return [undefined, "", "*"].some(value => value === group.matcher) && Array.isArray(group.hooks)
|
|
353
|
+
&& group.hooks.some((h: unknown) => { const value = record(h); return value.type === "command" && value.command === command && value.timeout === timeout; });
|
|
354
|
+
});
|
|
355
|
+
}
|
|
315
356
|
function hookDocument(before: Image, events: readonly string[], commandFor: (event: string) => string, timeout = 1): Image {
|
|
316
357
|
const document = json(before), hooks = document.hooks === undefined ? {} : record(document.hooks);
|
|
317
358
|
for (const event of events) {
|
|
318
359
|
const rows = hooks[event] ?? []; if (!Array.isArray(rows)) throw Error("setup hook groups must be arrays");
|
|
319
360
|
const command = commandFor(event);
|
|
320
|
-
if (!rows
|
|
361
|
+
if (!hasHook(rows, command, timeout)) rows.push({ hooks: [{ type: "command", command, timeout }] });
|
|
321
362
|
hooks[event] = rows;
|
|
322
363
|
}
|
|
323
364
|
document.hooks = hooks; return objectImage(document, before);
|
|
@@ -341,7 +382,10 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
|
|
|
341
382
|
if (target === "opencode") assertOpencodeLayout(c, "apply");
|
|
342
383
|
if (target === "gemini") assertGeminiHome(c.env);
|
|
343
384
|
const prior = receipt(c, target, false), expected = c.paths[target].map(path => read(path));
|
|
344
|
-
|
|
385
|
+
// Only a same-file before-image can restore a target. A newly authoritative personal source owns its current bytes.
|
|
386
|
+
// Mapped per current path: a pre-#203 jcode receipt covers mcp.json only, so config.toml starts from its live image.
|
|
387
|
+
const restored = c.paths[target].map((path, index) => { const change = prior?.changes[index];
|
|
388
|
+
return prior && change && change.path === path ? restoreChange(prior, change, index, expected[index]!) : expected[index]!; });
|
|
345
389
|
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 };
|
|
346
390
|
const changes: Change[] = c.paths[target].map((path, i) => ({ key: `${target}:${i}`, path, before: restored[i]!, after: restored[i]!, start: expected[i]! }));
|
|
347
391
|
const staging = mkdtempSync(join(realPath(tmpdir()), "amem-setup-"));
|
|
@@ -398,6 +442,7 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
|
|
|
398
442
|
} else if (target === "jcode") {
|
|
399
443
|
// Shared native pools use the daemon cwd; amem must bind the calling session's project.
|
|
400
444
|
changes[0]!.after = putMcpRecord(target, changes[0]!.before, { command: c.bun, args: ["--smol", c.entry, "mcp"], shared: false });
|
|
445
|
+
changes[1]!.after = putJcodeHooks(changes[1]!.before, jcodeHookCommand(c.bun, c.entry));
|
|
401
446
|
} else if (target === "opencode") {
|
|
402
447
|
// `opencode mcp add` is interactive for local servers and has no remove; edit the owned entry directly.
|
|
403
448
|
changes[0]!.after = putMcpRecord(target, changes[0]!.before, opencodeServer(c.bun, c.entry));
|
package/src/ui/panel.js
CHANGED
|
@@ -109,6 +109,31 @@ export default (host) => {
|
|
|
109
109
|
project ? btn("Forget this project...", () => act({ action: "project_forget" }), "btn danger") : null)));
|
|
110
110
|
return out;
|
|
111
111
|
},
|
|
112
|
+
recap(s) {
|
|
113
|
+
const brief = s.brief;
|
|
114
|
+
if (!brief || typeof brief.context !== "string") return [ui.empty("Recap unavailable", "The current SessionStart brief could not be computed.")];
|
|
115
|
+
const coverage = brief.coverage ?? {};
|
|
116
|
+
const sections = ["work", "curated", "capture", "repo"].filter((key) => Object.hasOwn(coverage, key));
|
|
117
|
+
const rows = sections.map((key) => {
|
|
118
|
+
const value = coverage[key];
|
|
119
|
+
const detail = value && typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
120
|
+
return { title: key, sub: line(detail, 180) };
|
|
121
|
+
});
|
|
122
|
+
const tokens = Number.isSafeInteger(brief.tokens) && brief.tokens >= 0 ? `At most ${brief.tokens} tokens` : "Token upper bound unavailable";
|
|
123
|
+
const generated = typeof s.generatedAt === "string" ? s.generatedAt : "unavailable";
|
|
124
|
+
return [
|
|
125
|
+
ui.notice("Computed now for this snapshot; this is not a copy of a prior session's injected brief.", "neutral"),
|
|
126
|
+
h("section", { class: "card amem-card" },
|
|
127
|
+
h("h3", { text: "Coverage" }),
|
|
128
|
+
ui.rows(rows, (row) => ui.row(row)) ?? ui.empty("No coverage details"),
|
|
129
|
+
h("dl", { class: "ui-provenance" },
|
|
130
|
+
h("dt", { text: "Status" }), h("dd", { text: brief.partial === true ? "Partial" : "Complete" }),
|
|
131
|
+
h("dt", { text: "Token bound" }), h("dd", { text: tokens }),
|
|
132
|
+
h("dt", { text: "Generated" }), h("dd", { text: generated }))),
|
|
133
|
+
s.repoAbsent === true ? h("p", { class: "amem-sub", text: "Repository section absent: the console has no repository binding." }) : null,
|
|
134
|
+
h("section", { class: "card amem-card" }, h("h3", { text: "SessionStart context" }), h("pre", { class: "amem-pre", text: brief.context })),
|
|
135
|
+
].filter(Boolean);
|
|
136
|
+
},
|
|
112
137
|
};
|
|
113
138
|
|
|
114
139
|
host.onSnapshot((s) => {
|
package/src/ui/plugin.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { createHash } from "node:crypto";
|
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { createMemoryMcp } from "../mcp-memory.ts";
|
|
10
|
+
import { sessionStartBrief } from "../session-brief.ts";
|
|
11
|
+
import { withBriefSnapshot } from "../memory/doctor.ts";
|
|
10
12
|
import { MCP_TOOLS } from "../mcp.ts";
|
|
11
13
|
import { CandidateStore, type Candidate, type CandidateInput } from "../memory/candidates.ts";
|
|
12
14
|
import { candidateOverlapsBatch } from "../memory/candidate-overlaps.ts";
|
|
@@ -99,11 +101,12 @@ export const plugin = {
|
|
|
99
101
|
api: 1 as const, tool: "amem", version: VERSION, title: "Memory", icon: "memory", pollMs: 15_000,
|
|
100
102
|
panels: [
|
|
101
103
|
{ id: "review", title: "Review", order: 1 },
|
|
102
|
-
{ id: "
|
|
103
|
-
{ id: "
|
|
104
|
-
{ id: "
|
|
105
|
-
{ id: "
|
|
106
|
-
{ id: "
|
|
104
|
+
{ id: "recap", title: "Recap", order: 2 },
|
|
105
|
+
{ id: "find", title: "Search", order: 3 },
|
|
106
|
+
{ id: "timeline", title: "Timeline", order: 4 },
|
|
107
|
+
{ id: "work", title: "Work lists", order: 5 },
|
|
108
|
+
{ id: "goals", title: "Goals", order: 6 },
|
|
109
|
+
{ id: "capacity", title: "Capacity", order: 7 },
|
|
107
110
|
],
|
|
108
111
|
commands: [
|
|
109
112
|
{ id: "gc", title: "Evict expired capture rows", hint: "amem gc", action: "gc" },
|
|
@@ -247,6 +250,15 @@ export const plugin = {
|
|
|
247
250
|
async function panel(name: string, args: Args, project: string): Promise<Args> {
|
|
248
251
|
switch (name) {
|
|
249
252
|
case "review": return { ...(await pending(project, true)), selected: str(args.id) ?? null };
|
|
253
|
+
case "recap": {
|
|
254
|
+
const revision = policyVersion(), generatedAt = new Date().toISOString();
|
|
255
|
+
assertProjectActive(base, project);
|
|
256
|
+
const brief = await withBriefSnapshot(base, project, measureOnly => sessionStartBrief({ cwd: base, base, project,
|
|
257
|
+
repository: false, policy: configuredPolicyOptions(base), measureOnly }));
|
|
258
|
+
assertProjectActive(base, project);
|
|
259
|
+
if (policyVersion() !== revision) throw Error("recap policy changed");
|
|
260
|
+
return { brief, generatedAt, repoAbsent: true };
|
|
261
|
+
}
|
|
250
262
|
case "find": {
|
|
251
263
|
const q = str(args.q);
|
|
252
264
|
if (!q) return { q: "", hits: [] };
|