From e1bde5a64717f2414515a69b909040690ab6c3e7 Mon Sep 17 00:00:00 2001 From: inquerium Date: Mon, 27 Jul 2026 15:10:37 -0500 Subject: [PATCH 1/5] feat: Homebrew cask (self-hosted tap, no signing required) packaging/homebrew-tap/ is the canonical tap content: a brew-style-clean cask for the v0.10.0 arm64 DMG (sha256-pinned, livecheck on GitHub releases, zap for app data) plus maintainer docs for per-release bumps and first-time publishing. Verified end-to-end locally: installed through a local tap, checksum validated by brew, app landed in /Applications. README gains the brew install as the primary macOS path, with --no-quarantine documented for the unsigned app. Co-Authored-By: Claude Fable 5 --- README.md | 11 +++++++- packaging/homebrew-tap/Casks/omniwork.rb | 33 +++++++++++++++++++++++ packaging/homebrew-tap/README.md | 34 ++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 packaging/homebrew-tap/Casks/omniwork.rb create mode 100644 packaging/homebrew-tap/README.md diff --git a/README.md b/README.md index a7f1dba..af401cb 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,16 @@ delegate** *inside* Claude Code or Codex. ## Download -From the [**latest release**](https://github.com/VedSoni-dev/omniwork/releases/latest): +**macOS (Apple Silicon) — Homebrew:** + +```sh +brew install --cask --no-quarantine VedSoni-dev/tap/omniwork +``` + +(`--no-quarantine` skips the Gatekeeper prompt for the unsigned app; omit it if you'd +rather right-click → Open once.) + +Or grab an installer from the [**latest release**](https://github.com/VedSoni-dev/omniwork/releases/latest): | Platform | File | Size | Notes | |---|---|---|---| diff --git a/packaging/homebrew-tap/Casks/omniwork.rb b/packaging/homebrew-tap/Casks/omniwork.rb new file mode 100644 index 0000000..c8fda38 --- /dev/null +++ b/packaging/homebrew-tap/Casks/omniwork.rb @@ -0,0 +1,33 @@ +cask "omniwork" do + version "0.10.0" + sha256 "20671ad1a3f03fe74591680b57e7304d1d87e1213ff1e0ab6510b9e83a26e0cb" + + url "https://github.com/VedSoni-dev/omniwork/releases/download/v#{version}/OmniWork-#{version}-arm64.dmg" + name "OmniWork" + desc "Free, local, Claude Code-style desktop coding agent" + homepage "https://github.com/VedSoni-dev/omniwork" + + livecheck do + url :url + strategy :github_latest + end + + depends_on arch: :arm64 + depends_on macos: :monterey + + app "OmniWork.app" + + zap trash: [ + "~/Library/Application Support/omniwork", + "~/Library/Preferences/com.omniwork.app.plist", + "~/Library/Saved Application State/com.omniwork.app.savedState", + ] + + caveats <<~EOS + OmniWork is not signed with an Apple Developer certificate yet, so macOS + will block the first launch unless you either: + + installed with: brew install --cask --no-quarantine VedSoni-dev/tap/omniwork + or, once: right-click OmniWork.app in /Applications → Open + EOS +end diff --git a/packaging/homebrew-tap/README.md b/packaging/homebrew-tap/README.md new file mode 100644 index 0000000..b2673e2 --- /dev/null +++ b/packaging/homebrew-tap/README.md @@ -0,0 +1,34 @@ +# OmniWork Homebrew tap + +Install [OmniWork](https://github.com/VedSoni-dev/omniwork) — the free, local, +Claude Code-style coding agent: + +```sh +brew install --cask --no-quarantine VedSoni-dev/tap/omniwork +``` + +`--no-quarantine` skips the Gatekeeper block on the (unsigned) app. Without it, +right-click OmniWork.app → Open once after installing. + +--- + +## Maintaining (in the omniwork repo) + +This directory is the canonical source; the published tap lives at +`VedSoni-dev/homebrew-tap` and mirrors it. On each release: + +```sh +# 1. compute the new artifact's checksum +curl -sL "https://github.com/VedSoni-dev/omniwork/releases/download/v/OmniWork--arm64.dmg" | shasum -a 256 + +# 2. bump `version` and `sha256` in Casks/omniwork.rb (here), commit + +# 3. mirror to the tap repo +git clone git@github.com:VedSoni-dev/homebrew-tap.git /tmp/tap +cp -R packaging/homebrew-tap/* /tmp/tap/ +cd /tmp/tap && git add -A && git commit -m "omniwork " && git push +``` + +Publishing for the first time: create a **public repo named exactly +`homebrew-tap`** under the org/user, then run step 3. Nothing else — Homebrew +resolves `VedSoni-dev/tap` to that repo automatically. From 3f0c5f3f3fcd4ee590c24f880b7d786b0c7a5310 Mon Sep 17 00:00:00 2001 From: inquerium Date: Mon, 27 Jul 2026 15:24:44 -0500 Subject: [PATCH 2/5] feat: chat attachments, project knowledge, and an in-app memory editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attachments: a paperclip button and window-wide drag & drop join image paste. Images go to vision; text files ride along as fenced context blocks for the model while the transcript shows only "prompt 📎 name" (new label channel through session:send). Size/count caps, binary detection, and a pointer to project knowledge for oversized files. Project knowledge, like Claude's: projects//knowledge/ holds user-uploaded reference files. The system prompt lists names+sizes only; the agent reads bodies on demand via a read_knowledge tool (path-escape safe, binary-refusing, truncating). Add files via the folder-chip menu (native multi-select) or drop them in the folder (/knowledge opens it). Memory editor: /memory now opens an in-app editor over the real MEMORY.md files — project and global tabs, save, reveal in Finder — so users can curate what the agent remembers without leaving the app. Verified: 8 new knowledge assertions in test:projects; scripted renderer test round-trips the memory editor and confirms the attach affordances. Co-Authored-By: Claude Fable 5 --- electron/agent.js | 10 +++++-- electron/main.js | 45 +++++++++++++++++++++++++++-- electron/memory.js | 49 +++++++++++++++++++++++++++++++- electron/preload.js | 6 +++- electron/projects.js | 8 ++++++ electron/sessions.js | 14 +++++---- renderer/app.js | 67 ++++++++++++++++++++++++++++++++++++++++++-- renderer/index.html | 20 +++++++++++++ renderer/styles.css | 7 +++++ test/projects.js | 16 ++++++++++- 10 files changed, 225 insertions(+), 17 deletions(-) diff --git a/electron/agent.js b/electron/agent.js index c36d367..7e1ef28 100644 --- a/electron/agent.js +++ b/electron/agent.js @@ -7,7 +7,7 @@ // with its own context/tool-loop, and report a summary back to the parent. const { TOOL_SCHEMA, executeTool } = require("./tools"); -const { MEMORY_TOOL, saveMemory, loadForPrompt } = require("./memory"); +const { MEMORY_TOOL, saveMemory, loadForPrompt, KNOWLEDGE_TOOL, knowledgeSection, readKnowledge } = require("./memory"); const { DEFAULT_CONTEXT, estimateTokens, shouldCompact, compact } = require("./compactor"); const skills = require("./skills"); @@ -97,7 +97,7 @@ class Agent { this.workspace = workspace; this.emit = emit; // (event, payload) => void this.mcp = mcp || null; - this.memory = memory; // { globalDir, projectDir } | null + this.memory = memory; // { globalDir, projectDir, knowledgeDir? } | null this.skillsDir = skillsDir || null; // global skills root this.browser = browser || null; // BrowserManager this.contextTokens = DEFAULT_CONTEXT; @@ -152,6 +152,7 @@ class Agent { if (this.memory) { const saved = loadForPrompt(this.memory.globalDir, this.memory.projectDir); if (saved) mem += `\n\n${saved}`; + if (this.memory.knowledgeDir) mem += knowledgeSection(this.memory.knowledgeDir); } // Skills: names + descriptions only; bodies load via use_skill. if (this.skillsDir) mem += skills.promptSection(this.skillsDir, this.workspace); @@ -181,9 +182,10 @@ class Agent { const extra = this.mcp ? this.mcp.toolSchema() : []; const sub = this.canSpawn ? [SUBAGENT_TOOL] : []; const mem = this.memory ? [MEMORY_TOOL] : []; + const kn = this.memory && this.memory.knowledgeDir ? [KNOWLEDGE_TOOL] : []; const sk = this.skillsDir ? [skills.USE_SKILL_TOOL, skills.SAVE_SKILL_TOOL, skills.INSTALL_SKILLS_TOOL] : []; const web = this.browser ? [require("./browser").WEB_SEARCH_TOOL, require("./browser").BROWSE_TOOL] : []; - return [...TOOL_SCHEMA, ...sub, ...mem, ...sk, ...web, ...extra]; + return [...TOOL_SCHEMA, ...sub, ...mem, ...kn, ...sk, ...web, ...extra]; } abort() { this.aborted = true; } @@ -448,6 +450,8 @@ class Agent { const made = skills.createSkill(this.skillsDir, parsedArgs.name, parsedArgs.description, parsedArgs.instructions); result = `Saved skill "${made.name}" — available in every project via /` + made.name; } catch (e) { result = "Failed to save skill: " + e.message; } + } else if (name === "read_knowledge" && this.memory && this.memory.knowledgeDir) { + result = readKnowledge(this.memory.knowledgeDir, parsedArgs.name || ""); } else if (name === "save_memory" && this.memory) { const dir = parsedArgs.scope === "global" ? this.memory.globalDir : this.memory.projectDir; try { result = saveMemory(dir, parsedArgs.title || "note", parsedArgs.content || ""); } diff --git a/electron/main.js b/electron/main.js index 18067d1..807c134 100644 --- a/electron/main.js +++ b/electron/main.js @@ -132,9 +132,9 @@ ipcMain.handle("session:create", (_e, opts) => { ipcMain.handle("session:list", () => (sessions ? { sessions: sessions.list(), activeId: sessions.activeId } : { sessions: [], activeId: null })); ipcMain.handle("session:setActive", (_e, id) => { if (sessions) sessions.setActive(id); return true; }); ipcMain.handle("session:transcript", (_e, id) => (sessions ? sessions.transcript(id) : [])); -ipcMain.handle("session:send", async (_e, { id, text, images }) => { +ipcMain.handle("session:send", async (_e, { id, text, images, label }) => { if (!sessions) { send("session:event", { sessionId: id, type: "error", message: "Engine still starting." }); return false; } - await sessions.send(id, text, images); + await sessions.send(id, text, images, label); return true; }); ipcMain.handle("session:stop", (_e, id) => { if (sessions) sessions.stop(id); return true; }); @@ -197,6 +197,47 @@ ipcMain.handle("skills:new", (_e, name) => { }); ipcMain.handle("skills:open", () => { fs.mkdirSync(skillsDir(), { recursive: true }); shell.openPath(skillsDir()); return true; }); +// ── IPC: memory editing + project knowledge ───────────────────────── +function memoryDirFor(scope) { + const s = sessions && sessions.sessions.get(sessions.activeId); + return scope === "global" || !s || !projects + ? path.join(app.getPath("userData"), "memory") + : projects.memoryDir(s.projectId); +} +ipcMain.handle("memory:read", (_e, scope) => { + const { memoryFile } = require("./memory"); + const f = memoryFile(memoryDirFor(scope)); + try { return { content: fs.readFileSync(f, "utf8"), path: f }; } + catch { return { content: "", path: f }; } +}); +ipcMain.handle("memory:write", (_e, { scope, content }) => { + const { memoryFile } = require("./memory"); + const dir = memoryDirFor(scope); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(memoryFile(dir), String(content ?? ""), "utf8"); + return true; +}); +function knowledgeDirForActive() { + const s = sessions && sessions.sessions.get(sessions.activeId); + return s && projects ? projects.knowledgeDir(s.projectId) : null; +} +ipcMain.handle("knowledge:open", () => { + const dir = knowledgeDirForActive(); + if (dir) shell.openPath(dir); + return Boolean(dir); +}); +ipcMain.handle("knowledge:import", async () => { + const dir = knowledgeDirForActive(); + if (!dir) return { error: "no active project" }; + const r = await dialog.showOpenDialog(win, { properties: ["openFile", "multiSelections"], title: "Add files to project knowledge" }); + if (r.canceled || !r.filePaths.length) return { added: [] }; + const added = []; + for (const p of r.filePaths) { + try { fs.copyFileSync(p, path.join(dir, path.basename(p))); added.push(path.basename(p)); } catch {} + } + return { added }; +}); + // Open a project's memory file (or the global one) in the OS file manager/editor. ipcMain.handle("memory:open", (_e, scope) => { if (!projects) return false; diff --git a/electron/memory.js b/electron/memory.js index c350f47..539c7dd 100644 --- a/electron/memory.js +++ b/electron/memory.js @@ -29,6 +29,53 @@ const MEMORY_TOOL = { function memoryFile(dir) { return path.join(dir, "MEMORY.md"); } +// ── project knowledge ──────────────────────────────────────────────── +// Files the user adds to a project for the agent to consult. The prompt +// carries only the file list; bodies load through read_knowledge. + +const MAX_KNOWLEDGE_READ = 24_000; // chars per file read + +const KNOWLEDGE_TOOL = { + type: "function", + function: { + name: "read_knowledge", + description: "Read a file from this project's knowledge folder (documents the user uploaded as reference context). Call when a listed knowledge file is relevant to the task.", + parameters: { + type: "object", + properties: { name: { type: "string", description: "File name from the project-knowledge list." } }, + required: ["name"], + }, + }, +}; + +function listKnowledge(dir) { + try { + return fs.readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isFile() && !e.name.startsWith(".")) + .map((e) => ({ name: e.name, size: fs.statSync(path.join(dir, e.name)).size })); + } catch { return []; } +} + +function knowledgeSection(dir) { + const files = dir ? listKnowledge(dir) : []; + if (!files.length) return ""; + const lines = files.map((f) => `- ${f.name} (${f.size > 1024 ? Math.round(f.size / 1024) + " KB" : f.size + " B"})`); + return `\n\n## Project knowledge (user-uploaded reference files)\nRead one with read_knowledge when relevant:\n${lines.join("\n")}`; +} + +function readKnowledge(dir, name) { + const base = path.basename(String(name || "")); // no path escapes + const file = path.join(dir, base); + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { + const have = listKnowledge(dir).map((f) => f.name).join(", "); + return `No knowledge file named "${base}". Available: ${have || "none"}.`; + } + const buf = fs.readFileSync(file); + if (buf.includes(0)) return `"${base}" is a binary file (${buf.length} bytes) — cannot read as text.`; + const text = buf.toString("utf8"); + return text.length > MAX_KNOWLEDGE_READ ? text.slice(0, MAX_KNOWLEDGE_READ) + `\n…(truncated, ${text.length} chars total)` : text; +} + function saveMemory(dir, title, content) { fs.mkdirSync(dir, { recursive: true }); const file = memoryFile(dir); @@ -53,4 +100,4 @@ function loadForPrompt(globalDir, projectDir) { return out.trim(); } -module.exports = { MEMORY_TOOL, saveMemory, loadForPrompt, memoryFile }; +module.exports = { MEMORY_TOOL, saveMemory, loadForPrompt, memoryFile, KNOWLEDGE_TOOL, listKnowledge, knowledgeSection, readKnowledge }; diff --git a/electron/preload.js b/electron/preload.js index 2cca4ba..79d17f3 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -9,7 +9,7 @@ contextBridge.exposeInMainWorld("omniwork", { listSessions: () => ipcRenderer.invoke("session:list"), setActiveSession: (id) => ipcRenderer.invoke("session:setActive", id), getTranscript: (id) => ipcRenderer.invoke("session:transcript", id), - sendMessage: (id, text, images) => ipcRenderer.invoke("session:send", { id, text, images }), + sendMessage: (id, text, images, label) => ipcRenderer.invoke("session:send", { id, text, images, label }), stopSession: (id) => ipcRenderer.invoke("session:stop", id), removeSession: (id) => ipcRenderer.invoke("session:remove", id), pickWorkspace: (id) => ipcRenderer.invoke("session:pickWorkspace", id), @@ -17,6 +17,10 @@ contextBridge.exposeInMainWorld("omniwork", { revealFolder: (p) => ipcRenderer.invoke("app:revealFolder", p), newProject: () => ipcRenderer.invoke("project:new"), openMemory: (scope) => ipcRenderer.invoke("memory:open", scope), + readMemory: (scope) => ipcRenderer.invoke("memory:read", scope), + writeMemory: (scope, content) => ipcRenderer.invoke("memory:write", { scope, content }), + openKnowledge: () => ipcRenderer.invoke("knowledge:open"), + importKnowledge: () => ipcRenderer.invoke("knowledge:import"), listSkills: () => ipcRenderer.invoke("skills:list"), installSkills: (src) => ipcRenderer.invoke("skills:install", src), newSkill: (name) => ipcRenderer.invoke("skills:new", name), diff --git a/electron/projects.js b/electron/projects.js index 297da4c..8a5b2bd 100644 --- a/electron/projects.js +++ b/electron/projects.js @@ -70,6 +70,14 @@ class ProjectManager { return d; } + // Project knowledge: files the user drops in for the agent to consult + // (like Claude's project knowledge). Read on demand via read_knowledge. + knowledgeDir(id) { + const d = path.join(this.rootDir, id, "knowledge"); + fs.mkdirSync(d, { recursive: true }); + return d; + } + // One-time migration from the legacy single-file store (sessions.json). // Returns the saved sessions it found, already assigned to projects. migrateLegacy(legacyPath) { diff --git a/electron/sessions.js b/electron/sessions.js index 7eb5cd9..0a1db14 100644 --- a/electron/sessions.js +++ b/electron/sessions.js @@ -117,7 +117,7 @@ class SessionManager { model: this.model, workspace: sess.workspace, mcp: this.mcp, - memory: this.projects ? { globalDir: this.globalMemoryDir, projectDir: this.projects.memoryDir(sess.projectId) } : null, + memory: this.projects ? { globalDir: this.globalMemoryDir, projectDir: this.projects.memoryDir(sess.projectId), knowledgeDir: this.projects.knowledgeDir(sess.projectId) } : null, skillsDir: this.skillsDir, browser: this.browser, approvalMode: this.approvalMode, @@ -231,14 +231,16 @@ class SessionManager { } catch {} } - async send(id, text, images) { + // `label` is what the transcript shows (e.g. prompt + attachment names); + // `text` is the full model input, which may embed attached file contents. + async send(id, text, images, label) { const sess = this.sessions.get(id); if (!sess) return; sess.status = "running"; this.#pushList(); - const label = text + (images && images.length ? ` 📎 ${images.length} image${images.length > 1 ? "s" : ""}` : ""); - sess.transcript.push({ type: "user", content: label }); - this.emit(id, "user", { content: label }); + const shown = (label || text) + (images && images.length ? ` 📎 ${images.length} image${images.length > 1 ? "s" : ""}` : ""); + sess.transcript.push({ type: "user", content: shown }); + this.emit(id, "user", { content: shown }); try { await sess.agent.send(text, images); } catch (e) { @@ -299,7 +301,7 @@ class SessionManager { if (proj.id !== s.projectId) { try { fs.unlinkSync(this.#sessionFile(s)); } catch {} s.projectId = proj.id; - if (s.agent) s.agent.memory = { globalDir: this.globalMemoryDir, projectDir: this.projects.memoryDir(proj.id) }; + if (s.agent) s.agent.memory = { globalDir: this.globalMemoryDir, projectDir: this.projects.memoryDir(proj.id), knowledgeDir: this.projects.knowledgeDir(proj.id) }; } this.projects.touch(proj.id); } diff --git a/renderer/app.js b/renderer/app.js index f593d6c..c91a832 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3,6 +3,7 @@ const stub = { createSession: async () => ({ id: "demo", title: "Main", workspace: "", status: "idle" }), newProject: async () => null, openMemory: async () => {}, compactSession: async () => {}, listSkills: async () => [], installSkills: async () => ({ error: "unavailable" }), newSkill: async () => ({}), openSkills: async () => {}, + readMemory: async () => ({ content: "" }), writeMemory: async () => {}, openKnowledge: async () => {}, importKnowledge: async () => ({ added: [] }), listSessions: async () => ({ sessions: [], activeId: null }), setActiveSession: async () => {}, getTranscript: async () => [], sendMessage: async () => {}, stopSession: async () => {}, removeSession: async () => {}, pickWorkspace: async () => {}, @@ -31,6 +32,7 @@ let approvalMode = "auto"; let currentMcp = []; const mentions = new Set(); const pastedImages = []; // data URLs +const attachedFiles = []; // { name, content } — text files attached as context const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); const scrollDown = () => (scroll.scrollTop = scroll.scrollHeight); @@ -282,6 +284,9 @@ function folderMenu() { const items = [ { label: "Change folder…", hint: "pick any folder", onPick: () => api.pickWorkspace(activeId) }, { label: api.platform === "darwin" ? "Reveal in Finder" : "Show in file manager", hint: prettyPath(s.workspace), onPick: () => api.revealFolder(s.workspace) }, + { label: "🧠 Project memory…", hint: "view & edit what the agent remembers", onPick: () => openMemoryEditor() }, + { label: "📚 Add files to knowledge…", hint: "reference docs for every session here", onPick: async () => { const r = await api.importKnowledge(); if (r.added && r.added.length) addSystem(`📚 Added to project knowledge: ${r.added.join(", ")}`); } }, + { label: "📂 Open knowledge folder", hint: "drop files in directly", onPick: () => api.openKnowledge() }, ]; const seen = new Set([s.workspace]); for (const o of sessionsCache) { @@ -399,7 +404,38 @@ function renderChips() { const box = $("chips"); box.innerHTML = ""; mentions.forEach((p) => { const c = document.createElement("span"); c.className = "chip"; c.innerHTML = `@${esc(p)} ✕`; c.querySelector(".x").addEventListener("click", () => { mentions.delete(p); renderChips(); }); box.appendChild(c); }); pastedImages.forEach((url, i) => { const c = document.createElement("span"); c.className = "chip imgchip"; c.innerHTML = ` image ✕`; c.querySelector(".x").addEventListener("click", () => { pastedImages.splice(i, 1); renderChips(); }); box.appendChild(c); }); + attachedFiles.forEach((f, i) => { const c = document.createElement("span"); c.className = "chip"; c.innerHTML = `📄 ${esc(f.name)} ✕`; c.querySelector(".x").addEventListener("click", () => { attachedFiles.splice(i, 1); renderChips(); }); box.appendChild(c); }); } + +// ── attachments: 📎 button + drag & drop ─────────────────────────── +// Images become vision input; text files ride along as context blocks. +const MAX_ATTACH = 8, MAX_FILE_BYTES = 400_000, MAX_FILE_CHARS = 100_000; +function addAttachment(file) { + if (pastedImages.length + attachedFiles.length >= MAX_ATTACH) { addSystem(`Attachment limit is ${MAX_ATTACH} per message.`); return; } + if (file.type && file.type.startsWith("image/")) { + const r = new FileReader(); + r.onload = () => { pastedImages.push(r.result); renderChips(); }; + r.readAsDataURL(file); + return; + } + if (file.size > MAX_FILE_BYTES) { addSystem(`"${file.name}" is too large to attach (max ${Math.round(MAX_FILE_BYTES / 1000)} KB). Add it to project knowledge instead (folder menu).`); return; } + const r = new FileReader(); + r.onload = () => { + const text = String(r.result || ""); + if (text.includes("\u0000")) { addSystem(`"${file.name}" looks binary — attach images or text files.`); return; } + attachedFiles.push({ name: file.name, content: text.slice(0, MAX_FILE_CHARS) }); + renderChips(); + }; + r.readAsText(file); +} +function handleFiles(list) { for (const f of list || []) addAttachment(f); input.focus(); } +$("attach").addEventListener("click", () => $("file-input").click()); +$("file-input").addEventListener("change", (e) => { handleFiles(e.target.files); e.target.value = ""; }); +let dragDepth = 0; +window.addEventListener("dragover", (e) => e.preventDefault()); +window.addEventListener("dragenter", (e) => { e.preventDefault(); if (++dragDepth === 1 && e.dataTransfer?.types?.includes("Files")) document.body.classList.add("dropping"); }); +window.addEventListener("dragleave", () => { if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove("dropping"); } }); +window.addEventListener("drop", (e) => { e.preventDefault(); dragDepth = 0; document.body.classList.remove("dropping"); handleFiles(e.dataTransfer.files); }); // paste an image into the composer input.addEventListener("paste", (e) => { const items = (e.clipboardData && e.clipboardData.items) || []; @@ -491,10 +527,16 @@ async function send() { if ((!text && !mentions.size && !pastedImages.length) || !activeId) return; if (text.startsWith("/")) { input.value = ""; grow(); hidePop(); handleSlash(text); return; } let full = text; if (mentions.size) full = `${text}\n\nReferenced files: ${[...mentions].map((p) => "@" + p).join(", ")}`; + // Attached text files ride along for the model; the transcript shows only a label. + let label = null; + if (attachedFiles.length) { + label = text + ` 📎 ${attachedFiles.map((f) => f.name).join(", ")}`; + full += "\n\n" + attachedFiles.map((f) => `## Attached file: ${f.name}\n\`\`\`\n${f.content}\n\`\`\``).join("\n\n"); + } const images = pastedImages.slice(); const w = $("welcome"); if (w) w.remove(); - input.value = ""; mentions.clear(); pastedImages.length = 0; renderChips(); grow(); hidePop(); - await api.sendMessage(activeId, full, images); + input.value = ""; mentions.clear(); pastedImages.length = 0; attachedFiles.length = 0; renderChips(); grow(); hidePop(); + await api.sendMessage(activeId, full, images, label); } // ── slash commands ───────────────────────────────────────────────── @@ -516,7 +558,8 @@ const COMMANDS = [ { name: "clear", desc: "Start a fresh session", run: () => newSession() }, { name: "new", desc: "Start a fresh session", run: () => newSession() }, { name: "project", desc: "New project — pick a folder", run: () => newProject() }, - { name: "memory", desc: "Open this project's memory file", run: (arg) => api.openMemory(arg === "global" ? "global" : undefined) }, + { name: "memory", desc: "View & edit what the agent remembers", run: () => openMemoryEditor() }, + { name: "knowledge", desc: "Open this project's knowledge folder", run: () => api.openKnowledge() }, { name: "folder", alias: "cwd", desc: "Change the working folder", run: () => api.pickWorkspace(activeId) }, { name: "model", args: "", desc: "Switch AI model", run: (arg) => { if (arg) { $("model").value = arg; api.setModel(arg); addSystem("model → " + arg); } else addSystem("usage: /model "); } }, { name: "undo", desc: "Undo last turn's file changes", run: () => api.undo(activeId) }, @@ -577,6 +620,24 @@ function cycleApproval() { stopBtn.addEventListener("click", () => api.stopSession(activeId)); document.addEventListener("click", (e) => { const t = e.target.closest(".tip"); if (t) { input.value = t.dataset.p; grow(); input.focus(); } }); +// ── memory editor ────────────────────────────────────────────────── +let memScope = "project"; +async function loadMemoryEditor(scope) { + memScope = scope; + $("mem-tab-project").classList.toggle("on", scope === "project"); + $("mem-tab-global").classList.toggle("on", scope === "global"); + const r = await api.readMemory(scope); + $("mem-text").value = r.content || ""; +} +function openMemoryEditor() { $("memory-modal").classList.remove("hidden"); loadMemoryEditor("project"); } +async function saveMemoryEditor() { await api.writeMemory(memScope, $("mem-text").value); } +$("mem-tab-project").addEventListener("click", async () => { await saveMemoryEditor(); loadMemoryEditor("project"); }); +$("mem-tab-global").addEventListener("click", async () => { await saveMemoryEditor(); loadMemoryEditor("global"); }); +$("mem-save").addEventListener("click", async () => { await saveMemoryEditor(); $("memory-modal").classList.add("hidden"); addSystem("🧠 Memory saved."); }); +$("mem-cancel").addEventListener("click", () => $("memory-modal").classList.add("hidden")); +$("mem-reveal").addEventListener("click", () => api.openMemory(memScope === "global" ? "global" : undefined)); +$("memory-modal").addEventListener("click", (e) => { if (e.target.id === "memory-modal") $("memory-modal").classList.add("hidden"); }); + // ── new session / cwd / model / dashboard ────────────────────────── $("new-project").addEventListener("click", () => newProject()); $("folder-chip").addEventListener("click", folderMenu); diff --git a/renderer/index.html b/renderer/index.html index a107e17..bd4b99b 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -38,8 +38,10 @@
> +
+
starting… @@ -89,6 +91,24 @@
+ + +