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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

# Git worktrees. Delivery gives each unit of work its own worktree, and the
# harness puts them here — inside the repository, so an unignored `git add .`
# in the primary checkout would commit an entire second checkout.
.claude/worktrees/

# Vendored ffmpeg is fetched by scripts/fetch-ffmpeg.mjs and checked by hash.
vendor/ffmpeg/
# Rust build output (crates/)
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"package": "pnpm run build && electron-builder --win --publish never"
},
"devDependencies": {
"@langchain/core": "^1.2.4",
"@langchain/groq": "^1.3.1",
"@langchain/langgraph": "^1.4.8",
"@open-wiki/access": "workspace:*",
"@open-wiki/audio": "workspace:*",
"@types/markdown-it": "^14.1.2",
Expand All @@ -26,14 +29,17 @@
"@vitejs/plugin-react": "^4.3.4",
"chokidar": "^5.0.0",
"clsx": "^2.1.1",
"deepagents": "1.12.1",
"electron": "^38.0.0",
"electron-builder": "^25.1.8",
"eslint": "^9.20.0",
"langchain": "^1.5.4",
"lucide-react": "^0.469.0",
"markdown-it": "^14.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^6.0.0"
"vite": "^6.0.0",
"zod": "^4.4.3"
},
"build": {
"appId": "dev.protonspy.openwiki",
Expand Down
129 changes: 129 additions & 0 deletions apps/desktop/src/main/agent/agent-prefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { defaultAppDataDir, secretsFile } from "@open-wiki/access/secrets";

/**
* The agent's per-project preferences — the model list captured at
* credential-save time and the model the user picked from it
* (specs/embedded-agent, R2.5, R2.7).
*
* **A sibling to the secrets file, not part of it.** The model list is fetched
* from Groq's public `/models` endpoint and is not a secret, so it lives in a
* separate file beside the secrets file (same project-keyed directory) rather
* than inside it. Keeping it out of the secrets file is what lets `credentialState`
* stay "whether a key is stored, never what it is" without the model list
* dragging the key's file-mode along with it.
*
* Pure on purpose: no Electron, no langchain. The settings module and the agent
* both read from here, and a test reaches it without standing either up.
*/

/**
* The default model the agent runs when the saved list contains it. Falls back
* to the first model in the list when it does not (R2.5).
*
* Canonical here rather than in `agent.ts` so the settings module — which must
* not import langchain — can read the default without pulling the agent's graph
* into the credential path. `agent.ts` re-exports it to keep its public surface.
*/
export const DEFAULT_MODEL = "openai/gpt-oss-120b";

export interface AgentPrefs {
/** The Groq `/models` list captured when the credential was saved. */
models: string[];
/** The model the user picked; defaults to {@link DEFAULT_MODEL} when present. */
selectedModel: string;
}

/**
* The prefs file: the secrets file's path with `.agent.json` in place of the
* extension. Same project-keyed directory, distinct file — a sibling, not a
* tenant of the secrets file.
*/
export function agentPrefsFile(
projectRoot: string,
appDataDir: string = defaultAppDataDir(),
): string {
return secretsFile(projectRoot, appDataDir).replace(/\.json$/, ".agent.json");
}

export function readAgentPrefs(
projectRoot: string,
appDataDir: string = defaultAppDataDir(),
): AgentPrefs | undefined {
const file = agentPrefsFile(projectRoot, appDataDir);
if (!existsSync(file)) return undefined;
// A truncated write or a hand edit leaves a file that is not JSON at all, and
// `normalize` never sees it — `JSON.parse` throws first. The throw would climb
// through `agentModels` in the settings module and out into the renderer as an
// unhandled rejection, so it is answered here the same way an absent file is:
// no prefs, and the caller falls back to the default model.
let raw: unknown;
try {
raw = JSON.parse(readFileSync(file, "utf8"));
} catch {
return undefined;
}
// Valid JSON that is not an object (`null`, a number, a list) reaches
// `normalize` as something with no fields to read; coerce it to the empty
// shape rather than dereferencing it.
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return normalize({});
return normalize(raw as Partial<AgentPrefs>);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Persist the prefs. Not a secret, so no `0600` — the model list is public, and
* a restrictive mode would imply a sensitivity the file does not have.
*/
export function writeAgentPrefs(
projectRoot: string,
prefs: AgentPrefs,
appDataDir: string = defaultAppDataDir(),
): void {
const file = agentPrefsFile(projectRoot, appDataDir);
mkdirSync(join(file, ".."), { recursive: true });
writeFileSync(file, JSON.stringify(prefs, null, 2) + "\n", "utf8");
}

/**
* The model the agent should run (R2.5). The selected model wins when it is in
* the list; otherwise the default, when it is in the list; otherwise the first
* model in the list; otherwise the default alone. A prefs file written before a
* model was removed from Groq's catalogue must not silently pick a model the
* user never chose.
*/
export function resolveModel(prefs: AgentPrefs | undefined, fallback = DEFAULT_MODEL): string {
const models = prefs?.models ?? [];
if (models.includes(prefs?.selectedModel ?? "")) return prefs!.selectedModel;
if (models.includes(fallback)) return fallback;
if (models.length > 0) return models[0]!;
return fallback;
}

/** A prefs shape read off disk may be partial or malformed; coerce to valid. */
function normalize(raw: Partial<AgentPrefs>): AgentPrefs {
const models = Array.isArray(raw.models)
? raw.models.filter((m): m is string => typeof m === "string")
: [];
const selectedModel =
typeof raw.selectedModel === "string" && raw.selectedModel.length > 0
? raw.selectedModel
: resolveModel({ models, selectedModel: "" });
return { models, selectedModel };
}

/**
* Read the `/models` response body into a list of model ids. Groq's endpoint is
* OpenAI-shaped — `{ data: [{ id, ... }] }` — so the ids are read defensively:
* anything that is not the expected shape yields an empty list rather than a
* crash, and a credential that checked out with no parseable list still saves.
*/
export function parseModelList(body: unknown): string[] {
if (typeof body !== "object" || body === null) return [];
const data = (body as { data?: unknown }).data;
if (!Array.isArray(data)) return [];
return data
.filter((m): m is { id?: unknown } => typeof m === "object" && m !== null)
.map((m) => m.id)
.filter((id): id is string => typeof id === "string" && id.length > 0);
}
Loading
Loading