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 +44 -9
- package/package.json +1 -1
- package/src/app/api/snapshots/[id]/restore/route.ts +6 -0
- package/src/app/api/snapshots/route.ts +6 -0
- package/src/components/chat/app-composer-hero.tsx +1 -1
- package/src/components/chat/chat-empty-state.tsx +3 -3
- package/src/components/dashboard/welcome-landing.tsx +4 -4
- package/src/components/onboarding/runtime-preference-modal.tsx +8 -8
- package/src/components/profiles/profile-card.tsx +18 -8
- package/src/lib/agents/runtime/ollama-adapter.ts +11 -6
- package/src/lib/agents/runtime/ollama-model-resolver.ts +76 -0
- package/src/lib/chat/ollama-engine.ts +17 -4
- package/src/lib/packs/templates/relay-agency-pro/pack.yaml +29 -13
- package/src/lib/snapshots/snapshot-manager.ts +40 -6
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
|
|
21741
|
-
return
|
|
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,
|
|
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.
|
|
25807
|
-
return "0.24.
|
|
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
|
@@ -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 1
|
|
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.
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
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 {
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
42
|
-
return
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
9
|
-
agency operating system:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
);
|