orionfold-relay 0.24.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -21726,6 +21726,41 @@ var init_openai_direct = __esm({
21726
21726
  }
21727
21727
  });
21728
21728
 
21729
+ // src/lib/agents/runtime/ollama-model-resolver.ts
21730
+ async function listPulledOllamaModels(baseUrl) {
21731
+ try {
21732
+ const response = await fetch(`${baseUrl}/api/tags`, {
21733
+ signal: AbortSignal.timeout(5e3)
21734
+ });
21735
+ if (!response.ok) return [];
21736
+ const data = await response.json();
21737
+ return (data.models ?? []).map((m) => m.name).filter((n) => typeof n === "string" && n.length > 0);
21738
+ } catch {
21739
+ return [];
21740
+ }
21741
+ }
21742
+ async function resolveOllamaModel(baseUrl, requestedModel, defaultModel) {
21743
+ const explicit = requestedModel?.trim() || defaultModel?.trim();
21744
+ if (explicit) return explicit;
21745
+ const pulled = await listPulledOllamaModels(baseUrl);
21746
+ if (pulled.length > 0) return pulled[0];
21747
+ throw new OllamaModelNotConfiguredError();
21748
+ }
21749
+ var OllamaModelNotConfiguredError;
21750
+ var init_ollama_model_resolver = __esm({
21751
+ "src/lib/agents/runtime/ollama-model-resolver.ts"() {
21752
+ "use strict";
21753
+ OllamaModelNotConfiguredError = class extends Error {
21754
+ constructor(message) {
21755
+ super(
21756
+ message ?? "No Ollama model is configured. Pull a model (e.g. `ollama pull llama3.2`) or set a default in Settings \u2192 Ollama."
21757
+ );
21758
+ this.name = "OllamaModelNotConfiguredError";
21759
+ }
21760
+ };
21761
+ }
21762
+ });
21763
+
21729
21764
  // src/lib/agents/runtime/ollama-adapter.ts
21730
21765
  import { eq as eq38 } from "drizzle-orm";
21731
21766
  async function getOllamaBaseUrl() {
@@ -21734,11 +21769,11 @@ async function getOllamaBaseUrl() {
21734
21769
  const url = await getSetting2(SETTINGS_KEYS2.OLLAMA_BASE_URL);
21735
21770
  return url || DEFAULT_OLLAMA_BASE_URL;
21736
21771
  }
21737
- async function getOllamaModel() {
21772
+ async function getOllamaModel(baseUrl) {
21738
21773
  const { getSetting: getSetting2 } = await Promise.resolve().then(() => (init_helpers(), helpers_exports));
21739
21774
  const { SETTINGS_KEYS: SETTINGS_KEYS2 } = await Promise.resolve().then(() => (init_settings(), settings_exports));
21740
- const model = await getSetting2(SETTINGS_KEYS2.OLLAMA_DEFAULT_MODEL);
21741
- return model || DEFAULT_OLLAMA_MODEL;
21775
+ const defaultModel = await getSetting2(SETTINGS_KEYS2.OLLAMA_DEFAULT_MODEL);
21776
+ return resolveOllamaModel(baseUrl, null, defaultModel);
21742
21777
  }
21743
21778
  async function streamOllamaChat(baseUrl, model, messages, signal) {
21744
21779
  const response = await fetch(`${baseUrl}/api/chat`, {
@@ -21833,7 +21868,7 @@ async function executeOllamaTask(taskId) {
21833
21868
  await db.update(tasks).set({ status: "running", updatedAt: /* @__PURE__ */ new Date() }).where(eq38(tasks.id, taskId));
21834
21869
  const ctx = await buildTaskQueryContext(task, agentProfileId);
21835
21870
  const baseUrl = await getOllamaBaseUrl();
21836
- const modelId = await getOllamaModel();
21871
+ const modelId = await getOllamaModel(baseUrl);
21837
21872
  const messages = [];
21838
21873
  if (ctx.systemInstructions) {
21839
21874
  messages.push({ role: "system", content: ctx.systemInstructions });
@@ -21924,7 +21959,7 @@ async function executeOllamaTask(taskId) {
21924
21959
  }
21925
21960
  async function runOllamaTaskAssist(input) {
21926
21961
  const baseUrl = await getOllamaBaseUrl();
21927
- const modelId = await getOllamaModel();
21962
+ const modelId = await getOllamaModel(baseUrl);
21928
21963
  const profileIds = listProfiles().map((p) => p.id);
21929
21964
  const profileList = profileIds.length > 0 ? `Available agent profiles: ${profileIds.join(", ")}` : "No explicit profiles available.";
21930
21965
  const systemPrompt = `You are an AI task definition assistant. Analyze the given task and return ONLY a JSON object (no markdown) with:
@@ -21980,7 +22015,7 @@ async function testOllamaConnection() {
21980
22015
  };
21981
22016
  }
21982
22017
  }
21983
- var DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, ollamaRuntimeAdapter;
22018
+ var DEFAULT_OLLAMA_BASE_URL, ollamaRuntimeAdapter;
21984
22019
  var init_ollama_adapter = __esm({
21985
22020
  "src/lib/agents/runtime/ollama-adapter.ts"() {
21986
22021
  "use strict";
@@ -21989,10 +22024,10 @@ var init_ollama_adapter = __esm({
21989
22024
  init_execution_manager();
21990
22025
  init_claude_agent();
21991
22026
  init_catalog2();
22027
+ init_ollama_model_resolver();
21992
22028
  init_registry2();
21993
22029
  init_ledger();
21994
22030
  DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434";
21995
- DEFAULT_OLLAMA_MODEL = "llama3.2";
21996
22031
  ollamaRuntimeAdapter = {
21997
22032
  metadata: getRuntimeCatalogEntry("ollama"),
21998
22033
  async executeTask(taskId) {
@@ -25803,8 +25838,8 @@ import { execFileSync as execFileSync3 } from "child_process";
25803
25838
  import yaml12 from "js-yaml";
25804
25839
  import semver from "semver";
25805
25840
  function relayCoreVersion() {
25806
- if (semver.valid("0.24.0")) {
25807
- return "0.24.0";
25841
+ if (semver.valid("0.24.1")) {
25842
+ return "0.24.1";
25808
25843
  }
25809
25844
  try {
25810
25845
  const root = getAppRoot(import.meta.dirname, 3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "Orionfold Relay — a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.",
5
5
  "keywords": [
6
6
  "ai",
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import {
3
3
  restoreFromSnapshot,
4
4
  isSnapshotLocked,
5
+ SnapshotBusyError,
5
6
  } from "@/lib/snapshots/snapshot-manager";
6
7
  import { db } from "@/lib/db";
7
8
  import { tasks } from "@/lib/db/schema";
@@ -51,6 +52,11 @@ export async function POST(
51
52
  "Restore complete. Please restart the server to load the restored database.",
52
53
  });
53
54
  } catch (error) {
55
+ // Lock contention is a 409, not a server error — never conflate it with a
56
+ // genuine failure (issue #24).
57
+ if (error instanceof SnapshotBusyError) {
58
+ return NextResponse.json({ error: error.message }, { status: 409 });
59
+ }
54
60
  return NextResponse.json(
55
61
  {
56
62
  error:
@@ -4,6 +4,7 @@ import {
4
4
  listSnapshots,
5
5
  getSnapshotsSize,
6
6
  isSnapshotLocked,
7
+ SnapshotBusyError,
7
8
  } from "@/lib/snapshots/snapshot-manager";
8
9
 
9
10
  /** GET /api/snapshots — list all snapshots with disk usage */
@@ -46,6 +47,11 @@ export async function POST(req: NextRequest) {
46
47
 
47
48
  return NextResponse.json(snapshot, { status: 201 });
48
49
  } catch (error) {
50
+ // A lock grabbed between the isSnapshotLocked() check and createSnapshot is
51
+ // still contention, not a server error (issue #24).
52
+ if (error instanceof SnapshotBusyError) {
53
+ return NextResponse.json({ error: error.message }, { status: 409 });
54
+ }
49
55
  return NextResponse.json(
50
56
  { error: error instanceof Error ? error.message : "Failed to create snapshot" },
51
57
  { status: 500 }
@@ -16,7 +16,7 @@ const EXAMPLE_PROMPTS: { label: string; prompt: string }[] = [
16
16
  {
17
17
  label: "Build me a reading log…",
18
18
  prompt:
19
- "Build me a reading log. Track each book with title, author, date finished, and a 15 rating. Every Friday at 5pm, summarize what I read this week.",
19
+ "Build me a reading log. Track each book with title, author, date finished, and a rating from 1 to 5. Every Friday at 5pm, summarize what I read this week.",
20
20
  },
21
21
  {
22
22
  label: "Build me an expense tracker for my contractors…",
@@ -97,10 +97,10 @@ export function ChatEmptyState({
97
97
  <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
98
98
  <Bot className="h-6 w-6 text-primary" />
99
99
  </div>
100
- <h2 className="text-lg font-semibold">Describe an app. Orionfold Relay builds it.</h2>
100
+ <h2 className="text-lg font-semibold">Describe an app. Relay builds it.</h2>
101
101
  <p className="text-sm text-muted-foreground text-center max-w-md">
102
- Profiles, blueprints, tables, and schedules, composed from a single prompt.
103
- Or ask anything about your workspace.
102
+ One prompt builds the whole app: profiles, blueprints, tables, and
103
+ schedules. Or ask anything about your workspace.
104
104
  </p>
105
105
  </div>
106
106
 
@@ -9,17 +9,17 @@ const pillars = [
9
9
  {
10
10
  icon: Sparkles,
11
11
  title: "Apps from a sentence",
12
- description: "Describe what you do every week. Orionfold Relay composes the profile, blueprint, schedule, and tables into a running app. No code.",
12
+ description: "Tell Relay what you do each week. It builds a running app for you. No code needed.",
13
13
  },
14
14
  {
15
15
  icon: Shield,
16
16
  title: "Your rules, enforced",
17
- description: "Every agent action respects your policies. Full audit trail for every decision.",
17
+ description: "Agents follow your rules on every task. You get a full record of what they did.",
18
18
  },
19
19
  {
20
20
  icon: Wallet,
21
21
  title: "Know what you spend",
22
- description: "Track spend per task, per provider. Budget guardrails prevent surprise bills.",
22
+ description: "See the cost of each task. Set a budget so you never get a surprise bill.",
23
23
  },
24
24
  ];
25
25
 
@@ -44,7 +44,7 @@ export function WelcomeLanding({ starters = [] }: WelcomeLandingProps) {
44
44
  Welcome
45
45
  </h1>
46
46
  <p className="text-base text-muted-foreground mb-8 max-w-lg">
47
- Your AI Business Operating System. Describe an app, Orionfold Relay builds it, and runs it on your rules, your budget, your data.
47
+ Relay is your AI business operating system. Describe an app. Relay builds it and runs it on your rules, your budget, and your data.
48
48
  </p>
49
49
 
50
50
  <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 w-full mb-8">
@@ -42,7 +42,7 @@ const STATIC_OPTIONS: readonly PreferenceOption[] = [
42
42
  id: "quality",
43
43
  label: "Best quality",
44
44
  capabilityNote:
45
- "Top-tier model (Opus). Full filesystem + tool access; highest cost.",
45
+ "Our smartest model (Opus). It can use every tool and read files. Costs the most.",
46
46
  recommendedModel: "opus",
47
47
  icon: Sparkles,
48
48
  },
@@ -50,7 +50,7 @@ const STATIC_OPTIONS: readonly PreferenceOption[] = [
50
50
  id: "balanced",
51
51
  label: "Balanced (recommended)",
52
52
  capabilityNote:
53
- "Strong quality at a moderate price (Sonnet). Same capabilities as Opus.",
53
+ "Great quality for less (Sonnet). It can do everything Opus can.",
54
54
  recommendedModel: "sonnet",
55
55
  icon: Scale,
56
56
  },
@@ -58,7 +58,7 @@ const STATIC_OPTIONS: readonly PreferenceOption[] = [
58
58
  id: "cost",
59
59
  label: "Lowest cost",
60
60
  capabilityNote:
61
- "Fastest, cheapest cloud model (Haiku). Same tool surface as Sonnet.",
61
+ "Our fastest, cheapest model (Haiku). It uses the same tools as Sonnet.",
62
62
  recommendedModel: "haiku",
63
63
  icon: DollarSign,
64
64
  },
@@ -66,7 +66,7 @@ const STATIC_OPTIONS: readonly PreferenceOption[] = [
66
66
  id: "privacy",
67
67
  label: "Best privacy (local only)",
68
68
  capabilityNote:
69
- "Runs entirely on your machine via Ollama. No cloud calls; no MCP servers; smaller context window.",
69
+ "Runs on your own computer with Ollama. Nothing leaves your machine. It skips cloud tools and holds less at once.",
70
70
  recommendedModel: "", // resolved against the Ollama discovery list at submit time
71
71
  icon: Lock,
72
72
  },
@@ -168,7 +168,7 @@ export function RuntimePreferenceModal({
168
168
  return {
169
169
  modelId: BALANCED_FALLBACK_MODEL,
170
170
  fallbackNote:
171
- "No local models found. Point Orionfold Relay at your Ollama install in Settings. Using balanced default for now.",
171
+ "We could not find a local model. We will use the balanced model for now. To use your own, point Relay at Ollama in Settings.",
172
172
  };
173
173
  }
174
174
  return {
@@ -248,8 +248,8 @@ export function RuntimePreferenceModal({
248
248
  <DialogHeader>
249
249
  <DialogTitle>Pick your default chat model</DialogTitle>
250
250
  <DialogDescription>
251
- We&apos;ll set sensible defaults based on what matters most to you.
252
- You can change this anytime in Settings.
251
+ Tell us what matters most and we will set a good default. You can
252
+ change it anytime in Settings.
253
253
  </DialogDescription>
254
254
  </DialogHeader>
255
255
 
@@ -311,7 +311,7 @@ export function RuntimePreferenceModal({
311
311
  onClick={handleSkip}
312
312
  disabled={submitting}
313
313
  >
314
- Skip use default
314
+ Skip, use default
315
315
  </Button>
316
316
  <Button onClick={handleConfirm} disabled={submitting}>
317
317
  {submitting ? "Saving…" : "Continue"}
@@ -3,7 +3,7 @@
3
3
  import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
4
4
  import { Badge } from "@/components/ui/badge";
5
5
  import { Download } from "lucide-react";
6
- import { listRuntimeCatalog } from "@/lib/agents/runtime/catalog";
6
+ import type { AgentRuntimeId } from "@/lib/agents/runtime/catalog";
7
7
  import { getSupportedRuntimes } from "@/lib/agents/profiles/compatibility";
8
8
  import { IconCircle, getProfileIcon, getDomainColors } from "@/lib/constants/card-icons";
9
9
  import type { AgentProfile } from "@/lib/agents/profiles/types";
@@ -14,13 +14,23 @@ interface ProfileCardProps {
14
14
  onClick: () => void;
15
15
  }
16
16
 
17
+ /**
18
+ * Short chip labels for the compact runtime-coverage row. Distinct from the
19
+ * catalog's full `label` (used in profile-detail-view) — a card needs one word.
20
+ * Keyed by runtime id, NOT provider: claude-code vs anthropic-direct share a
21
+ * provider, as do openai-codex vs openai-direct, so a provider-based (or the old
22
+ * `label.includes("Codex")`) heuristic collapses distinct runtimes — notably
23
+ * Ollama, the $0-local differentiator, which used to render as "Claude".
24
+ */
25
+ const RUNTIME_SHORT_LABEL: Record<AgentRuntimeId, string> = {
26
+ "claude-code": "Claude",
27
+ "openai-codex-app-server": "Codex",
28
+ "anthropic-direct": "Anthropic",
29
+ "openai-direct": "OpenAI",
30
+ ollama: "Ollama (Local)",
31
+ };
32
+
17
33
  export function ProfileCard({ profile, isBuiltin = false, onClick }: ProfileCardProps) {
18
- const runtimeLabelMap = new Map(
19
- listRuntimeCatalog().map((runtime) => [
20
- runtime.id,
21
- runtime.label.includes("Codex") ? "Codex" : "Claude",
22
- ])
23
- );
24
34
 
25
35
  return (
26
36
  <Card
@@ -67,7 +77,7 @@ export function ProfileCard({ profile, isBuiltin = false, onClick }: ProfileCard
67
77
  <div className="flex flex-wrap gap-1">
68
78
  {getSupportedRuntimes(profile).map((runtimeId) => (
69
79
  <Badge key={runtimeId} variant="secondary" className="text-xs">
70
- {runtimeLabelMap.get(runtimeId) ?? runtimeId}
80
+ {RUNTIME_SHORT_LABEL[runtimeId] ?? runtimeId}
71
81
  </Badge>
72
82
  ))}
73
83
  </div>
@@ -12,6 +12,7 @@ import { eq } from "drizzle-orm";
12
12
  import { setExecution, removeExecution, getExecution } from "../execution-manager";
13
13
  import { buildTaskQueryContext, createTaskUsageState } from "../claude-agent";
14
14
  import { getRuntimeCatalogEntry } from "./catalog";
15
+ import { resolveOllamaModel } from "./ollama-model-resolver";
15
16
  import type {
16
17
  AgentRuntimeAdapter,
17
18
  RuntimeConnectionResult,
@@ -24,7 +25,6 @@ import { recordUsageLedgerEntry, resolveUsageActivityType } from "@/lib/usage/le
24
25
  // ── Constants ───────────────────────────────────────────────────────
25
26
 
26
27
  const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434";
27
- const DEFAULT_OLLAMA_MODEL = "llama3.2";
28
28
 
29
29
  // ── Settings helpers ────────────────────────────────────────────────
30
30
 
@@ -35,11 +35,16 @@ async function getOllamaBaseUrl(): Promise<string> {
35
35
  return url || DEFAULT_OLLAMA_BASE_URL;
36
36
  }
37
37
 
38
- async function getOllamaModel(): Promise<string> {
38
+ /**
39
+ * Resolve the effective Ollama model for a task. Uses the configured default,
40
+ * else the first actually-pulled model, else throws a named error — never the
41
+ * old hardcoded `llama3.2` phantom (issue #25).
42
+ */
43
+ async function getOllamaModel(baseUrl: string): Promise<string> {
39
44
  const { getSetting } = await import("@/lib/settings/helpers");
40
45
  const { SETTINGS_KEYS } = await import("@/lib/constants/settings");
41
- const model = await getSetting(SETTINGS_KEYS.OLLAMA_DEFAULT_MODEL);
42
- return model || DEFAULT_OLLAMA_MODEL;
46
+ const defaultModel = await getSetting(SETTINGS_KEYS.OLLAMA_DEFAULT_MODEL);
47
+ return resolveOllamaModel(baseUrl, null, defaultModel);
43
48
  }
44
49
 
45
50
  // ── NDJSON streaming chat ───────────────────────────────────────────
@@ -186,7 +191,7 @@ async function executeOllamaTask(taskId: string): Promise<void> {
186
191
 
187
192
  const ctx = await buildTaskQueryContext(task, agentProfileId);
188
193
  const baseUrl = await getOllamaBaseUrl();
189
- const modelId = await getOllamaModel();
194
+ const modelId = await getOllamaModel(baseUrl);
190
195
 
191
196
  // Build messages
192
197
  const messages: OllamaChatMessage[] = [];
@@ -300,7 +305,7 @@ async function executeOllamaTask(taskId: string): Promise<void> {
300
305
 
301
306
  async function runOllamaTaskAssist(input: TaskAssistInput): Promise<TaskAssistResponse> {
302
307
  const baseUrl = await getOllamaBaseUrl();
303
- const modelId = await getOllamaModel();
308
+ const modelId = await getOllamaModel(baseUrl);
304
309
 
305
310
  const profileIds = listProfiles().map((p) => p.id);
306
311
  const profileList = profileIds.length > 0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Ollama model resolution — import-free leaf.
3
+ *
4
+ * Shared by the task adapter (`ollama-adapter.ts`, runtime-registry-reachable)
5
+ * and the chat engine (`ollama-engine.ts`). Kept dependency-free (only `fetch`)
6
+ * so it can never introduce a module-load cycle into the runtime catalog graph
7
+ * (see memory `shared-constant-zero-import-leaf` + the smoke-test budget rule).
8
+ *
9
+ * Fixes issue #25: a fresh install has no `OLLAMA_DEFAULT_MODEL` setting, so the
10
+ * old code fell back to a hardcoded `llama3.2` that no customer had pulled →
11
+ * every Ollama task 404'd. We instead resolve to an actually-pulled model, or
12
+ * fail with a named, actionable error.
13
+ */
14
+
15
+ /**
16
+ * Raised when no Ollama model can be resolved — no explicit/default model is
17
+ * set and the local Ollama has no models pulled. Named so callers surface an
18
+ * actionable message instead of a raw 404 on a phantom model (CLAUDE.md #1/#2).
19
+ */
20
+ export class OllamaModelNotConfiguredError extends Error {
21
+ constructor(message?: string) {
22
+ super(
23
+ message ??
24
+ "No Ollama model is configured. Pull a model (e.g. `ollama pull llama3.2`) or set a default in Settings → Ollama.",
25
+ );
26
+ this.name = "OllamaModelNotConfiguredError";
27
+ }
28
+ }
29
+
30
+ interface OllamaTagsResponse {
31
+ models?: Array<{ name?: string }>;
32
+ }
33
+
34
+ /** List the models currently pulled into the local Ollama, newest-first order as returned. */
35
+ export async function listPulledOllamaModels(baseUrl: string): Promise<string[]> {
36
+ try {
37
+ const response = await fetch(`${baseUrl}/api/tags`, {
38
+ signal: AbortSignal.timeout(5000),
39
+ });
40
+ if (!response.ok) return [];
41
+ const data = (await response.json()) as OllamaTagsResponse;
42
+ return (data.models ?? [])
43
+ .map((m) => m.name)
44
+ .filter((n): n is string => typeof n === "string" && n.length > 0);
45
+ } catch {
46
+ // Connection failures surface elsewhere (testConnection); here we just
47
+ // report "no models available" and let the caller raise the named error.
48
+ return [];
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Resolve the effective Ollama model.
54
+ *
55
+ * Precedence: an explicitly requested model → the configured default →
56
+ * the first actually-pulled model. If none of those yield a model, throw
57
+ * {@link OllamaModelNotConfiguredError} rather than returning a phantom that
58
+ * would 404 at call time.
59
+ *
60
+ * @param baseUrl Ollama base URL.
61
+ * @param requestedModel A caller-pinned model (e.g. chat's `ollama:` selection). Optional.
62
+ * @param defaultModel The `OLLAMA_DEFAULT_MODEL` setting value. Optional/empty on fresh install.
63
+ */
64
+ export async function resolveOllamaModel(
65
+ baseUrl: string,
66
+ requestedModel?: string | null,
67
+ defaultModel?: string | null,
68
+ ): Promise<string> {
69
+ const explicit = requestedModel?.trim() || defaultModel?.trim();
70
+ if (explicit) return explicit;
71
+
72
+ const pulled = await listPulledOllamaModels(baseUrl);
73
+ if (pulled.length > 0) return pulled[0];
74
+
75
+ throw new OllamaModelNotConfiguredError();
76
+ }
@@ -19,6 +19,7 @@ import {
19
19
  import { buildChatContext } from "./context-builder";
20
20
  import { getWorkspaceContext } from "@/lib/environment/workspace-context";
21
21
  import { recordUsageLedgerEntry } from "@/lib/usage/ledger";
22
+ import { resolveOllamaModel } from "@/lib/agents/runtime/ollama-model-resolver";
22
23
  import type { ChatStreamEvent } from "./types";
23
24
 
24
25
  /**
@@ -40,10 +41,22 @@ export async function* sendOllamaMessage(
40
41
  // Resolve Ollama base URL and model
41
42
  const baseUrl =
42
43
  (await getSetting(SETTINGS_KEYS.OLLAMA_BASE_URL)) || "http://localhost:11434";
43
- const modelId =
44
- conversation.modelId?.replace(/^ollama:/, "") ||
45
- (await getSetting(SETTINGS_KEYS.OLLAMA_DEFAULT_MODEL)) ||
46
- "llama3.2";
44
+ // Resolve: conversation-pinned model → configured default → first pulled
45
+ // model → named error. Never the old hardcoded `llama3.2` phantom (#25).
46
+ const requestedModel = conversation.modelId?.replace(/^ollama:/, "");
47
+ const defaultModel = await getSetting(SETTINGS_KEYS.OLLAMA_DEFAULT_MODEL);
48
+ let modelId: string;
49
+ try {
50
+ modelId = await resolveOllamaModel(baseUrl, requestedModel, defaultModel);
51
+ } catch (err) {
52
+ // Surface the "no model configured" case as a visible chat error rather
53
+ // than an unhandled rejection that silently kills the stream (#25, CLAUDE.md #1).
54
+ yield {
55
+ type: "error",
56
+ message: err instanceof Error ? err.message : "No Ollama model configured",
57
+ };
58
+ return;
59
+ }
47
60
 
48
61
  // Build context
49
62
  let projectName: string | null = null;
@@ -5,19 +5,35 @@ author: Orionfold
5
5
  # This description renders as the what-you-get preview on the locked /packs
6
6
  # card (D6) — it is sales copy, not an internal summary.
7
7
  description: >
8
- Relay Agency runs a workflow. Agency Pro runs your agency. Six chapters of
9
- agency operating system: a Finance Cockpit that closes your month by itself
10
- (per-client rollups, margin on one screen, a scheduled month-end close);
11
- Intake Pipelines that turn a dropped row into the right client workflow
12
- automatically; a New-Business Machine that takes a prospect from research to
13
- signed engagement letter; client-safe Governance profiles with hard tool
14
- policies and a per-client audit export; a deep Commercial Real Estate
15
- renewal engine that covers critical dates, escalations, comps, LOI drafts,
16
- and portfolio rent roll; and the Nonprofit deep chapter (added in v0.2.0,
17
- your first included update), a grant pipeline that takes every opportunity from
18
- fit-scored go/no-go through LOI, full application, and post-award
19
- restricted-funds compliance with a reporting calendar. Everything installs
20
- offline; Relay never sends your data to Orionfold.
8
+ Agency Pro runs your whole agency, not just one workflow. Six chapters of an
9
+ agency operating system:
10
+
11
+
12
+ Finance Cockpit closes your month for you. See margin on one screen, with
13
+ per-client rollups and a month-end close that runs on a schedule.
14
+
15
+
16
+ Intake Pipelines turn a new row into the right client workflow on their own.
17
+
18
+
19
+ New-Business Machine takes a prospect from first research to a signed
20
+ engagement letter.
21
+
22
+
23
+ Governance profiles keep each client safe with firm tool rules and an audit
24
+ export you can hand over.
25
+
26
+
27
+ Real Estate renewal engine tracks key dates, escalations, comps, LOI drafts,
28
+ and your full rent roll.
29
+
30
+
31
+ Nonprofit chapter (new in v0.2.0, your first free update) runs a grant
32
+ pipeline from a go or no-go call, through the full application, to
33
+ post-award reporting and fund rules.
34
+
35
+
36
+ Everything installs offline. Relay never sends your data to Orionfold.
21
37
  relayCore: ">=0.18.0"
22
38
  entitlement: product:orionfold-relay
23
39
  # Two-phase offer, hand-maintained to match orionfold.com/relay/ in this
@@ -48,6 +48,18 @@ export function isSnapshotLocked(): boolean {
48
48
  return snapshotLock;
49
49
  }
50
50
 
51
+ /**
52
+ * Raised when a snapshot operation is requested while another one holds the
53
+ * mutex. Named so callers (the API route) can map lock-contention to 409
54
+ * instead of conflating it with a genuine 500 failure. See issue #24.
55
+ */
56
+ export class SnapshotBusyError extends Error {
57
+ constructor() {
58
+ super("Another snapshot operation is already in progress");
59
+ this.name = "SnapshotBusyError";
60
+ }
61
+ }
62
+
51
63
  interface SnapshotManifest {
52
64
  version: 1;
53
65
  timestamp: string;
@@ -103,16 +115,38 @@ function dirSize(dirPath: string): { fileCount: number; sizeBytes: number } {
103
115
 
104
116
  /**
105
117
  * Create a full-state snapshot (DB + files).
118
+ *
119
+ * Public entry point: acquires the mutex, then delegates to the unlocked core.
120
+ * Throws {@link SnapshotBusyError} if another operation holds the lock.
106
121
  */
107
122
  export async function createSnapshot(
108
123
  label: string,
109
124
  type: "manual" | "auto" = "manual"
110
125
  ): Promise<SnapshotRow> {
111
126
  if (snapshotLock) {
112
- throw new Error("Another snapshot operation is already in progress");
127
+ throw new SnapshotBusyError();
113
128
  }
114
129
 
115
130
  snapshotLock = true;
131
+ try {
132
+ return await createSnapshotUnlocked(label, type);
133
+ } finally {
134
+ snapshotLock = false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Create a snapshot WITHOUT touching the mutex.
140
+ *
141
+ * Callers are responsible for holding `snapshotLock` around this. It exists so
142
+ * an already-locked operation (e.g. restoreFromSnapshot's pre-restore safety
143
+ * snapshot) can create a snapshot without self-deadlocking against the public
144
+ * `createSnapshot` lock check (issue #24).
145
+ */
146
+ async function createSnapshotUnlocked(
147
+ label: string,
148
+ type: "manual" | "auto" = "manual"
149
+ ): Promise<SnapshotRow> {
116
150
  const id = generateId();
117
151
  const now = new Date();
118
152
  const sanitizedLabel = label.replace(/[^a-zA-Z0-9-_ ]/g, "_").slice(0, 100);
@@ -246,8 +280,6 @@ export async function createSnapshot(
246
280
  }
247
281
 
248
282
  throw error;
249
- } finally {
250
- snapshotLock = false;
251
283
  }
252
284
  }
253
285
 
@@ -353,7 +385,7 @@ export async function restoreFromSnapshot(id: string): Promise<{
353
385
  preRestoreSnapshotId: string;
354
386
  }> {
355
387
  if (snapshotLock) {
356
- throw new Error("Another snapshot operation is already in progress");
388
+ throw new SnapshotBusyError();
357
389
  }
358
390
 
359
391
  const snapshot = await getSnapshot(id);
@@ -373,8 +405,10 @@ export async function restoreFromSnapshot(id: string): Promise<{
373
405
  snapshotLock = true;
374
406
 
375
407
  try {
376
- // 1. Create pre-restore safety snapshot
377
- const preRestore = await createSnapshot(
408
+ // 1. Create pre-restore safety snapshot. Use the UNLOCKED core: we already
409
+ // hold snapshotLock, and the public createSnapshot would throw
410
+ // SnapshotBusyError against our own lock (the issue #24 deadlock).
411
+ const preRestore = await createSnapshotUnlocked(
378
412
  `pre-restore-${formatTimestamp(new Date())}`,
379
413
  "auto"
380
414
  );