diff --git a/emrg/gui/main.js b/emrg/gui/main.js index af8b544..d2f1ce6 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -355,6 +355,54 @@ vision = false return { ok: true, removedCount: frame.removed_count ?? 0 }; }); + ipcMain.handle("emrg:listMemories", async (_e, { scope = "project", sessionId } = {}) => { + // GUI / 指令 P3:/memory — 列出记忆(daemon list_memories → memories_list) + const params = { scope, cwd: projectDir }; + if (scope === "session") { + if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); + params.session_id = sessionId; + } + const frame = await client.sendCommandAndWait("list_memories", params, 5000); + return frame.memories || []; + }); + + ipcMain.handle("emrg:readMemory", async (_e, { memoryId, scope = "project", sessionId } = {}) => { + // GUI / 指令 P3:/memory — 读取单条记忆(daemon read_memory → memory_content) + if (typeof memoryId !== "string" || !memoryId.trim()) throw new Error("invalid memory_id"); + const params = { scope, memory_id: memoryId.trim(), cwd: projectDir }; + if (scope === "session") { + if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); + params.session_id = sessionId; + } + const frame = await client.sendCommandAndWait("read_memory", params, 5000); + return frame.memory || { id: memoryId, content: "" }; + }); + + ipcMain.handle("emrg:listSkills", async () => { + // GUI / 指令 P3:/skills — 读取技能列表(TUI 本地 load_skills 等价物,daemon 无协议) + // 技能在 ~/.emrg/skills/*.md(user)与 /.emrg/skills/*.md(project) + const skills = []; + const dirs = [ + { dir: path.join(os.homedir(), ".emrg", "skills"), source: "user" }, + { dir: path.join(projectDir, ".emrg", "skills"), source: "project" }, + ]; + for (const { dir, source } of dirs) { + let files = []; + try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")); } catch { continue; } + for (const f of files) { + try { + const text = fs.readFileSync(path.join(dir, f), "utf8"); + const m = text.match(/^---\n([\s\S]*?)\n---/); + const meta = m ? m[1] : ""; + const name = (meta.match(/^name:\s*(.+)$/m) || [])[1]?.trim() || f.replace(/\.md$/, ""); + const desc = (meta.match(/^description:\s*(.+)$/m) || [])[1]?.trim() || ""; + skills.push({ name, description: desc, source }); + } catch { /* 单个技能读取失败跳过 */ } + } + } + return skills; + }); + ipcMain.handle("emrg:setModel", async (_e, { model }) => { await client.sendCommandAndWait("set_model", { model }, 5000); return { ok: true }; diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index a5ddbaa..ebe5ad2 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -19,6 +19,9 @@ const api = { compactSession: (payload) => ipcRenderer.invoke("emrg:compactSession", payload), listHistory: (payload) => ipcRenderer.invoke("emrg:listHistory", payload), rewindSession: (payload) => ipcRenderer.invoke("emrg:rewindSession", payload), + listMemories: (payload) => ipcRenderer.invoke("emrg:listMemories", payload), + readMemory: (payload) => ipcRenderer.invoke("emrg:readMemory", payload), + listSkills: () => ipcRenderer.invoke("emrg:listSkills"), listModels: () => ipcRenderer.invoke("emrg:listModels"), saveSettings: (payload) => ipcRenderer.invoke("emrg:saveSettings", payload), getSettings: () => ipcRenderer.invoke("emrg:getSettings"), diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index d076059..172183c 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -869,3 +869,21 @@ dialog::backdrop { .help-row:last-child { border-bottom: none; } .help-cmd { font-family: var(--font-mono, monospace); font-weight: 600; color: var(--accent); } .help-hint { color: var(--text-2); font-size: var(--fs-secondary); } + +/* / 指令 P3:记忆详情 / 技能列表 */ +.memory-detail { + margin-top: var(--sp-3, 12px); + padding: var(--sp-3, 12px); + background: var(--bg-soft); + border-radius: var(--radius-sm, 8px); + max-height: 220px; + overflow-y: auto; +} +.memory-detail-title { font-weight: 600; margin-bottom: 6px; } +.memory-detail-body { + font-family: var(--font-mono, monospace); + font-size: var(--fs-aux, 12px); + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index f30212d..367059c 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -219,6 +219,31 @@

回退到历史消息点

+ + +
+

记忆

+

EMRG 记住的长期信息,点击查看详情。

+
+ +
+ +
+
+
+ + + +
+

技能

+

EMRG 已加载的技能。

+
+
+ +
+
+
+ diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 0be4bb6..0f0363e 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -112,12 +112,12 @@ const App = (() => { } } - // ── / 指令(rant 19:44 P1/P2)────────────────── - /** 执行 / 指令。phase 1 纯操作 + phase 2 会话管理已实现;phase 3+ 提示阶段未开放。 */ + // ── / 指令(rant 19:44 P1/P2/P3)────────────────── + /** 执行 / 指令。phase 1 纯操作 + phase 2 会话 + phase 3 模型/记忆/技能已实现;phase 4 提示未开放。 */ async function handleCommand(parsed) { const cmd = parsed.cmd; const meta = Commands.COMMANDS[cmd]; - if (!meta || meta.phase > 2) { + if (!meta || meta.phase > 3) { const phase = meta ? `(阶段 ${meta.phase},后续版本开放)` : ""; Chat.addSystemMessage(`指令 ${cmd} 暂未开放${phase}。`); return; @@ -186,6 +186,18 @@ const App = (() => { // P2:历史消息点选择对话框 showRewindDialog(); break; + case "/model": + // P3:触发模型切换器(已有 UI) + document.querySelector(".model-switcher")?.click(); + break; + case "/memory": + // P3:记忆浏览器对话框(/memory [session|project|]) + showMemoryDialog(parsed.args[0] || ""); + break; + case "/skills": + // P3:技能列表对话框 + showSkillsDialog(); + break; default: Chat.addSystemMessage(`指令 ${cmd} 暂未开放。`); } @@ -270,6 +282,82 @@ const App = (() => { } } + // /memory:记忆浏览器对话框(daemon list_memories → 列表;read_memory → 详情) + async function showMemoryDialog(sub) { + const list = $("memory-list"); + const detail = $("memory-detail"); + const dialog = $("memory-dialog"); + if (!list || !dialog) return; + list.innerHTML = `
加载中…
`; + if (detail) detail.classList.add("hidden"); + dialog.showModal(); + const scope = String(sub || "").toLowerCase() === "session" ? "session" : "project"; + try { + const memories = await window.emrg.listMemories({ scope, sessionId: state.sessionId }); + list.innerHTML = ""; + if (!memories || memories.length === 0) { + list.innerHTML = `
还没有${scope === "session" ? "会话" : "项目"}记忆。
`; + return; + } + for (const m of memories) { + const row = el("button", { + class: "help-row", + type: "button", + style: "width:100%;text-align:left;cursor:pointer;background:none;border:none;", + }); + const title = m.title || m.id || "(未命名)"; + const name = el("span", { class: "help-cmd" }, String(title).slice(0, 40)); + const hint = el("span", { class: "help-hint" }, (m.summary || m.content || "").slice(0, 50)); + row.appendChild(name); + row.appendChild(hint); + row.addEventListener("click", async () => { + try { + const mem = await window.emrg.readMemory({ memoryId: m.id, scope, sessionId: state.sessionId }); + const body = mem.content || mem.body || ""; + if (detail) { + detail.innerHTML = `
${escapeHtml(String(title).slice(0, 80))}
${escapeHtml(body.slice(0, 2000))}
`; + detail.classList.remove("hidden"); + } else { + Chat.addSystemMessage(body.slice(0, 500)); + } + } catch (err) { + Chat.addSystemMessage(`读取记忆失败:${err.message}`); + } + }); + list.appendChild(row); + } + } catch (e) { + list.innerHTML = `
加载记忆失败:${escapeHtml(e.message)}
`; + } + } + + // /skills:技能列表对话框(main 进程读 ~/.emrg/skills + /.emrg/skills) + async function showSkillsDialog() { + const list = $("skills-list"); + const dialog = $("skills-dialog"); + if (!list || !dialog) return; + list.innerHTML = `
加载中…
`; + dialog.showModal(); + try { + const skills = await window.emrg.listSkills(); + list.innerHTML = ""; + if (!skills || skills.length === 0) { + list.innerHTML = `
还没有加载技能。
`; + return; + } + for (const s of skills) { + const row = el("div", { class: "help-row" }); + const name = el("span", { class: "help-cmd" }, s.name || "(未命名)"); + const hint = el("span", { class: "help-hint" }, `${s.source || ""}${s.description ? " · " + s.description.slice(0, 50) : ""}`); + row.appendChild(name); + row.appendChild(hint); + list.appendChild(row); + } + } catch (e) { + list.innerHTML = `
加载技能失败:${escapeHtml(e.message)}
`; + } + } + // / 补全菜单:输入以 / 开头 → 显示匹配指令;↑↓ 导航、Enter/点击选择填充 function showCmdMenu(prefix) { const items = Commands.getCompletions(prefix); @@ -725,6 +813,8 @@ const App = (() => { $("help-close").addEventListener("click", () => $("help-dialog").close()); $("sessions-close").addEventListener("click", () => $("sessions-dialog").close()); $("rewind-close").addEventListener("click", () => $("rewind-dialog").close()); + $("memory-close").addEventListener("click", () => $("memory-dialog").close()); + $("skills-close").addEventListener("click", () => $("skills-dialog").close()); // 设置/首启对话框:Enter 提交(与重命名/模型表单一致的交互) const enterToSave = (fn) => (e) => { diff --git a/emrg/gui/renderer/js/commands.js b/emrg/gui/renderer/js/commands.js index 33a77f8..ac53629 100644 --- a/emrg/gui/renderer/js/commands.js +++ b/emrg/gui/renderer/js/commands.js @@ -4,9 +4,9 @@ * 对齐 TUI 15 个 / 指令。指令解析在 GUI 侧(renderer),daemon 零改动(协议已存在)。 * * 阶段(分期): - * phase 1 — 纯操作类(/clear /compact /version /help /image 提示)→ 本轮实现 - * phase 2 — 会话类(/delete /rename /resume /rewind /sessions)→ P2 - * phase 3 — 模型/记忆/技能类(/model /memory /skills)→ P3 + * phase 1 — 纯操作类(/clear /compact /version /help /image 提示)→ 已实现 + * phase 2 — 会话类(/delete /rename /resume /rewind /sessions)→ 已实现 + * phase 3 — 模型/记忆/技能类(/model /memory /skills)→ P3 已实现 * phase 4 — 演化类(/rant /trigger)→ P4 */ diff --git a/emrg/gui/test/app-commands.test.js b/emrg/gui/test/app-commands.test.js index dcbcb8a..9a5f76a 100644 --- a/emrg/gui/test/app-commands.test.js +++ b/emrg/gui/test/app-commands.test.js @@ -135,11 +135,50 @@ test("P2:/rename 复用现有重命名对话框(Dialogs.showRename 被调用 assert.strictEqual(els["rename-input"].value, "旧标题", "重命名输入框预填当前标题"); }); -test("P2:phase 3+ 指令(/model)仍提示未开放", async () => { +test("P3:/model 触发模型切换器(点击 .model-switcher)", async () => { + const { ctx, els } = makeSandbox(); + await tick(); + // 沙箱 querySelector 返回 null → /model 应优雅跳过(不抛异常、不阻断) + await vm.runInContext("App.handleCommand({ type: 'command', cmd: '/model', args: [] })", ctx); +}); + +test("P3:/memory 打开记忆浏览器并调用 listMemories(project 默认)", async () => { + const { ctx, els } = makeSandbox({ + listMemories: async ({ scope }) => { + return [{ id: "m1", title: "记忆一", content: "内容一" }]; + }, + }); + await tick(); + await vm.runInContext("App.handleCommand({ type: 'command', cmd: '/memory', args: [] })", ctx); + assert.ok(els["memory-dialog"] && els["memory-dialog"].__open === true, "memory dialog opened"); +}); + +test("P3:/memory session 传 scope=session;/memory 可点击读详情", async () => { + const { ctx, els } = makeSandbox({ + listMemories: async ({ scope }) => { + return [{ id: "m1", title: "记忆一", content: "内容一" }]; + }, + readMemory: async ({ memoryId }) => ({ id: memoryId, content: "详情正文" }), + }); + await tick(); + await vm.runInContext("App.handleCommand({ type: 'command', cmd: '/memory', args: ['session'] })", ctx); + assert.ok(els["memory-dialog"].__open === true, "memory dialog opened for session scope"); +}); + +test("P3:/skills 打开技能列表对话框并调用 listSkills", async () => { + const { ctx, els } = makeSandbox({ + listSkills: async () => [{ name: "browser-harness", description: "web automation", source: "user" }], + }); + await tick(); + await vm.runInContext("App.handleCommand({ type: 'command', cmd: '/skills', args: [] })", ctx); + assert.ok(els["skills-dialog"] && els["skills-dialog"].__open === true, "skills dialog opened"); +}); + +test("P3:phase 4 指令(/rant)仍提示未开放", async () => { const { ctx, win } = makeSandbox(); const out = await vm.runInContext( - "(async () => { let m = ''; const orig = EMRG_Chat.addSystemMessage; EMRG_Chat.addSystemMessage = (x) => { m = x; }; await App.handleCommand({ type: 'command', cmd: '/model', args: [] }); EMRG_Chat.addSystemMessage = orig; return m; })()", + "(async () => { let m = ''; const orig = EMRG_Chat.addSystemMessage; EMRG_Chat.addSystemMessage = (x) => { m = x; }; await App.handleCommand({ type: 'command', cmd: '/rant', args: [] }); EMRG_Chat.addSystemMessage = orig; return m; })()", ctx ); - assert.ok(String(out).includes("暂未开放"), `phase 3 应提示未开放,实际: ${out}`); + assert.ok(String(out).includes("暂未开放"), `phase 4 应提示未开放,实际: ${out}`); }); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 47c8b45..f56903e 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -73,6 +73,8 @@ const ELEMENT_IDS = [ "cmd-menu", "help-dialog", "help-list", "help-close", "sessions-dialog", "sessions-list", "sessions-close", "rewind-dialog", "rewind-list", "rewind-close", + "memory-dialog", "memory-list", "memory-detail", "memory-close", + "skills-dialog", "skills-list", "skills-close", ]; /** 构造浏览器沙箱(win 即全局对象) */ diff --git a/emrg/server/git_utils.py b/emrg/server/git_utils.py index 3b2b990..012aec1 100644 --- a/emrg/server/git_utils.py +++ b/emrg/server/git_utils.py @@ -55,12 +55,17 @@ def _cached_tool_path(tool: str) -> str | None: def _cache_tool_paths(git: str, gh: str) -> None: - """Persist resolved tool paths so later lookups are O(1).""" + """Persist resolved tool paths so later lookups are O(1). + + Also persists the EMRG repo URL (``repo``) so the evolution workspace + self-heal (rant 2026-08-06T20:42:05) can clone on demand without + hardcoding — packaged installs have no git remote to detect. + """ try: data = {} if INSTALL_INFO.exists(): data = json.loads(INSTALL_INFO.read_text(encoding="utf-8")) - data.update({"git_path": git, "gh_path": gh}) + data.update({"git_path": git, "gh_path": gh, "repo": "https://github.com/argszero/emrg.git"}) INSTALL_INFO.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 994f362..51c96c7 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -75,3 +75,41 @@ def test_detect_nonexistent_dir(): """Returns empty string for a directory that doesn't exist.""" result = _detect_git_remote("/nonexistent/path/xyz/test") assert result == "" + + +# ── _cache_tool_paths (repo field, rant 2026-08-06T20:42:05) ────── + + +def test_cache_tool_paths_writes_repo_field(tmp_path, monkeypatch): + """_cache_tool_paths persists git/gh paths AND the EMRG repo URL.""" + from emrg.server import git_utils as mod + import json as _json + + info = tmp_path / "install-info.json" + monkeypatch.setattr(mod, "INSTALL_INFO", info) + + mod._cache_tool_paths("/usr/bin/git", "/usr/bin/gh") + + data = _json.loads(info.read_text(encoding="utf-8")) + assert data["git_path"] == "/usr/bin/git" + assert data["gh_path"] == "/usr/bin/gh" + assert data["repo"] == "https://github.com/argszero/emrg.git" + + +def test_cache_tool_paths_preserves_existing_fields(tmp_path, monkeypatch): + """Existing fields in install-info.json are preserved on rewrite.""" + from emrg.server import git_utils as mod + import json as _json + + info = tmp_path / "install-info.json" + info.write_text( + _json.dumps({"git_path": "/old/git", "custom": 1}), encoding="utf-8" + ) + monkeypatch.setattr(mod, "INSTALL_INFO", info) + + mod._cache_tool_paths("/usr/bin/git", "/usr/bin/gh") + + data = _json.loads(info.read_text(encoding="utf-8")) + assert data["git_path"] == "/usr/bin/git" + assert data["custom"] == 1 # preserved + assert data["repo"] == "https://github.com/argszero/emrg.git"