Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> — 读取单条记忆(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)与 <projectDir>/.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 };
Expand Down
3 changes: 3 additions & 0 deletions emrg/gui/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
18 changes: 18 additions & 0 deletions emrg/gui/renderer/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
25 changes: 25 additions & 0 deletions emrg/gui/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,31 @@ <h2>回退到历史消息点</h2>
</div>
</dialog>

<!-- 记忆浏览器对话框(/memory,GUI / 指令 P3) -->
<dialog id="memory-dialog">
<div class="dialog-card" style="min-width:440px;">
<h2>记忆</h2>
<p style="color:var(--text-2);font-size:var(--fs-secondary);margin:0 0 var(--sp-3);">EMRG 记住的长期信息,点击查看详情。</p>
<div id="memory-list" class="help-list"></div>
<div id="memory-detail" class="memory-detail hidden"></div>
<div class="dialog-actions">
<button type="button" id="memory-close" class="btn btn-ghost">关闭</button>
</div>
</div>
</dialog>

<!-- 技能列表对话框(/skills,GUI / 指令 P3) -->
<dialog id="skills-dialog">
<div class="dialog-card" style="min-width:440px;">
<h2>技能</h2>
<p style="color:var(--text-2);font-size:var(--fs-secondary);margin:0 0 var(--sp-3);">EMRG 已加载的技能。</p>
<div id="skills-list" class="help-list"></div>
<div class="dialog-actions">
<button type="button" id="skills-close" class="btn btn-ghost">关闭</button>
</div>
</div>
</dialog>

<script src="js/utils.js"></script>
<script src="js/commands.js"></script>
<script src="js/markdown.js"></script>
Expand Down
96 changes: 93 additions & 3 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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|<id>])
showMemoryDialog(parsed.args[0] || "");
break;
case "/skills":
// P3:技能列表对话框
showSkillsDialog();
break;
default:
Chat.addSystemMessage(`指令 ${cmd} 暂未开放。`);
}
Expand Down Expand Up @@ -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 = `<div class="help-row"><span class="help-hint">加载中…</span></div>`;
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 = `<div class="help-row"><span class="help-hint">还没有${scope === "session" ? "会话" : "项目"}记忆。</span></div>`;
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 = `<div class="memory-detail-title">${escapeHtml(String(title).slice(0, 80))}</div><pre class="memory-detail-body">${escapeHtml(body.slice(0, 2000))}</pre>`;
detail.classList.remove("hidden");
} else {
Chat.addSystemMessage(body.slice(0, 500));
}
} catch (err) {
Chat.addSystemMessage(`读取记忆失败:${err.message}`);
}
});
list.appendChild(row);
}
} catch (e) {
list.innerHTML = `<div class="help-row"><span class="help-hint">加载记忆失败:${escapeHtml(e.message)}</span></div>`;
}
}

// /skills:技能列表对话框(main 进程读 ~/.emrg/skills + <project>/.emrg/skills)
async function showSkillsDialog() {
const list = $("skills-list");
const dialog = $("skills-dialog");
if (!list || !dialog) return;
list.innerHTML = `<div class="help-row"><span class="help-hint">加载中…</span></div>`;
dialog.showModal();
try {
const skills = await window.emrg.listSkills();
list.innerHTML = "";
if (!skills || skills.length === 0) {
list.innerHTML = `<div class="help-row"><span class="help-hint">还没有加载技能。</span></div>`;
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 = `<div class="help-row"><span class="help-hint">加载技能失败:${escapeHtml(e.message)}</span></div>`;
}
}

// / 补全菜单:输入以 / 开头 → 显示匹配指令;↑↓ 导航、Enter/点击选择填充
function showCmdMenu(prefix) {
const items = Commands.getCompletions(prefix);
Expand Down Expand Up @@ -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) => {
Expand Down
6 changes: 3 additions & 3 deletions emrg/gui/renderer/js/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

Expand Down
45 changes: 42 additions & 3 deletions emrg/gui/test/app-commands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> 可点击读详情", 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}`);
});
2 changes: 2 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 即全局对象) */
Expand Down
9 changes: 7 additions & 2 deletions emrg/server/git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
38 changes: 38 additions & 0 deletions tests/test_git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading