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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|---|
Expand Down
37 changes: 32 additions & 5 deletions electron/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -142,6 +142,13 @@ class Agent {
const path = require("node:path");
const files = ["AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", ".omniwork.md", ".cursorrules"];
let mem = "";
// Project instructions the user set in the project page — first, they lead.
if (this.memory && this.memory.instructionsFile) {
try {
const ins = fs.readFileSync(this.memory.instructionsFile, "utf8").trim();
if (ins) mem += `\n\n## Project instructions (set by the user — follow these)\n${ins.slice(0, 8000)}`;
} catch {}
}
for (const f of files) {
try {
const p = path.join(this.workspace, f);
Expand All @@ -152,6 +159,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);
Expand Down Expand Up @@ -181,19 +189,26 @@ 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; }
// Aborting must also kill in-flight network work — a flag alone leaves the
// user waiting out a slow or hung stream before Esc "takes".
abort() {
this.aborted = true;
try { this.abortCtl?.abort(); } catch {}
}
setWorkspace(dir) { this.workspace = dir; }

// One-off model call outside the session's message history (summaries,
// titles, memory capture).
async oneShot(prompt) {
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
signal: AbortSignal.timeout(60_000),
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
body: JSON.stringify({ model: this.model, messages: [{ role: "user", content: prompt }], temperature: 0.2 }),
});
Expand Down Expand Up @@ -233,6 +248,7 @@ class Agent {
async #requestModel(stream) {
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
signal: this.abortCtl?.signal,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
body: JSON.stringify({
model: this.model,
Expand Down Expand Up @@ -275,6 +291,7 @@ class Agent {
let usage = null;
const toolCalls = [];
while (true) {
if (this.aborted) { try { reader.cancel(); } catch {} break; }
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
Expand Down Expand Up @@ -356,6 +373,7 @@ class Agent {

async send(userText, images) {
this.aborted = false;
this.abortCtl = new AbortController();
this.turnUndo = new Map();
this.undoAvailable = false;
this.turnStats = { startedAt: Date.now(), inTokens: 0, outTokens: 0, estimated: false };
Expand Down Expand Up @@ -387,7 +405,14 @@ class Agent {

let msg;
try { msg = await this.callModel(); }
catch (err) { this.emit("error", { message: err.message }); return; }
catch (err) {
if (this.aborted) { this.emit("aborted", this.#statsPayload()); return; }
const friendly = /fetch failed|ECONNREFUSED/i.test(err.message)
? "Lost connection to the engine — it restarts itself within ~20s. Try again in a moment."
: err.message;
this.emit("error", { message: friendly });
return;
}

if (msg.content && msg.content.trim()) {
this.lastText = msg.content;
Expand Down Expand Up @@ -448,6 +473,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 || ""); }
Expand Down
87 changes: 84 additions & 3 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { SessionManager } = require("./sessions");
const { MCPManager } = require("./mcp");
const { ProjectManager } = require("./projects");
const { BrowserManager } = require("./browser");
const { Scheduler } = require("./scheduler");

// Do this before anything spawns a child: a Finder/Dock launch hands us a bare
// PATH, which would break `npx` MCP servers and the agent's run_command.
Expand All @@ -21,6 +22,7 @@ let sessions = null;
let mcp = null;
let projects = null;
let browser = null;
let scheduler = null;

const state = { model: "auto", approval: "auto", lastWorkspace: null, gateway: { state: "boot" } };

Expand Down Expand Up @@ -101,6 +103,8 @@ async function boot() {
// Bring up MCP servers in the background; refresh connection list when ready.
mcp.startAll().then(() => send("mcp:list", { servers: mcp.list() })).catch(() => {});
installDefaultSkills();
scheduler = new Scheduler({ projects, sessions });
scheduler.start();
}).catch((e) => send("gateway:status", { state: "error", detail: e.message }));
}

Expand Down Expand Up @@ -131,10 +135,14 @@ 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:transcript", (_e, id) => {
if (!sessions) return { events: [], pendingApproval: null };
const s = sessions.sessions.get(id);
return { events: sessions.transcript(id), pendingApproval: (s && s.pendingApproval) || null };
});
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; });
Expand Down Expand Up @@ -197,6 +205,79 @@ 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 + project page ──────────
function memoryDirFor(scope, projectId) {
if (scope === "global" || !projects) return path.join(app.getPath("userData"), "memory");
if (projectId && projects.byId(projectId)) return projects.memoryDir(projectId);
const s = sessions && sessions.sessions.get(sessions.activeId);
return s ? projects.memoryDir(s.projectId) : path.join(app.getPath("userData"), "memory");
}
ipcMain.handle("memory:read", (_e, arg) => {
const { scope, projectId } = typeof arg === "object" && arg ? arg : { scope: arg };
const { memoryFile } = require("./memory");
const f = memoryFile(memoryDirFor(scope, projectId));
try { return { content: fs.readFileSync(f, "utf8"), path: f }; }
catch { return { content: "", path: f }; }
});
ipcMain.handle("memory:write", (_e, { scope, content, projectId }) => {
const { memoryFile } = require("./memory");
const dir = memoryDirFor(scope, projectId);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(memoryFile(dir), String(content ?? ""), "utf8");
return true;
});

// The project page: everything about one project in a single fetch.
ipcMain.handle("project:info", (_e, id) => {
const p = projects && projects.byId(id);
if (!p) return null;
const { listKnowledge } = require("./memory");
return {
project: p,
instructions: projects.readInstructions(id),
memory: (() => { try { return fs.readFileSync(require("./memory").memoryFile(projects.memoryDir(id)), "utf8"); } catch { return ""; } })(),
knowledge: listKnowledge(projects.knowledgeDir(id)),
schedule: scheduler ? scheduler.list(id) : [],
};
});
ipcMain.handle("project:update", (_e, { id, name, description }) => {
if (!projects) return null;
const p = projects.describe(id, { name, description });
if (p && sessions) sessions.renameProject(id, p.name); // broadcast the rail
return p;
});
ipcMain.handle("project:instructions", (_e, { id, content }) => {
if (projects) projects.writeInstructions(id, content);
return true;
});
ipcMain.handle("schedule:add", (_e, { id, prompt, every }) => {
try { return scheduler ? scheduler.add(id, { prompt, every }) : { error: "starting" }; }
catch (e) { return { error: e.message }; }
});
ipcMain.handle("schedule:remove", (_e, { id, taskId }) => { if (scheduler) scheduler.remove(id, taskId); return true; });
function knowledgeDirFor(projectId) {
if (!projects) return null;
if (projectId && projects.byId(projectId)) return projects.knowledgeDir(projectId);
const s = sessions && sessions.sessions.get(sessions.activeId);
return s ? projects.knowledgeDir(s.projectId) : null;
}
ipcMain.handle("knowledge:open", (_e, projectId) => {
const dir = knowledgeDirFor(projectId);
if (dir) shell.openPath(dir);
return Boolean(dir);
});
ipcMain.handle("knowledge:import", async (_e, projectId) => {
const dir = knowledgeDirFor(projectId);
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;
Expand Down
49 changes: 48 additions & 1 deletion electron/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 };
11 changes: 10 additions & 1 deletion electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@ 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),
setWorkspacePath: (id, p) => ipcRenderer.invoke("session:setWorkspacePath", { id, path: p }),
revealFolder: (p) => ipcRenderer.invoke("app:revealFolder", p),
newProject: () => ipcRenderer.invoke("project:new"),
openMemory: (scope) => ipcRenderer.invoke("memory:open", scope),
readMemory: (scope, projectId) => ipcRenderer.invoke("memory:read", { scope, projectId }),
writeMemory: (scope, content, projectId) => ipcRenderer.invoke("memory:write", { scope, content, projectId }),
projectInfo: (id) => ipcRenderer.invoke("project:info", id),
updateProject: (id, fields) => ipcRenderer.invoke("project:update", { id, ...fields }),
writeInstructions: (id, content) => ipcRenderer.invoke("project:instructions", { id, content }),
addSchedule: (id, prompt, every) => ipcRenderer.invoke("schedule:add", { id, prompt, every }),
removeSchedule: (id, taskId) => ipcRenderer.invoke("schedule:remove", { id, taskId }),
openKnowledge: (projectId) => ipcRenderer.invoke("knowledge:open", projectId),
importKnowledge: (projectId) => ipcRenderer.invoke("knowledge:import", projectId),
listSkills: () => ipcRenderer.invoke("skills:list"),
installSkills: (src) => ipcRenderer.invoke("skills:install", src),
newSkill: (name) => ipcRenderer.invoke("skills:new", name),
Expand Down
32 changes: 32 additions & 0 deletions electron/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,30 @@ class ProjectManager {
}

byId(id) { return this.projects.get(id) || null; }

describe(id, { name, description } = {}) {
const p = this.projects.get(id);
if (!p) return null;
if (name && String(name).trim()) p.name = String(name).trim().slice(0, 60);
if (description !== undefined) p.description = String(description).slice(0, 500);
this.save();
return p;
}

// Per-project instructions (like Claude's project instructions): injected
// into every session's system prompt, editable from the project page.
instructionsFile(id) {
fs.mkdirSync(path.join(this.rootDir, id), { recursive: true });
return path.join(this.rootDir, id, "INSTRUCTIONS.md");
}

readInstructions(id) {
try { return fs.readFileSync(this.instructionsFile(id), "utf8"); } catch { return ""; }
}

writeInstructions(id, content) {
fs.writeFileSync(this.instructionsFile(id), String(content ?? ""), "utf8");
}
list() { return [...this.projects.values()].sort((a, b) => (b.lastOpened || 0) - (a.lastOpened || 0)); }

touch(id) { const p = this.projects.get(id); if (p) { p.lastOpened = Date.now(); this.save(); } }
Expand All @@ -70,6 +94,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) {
Expand Down
Loading
Loading