@mandujs/core 0.45.1 → 0.46.0

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.
@@ -45,7 +45,7 @@ import type {
45
45
  CompletionResult,
46
46
  } from "../types";
47
47
  import type {
48
- CredentialStore} from "../credentials";
48
+ CredentialStore} from "../credentials";
49
49
  import {
50
50
  getCredentialStore,
51
51
  type StoredToken,
@@ -89,10 +89,8 @@ export const OPENAI_OAUTH_SCOPE = "openai.chat";
89
89
  /**
90
90
  * Default model — GPT-5.4 (current-generation OpenAI flagship as of
91
91
  * 2026-04). Gives brain doctor triage the quality it needs to produce
92
- * actionable patches, which was the whole motivation for moving off
93
- * the local ministral-3:3b adapter. Override via
94
- * `ManduConfig.brain.openai.model` (e.g. set to a cheaper tier for
95
- * low-stakes automated runs).
92
+ * actionable patches. Override via `ManduConfig.brain.openai.model`
93
+ * (e.g. set to a cheaper tier for low-stakes automated runs).
96
94
  */
97
95
  export const OPENAI_DEFAULT_MODEL = "gpt-5.4";
98
96
  export const OPENAI_API_BASE = "https://api.openai.com/v1";
@@ -20,8 +20,7 @@ import type {
20
20
  } from "./types";
21
21
  import { DEFAULT_BRAIN_POLICY } from "./types";
22
22
  import { type LLMAdapter, NoopAdapter } from "./adapters/base";
23
- import { createOllamaAdapter } from "./adapters/ollama";
24
- import type { SessionMemory} from "./memory";
23
+ import type { SessionMemory} from "./memory";
25
24
  import { getSessionMemory } from "./memory";
26
25
  import {
27
26
  detectEnvironment,
@@ -96,15 +95,13 @@ export class Brain {
96
95
  ...options.config,
97
96
  };
98
97
 
99
- // Set up adapter
100
- if (options.adapter) {
101
- this.adapter = options.adapter;
102
- } else if (this.config.adapter) {
103
- this.adapter = createOllamaAdapter(this.config.adapter);
104
- } else {
105
- // Default: Ollama with default settings
106
- this.adapter = createOllamaAdapter();
107
- }
98
+ // Set up adapter — sync default is NoopAdapter (template). The CLI
99
+ // constructs Brain with `options.adapter` populated by
100
+ // `resolveBrainAdapter()` so cloud tiers reach Brain. Surfaces that
101
+ // skip that resolver (server runtime, server-side imports of Brain)
102
+ // get the safe template fallback. Issue #235 removed the local
103
+ // Ollama tier; cloud OAuth is the only non-template adapter now.
104
+ this.adapter = options.adapter ?? new NoopAdapter();
108
105
 
109
106
  // Get session memory
110
107
  this.memory = getSessionMemory();
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Privacy invariants:
15
15
  * - If consent is not granted, the adapter MUST fall through to the
16
- * next tier in the resolver (ollama → template).
16
+ * template tier in the resolver (NoopAdapter).
17
17
  * - `telemetryOptOut: true` in config bypasses this module entirely —
18
18
  * cloud adapters are never constructed in that case.
19
19
  * - The consent cache only stores `{ providerId, projectFingerprint,
@@ -10,8 +10,11 @@
10
10
  // Types
11
11
  export * from "./types";
12
12
 
13
- // Adapters (includes Ollama, OpenAI OAuth, Anthropic OAuth, and the
13
+ // Adapters (OpenAI OAuth, Anthropic OAuth, plus the
14
14
  // `createBrainAdapter` / `resolveBrainAdapter` resolver — Issue #235).
15
+ // The local Ollama tier was removed; cloud OAuth is the only non-template
16
+ // adapter now. Interactive CLIs prompt `mandu brain login` when the
17
+ // resolver returns `needsLogin: true`.
15
18
  export * from "./adapters";
16
19
 
17
20
  // Credential store (OS keychain + filesystem fallback).
@@ -644,20 +644,19 @@ export interface ManduConfig {
644
644
  * Fields:
645
645
  * - `adapter` — Which connector to use. Default `"auto"`.
646
646
  * Auto resolves in priority order:
647
- * openai → anthropic → ollama → template.
648
- * Explicit values pin the choice but still
649
- * degrade to template when the dependency is
650
- * unreachable (no hard failures).
651
- * - `openai.model` Override the OpenAI model (default
652
- * `"gpt-4o-mini"`).
653
- * - `anthropic.model` — Override the Anthropic model (default
654
- * `"claude-haiku-4-5-20251001"`).
655
- * - `ollama.model` — Override the local Ollama model (default
656
- * `"ministral-3:3b"`).
647
+ * openai → anthropic → template. Explicit
648
+ * values pin the choice but still degrade
649
+ * to template when the dependency is
650
+ * unreachable (no hard failures). Interactive
651
+ * CLIs prompt `mandu brain login` when the
652
+ * fallback fires due to a missing token.
653
+ * - `openai.model` — Override the OpenAI model.
654
+ * - `anthropic.model` — Override the Anthropic model.
657
655
  * - `telemetryOptOut` — When `true`, cloud adapters are disabled
658
656
  * entirely regardless of stored tokens. The
659
- * resolver falls to ollama/template. Use for
660
- * privacy-strict environments.
657
+ * resolver falls to template silently (no
658
+ * login prompt). Use for privacy-strict
659
+ * environments.
661
660
  *
662
661
  * Omitting this block is equivalent to `{ adapter: "auto" }`.
663
662
  *
@@ -665,10 +664,9 @@ export interface ManduConfig {
665
664
  * @see `docs/brain/oauth-adapters.md` (when authored).
666
665
  */
667
666
  brain?: {
668
- adapter?: "auto" | "openai" | "anthropic" | "ollama" | "template";
667
+ adapter?: "auto" | "openai" | "anthropic" | "template";
669
668
  openai?: { model?: string };
670
669
  anthropic?: { model?: string };
671
- ollama?: { model?: string; baseUrl?: string };
672
670
  telemetryOptOut?: boolean;
673
671
  };
674
672
  /**
@@ -663,21 +663,13 @@ const BrainAnthropicConfigSchema = z
663
663
  })
664
664
  .strict();
665
665
 
666
- const BrainOllamaConfigSchema = z
667
- .object({
668
- model: z.string().min(1).optional(),
669
- baseUrl: z.string().url().optional(),
670
- })
671
- .strict();
672
-
673
666
  const BrainConfigSchema = z
674
667
  .object({
675
668
  adapter: z
676
- .enum(["auto", "openai", "anthropic", "ollama", "template"])
669
+ .enum(["auto", "openai", "anthropic", "template"])
677
670
  .default("auto"),
678
671
  openai: BrainOpenAIConfigSchema.optional(),
679
672
  anthropic: BrainAnthropicConfigSchema.optional(),
680
- ollama: BrainOllamaConfigSchema.optional(),
681
673
  telemetryOptOut: z.boolean().optional(),
682
674
  })
683
675
  .strict();
@@ -742,7 +734,8 @@ export const ManduConfigSchema = z
742
734
  * Issue #235 — Brain adapter selection. See {@link BrainConfigSchema}.
743
735
  * Optional; omission is equivalent to `{ adapter: "auto" }`, which
744
736
  * resolves in priority order: openai-oauth → anthropic-oauth →
745
- * ollama template.
737
+ * template (with `needsLogin: true` so interactive CLIs prompt
738
+ * `mandu brain login`).
746
739
  */
747
740
  brain: BrainConfigSchema.optional(),
748
741
  /**
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Deploy intent cache — `.mandu/deploy.intent.json`.
3
+ *
4
+ * Issue #250 — Phase 1.
5
+ *
6
+ * The cache is the persistence layer between `mandu deploy:plan`
7
+ * (writer) and `mandu deploy --target=...` (reader). It MUST be
8
+ * checked into the repo so deploys are deterministic, brain-free, and
9
+ * reproducible across CI runs.
10
+ *
11
+ * Per-entry shape:
12
+ *
13
+ * - `intent` — fully validated `DeployIntent`.
14
+ * - `source` — `"explicit"` (user wrote `.deploy()` on the
15
+ * route) or `"inferred"` (heuristic / brain).
16
+ * Explicit entries are never overwritten by
17
+ * `deploy:plan` — the inferer treats them as
18
+ * pinned ground truth.
19
+ * - `rationale` — short, human-readable reason. The plan command
20
+ * surfaces this in the diff so reviewers can audit
21
+ * why a route landed on a given runtime.
22
+ * - `sourceHash` — content hash of the route source the intent was
23
+ * inferred from. The next plan call skips
24
+ * re-inference when the hash matches — that's the
25
+ * cost cap on brain calls.
26
+ * - `inferredAt` — ISO timestamp; left unset on `explicit` entries.
27
+ */
28
+
29
+ import { z } from "zod";
30
+ import { promises as fs } from "fs";
31
+ import path from "path";
32
+ import { DeployIntent } from "./intent";
33
+
34
+ export const DeployIntentSource = z.enum(["explicit", "inferred"]);
35
+ export type DeployIntentSource = z.infer<typeof DeployIntentSource>;
36
+
37
+ export const DeployIntentCacheEntry = z.object({
38
+ intent: DeployIntent,
39
+ source: DeployIntentSource,
40
+ rationale: z.string().min(1),
41
+ sourceHash: z.string().min(1),
42
+ inferredAt: z.string().datetime().optional(),
43
+ });
44
+ export type DeployIntentCacheEntry = z.infer<typeof DeployIntentCacheEntry>;
45
+
46
+ export const DeployIntentCache = z.object({
47
+ /** Format version — bump on any breaking schema change. */
48
+ version: z.literal(1),
49
+ /** ISO timestamp of the most recent `deploy:plan` write. */
50
+ generatedAt: z.string().datetime(),
51
+ /**
52
+ * Identifier of the inferer used for the most recent write. Examples:
53
+ * - `"heuristic"` — rule-tree only (no brain).
54
+ * - `"openai:gpt-4.1-mini"` — brain-validated.
55
+ * - `"manual"` — user hand-edited entries.
56
+ */
57
+ brainModel: z.string().min(1).default("heuristic"),
58
+ /** Map of route id → cache entry. */
59
+ intents: z.record(z.string(), DeployIntentCacheEntry),
60
+ });
61
+ export type DeployIntentCache = z.infer<typeof DeployIntentCache>;
62
+
63
+ /** Filename relative to the project root. */
64
+ export const DEPLOY_INTENT_CACHE_FILE = ".mandu/deploy.intent.json";
65
+
66
+ /** Resolve the absolute path of the cache file for a given project root. */
67
+ export function resolveDeployIntentCachePath(rootDir: string): string {
68
+ return path.join(rootDir, DEPLOY_INTENT_CACHE_FILE);
69
+ }
70
+
71
+ /**
72
+ * An empty (but valid) cache. Returned by `loadDeployIntentCache`
73
+ * when the file is missing — the caller treats that as "every route
74
+ * needs inference".
75
+ */
76
+ export function emptyDeployIntentCache(): DeployIntentCache {
77
+ return {
78
+ version: 1,
79
+ generatedAt: new Date().toISOString(),
80
+ brainModel: "heuristic",
81
+ intents: {},
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Read + validate the cache file. A missing file resolves to an empty
87
+ * cache so first-time runs work without ceremony. A malformed file
88
+ * rejects — silently swallowing JSON errors would let a corrupted
89
+ * cache silently produce wrong deploy configs.
90
+ */
91
+ export async function loadDeployIntentCache(
92
+ rootDir: string,
93
+ ): Promise<DeployIntentCache> {
94
+ const file = resolveDeployIntentCachePath(rootDir);
95
+ let raw: string;
96
+ try {
97
+ raw = await fs.readFile(file, "utf8");
98
+ } catch (err) {
99
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
100
+ return emptyDeployIntentCache();
101
+ }
102
+ throw err;
103
+ }
104
+ let parsed: unknown;
105
+ try {
106
+ parsed = JSON.parse(raw);
107
+ } catch (err) {
108
+ throw new Error(
109
+ `Deploy intent cache is not valid JSON (${file}): ${(err as Error).message}`,
110
+ );
111
+ }
112
+ return DeployIntentCache.parse(parsed);
113
+ }
114
+
115
+ /**
116
+ * Atomically write the cache. The intermediate `.tmp` file + rename
117
+ * dance prevents a half-written cache from being read by a concurrent
118
+ * deploy.
119
+ */
120
+ export async function saveDeployIntentCache(
121
+ rootDir: string,
122
+ cache: DeployIntentCache,
123
+ ): Promise<void> {
124
+ const validated = DeployIntentCache.parse(cache);
125
+ const file = resolveDeployIntentCachePath(rootDir);
126
+ await fs.mkdir(path.dirname(file), { recursive: true });
127
+ const tmp = `${file}.tmp`;
128
+ // Stable key order so the committed file stays diff-friendly.
129
+ const ordered: DeployIntentCache = {
130
+ ...validated,
131
+ intents: Object.fromEntries(
132
+ Object.keys(validated.intents)
133
+ .sort()
134
+ .map((id) => [id, validated.intents[id]!]),
135
+ ),
136
+ };
137
+ await fs.writeFile(tmp, JSON.stringify(ordered, null, 2) + "\n", "utf8");
138
+ await fs.rename(tmp, file);
139
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `@mandujs/core/deploy` — deploy intent primitive (issue #250 Phase 1).
3
+ *
4
+ * The barrel re-exports everything adapters and the CLI need:
5
+ *
6
+ * - **Schemas**: `DeployIntent`, `DeployIntentCache`, runtime / cache /
7
+ * visibility / target enums, plus the partial `DeployIntentInput`
8
+ * used by the `.deploy()` builder call site.
9
+ * - **Cache I/O**: `loadDeployIntentCache`, `saveDeployIntentCache`,
10
+ * `emptyDeployIntentCache`, the cache file path constants.
11
+ * - **Inference**: the offline heuristic and the context builder. The
12
+ * brain inferer plugs in via `planDeploy({ infer: ... })`.
13
+ * - **Plan**: `planDeploy` returns the next cache + a diff in one
14
+ * pure call.
15
+ * - **Validation helpers**: `isStaticIntentValidFor` so adapters can
16
+ * surface configuration errors before deploy.
17
+ */
18
+
19
+ export {
20
+ DeployIntent,
21
+ DeployIntentInput,
22
+ DeployRuntime,
23
+ DeployCache,
24
+ DeployCacheLifetime,
25
+ DeployVisibility,
26
+ DeployTarget,
27
+ isStaticIntentValidFor,
28
+ } from "./intent";
29
+
30
+ export {
31
+ DeployIntentCache,
32
+ DeployIntentCacheEntry,
33
+ DeployIntentSource,
34
+ DEPLOY_INTENT_CACHE_FILE,
35
+ emptyDeployIntentCache,
36
+ loadDeployIntentCache,
37
+ saveDeployIntentCache,
38
+ resolveDeployIntentCachePath,
39
+ } from "./cache";
40
+
41
+ export {
42
+ buildDeployInferenceContext,
43
+ classifyImports,
44
+ extractImports,
45
+ hashSource,
46
+ type DependencyClass,
47
+ type DeployInferenceContext,
48
+ } from "./inference/context";
49
+
50
+ export {
51
+ inferDeployIntentHeuristic,
52
+ type InferenceResult,
53
+ } from "./inference/heuristic";
54
+
55
+ export {
56
+ planDeploy,
57
+ planHasChanges,
58
+ type PlanDeployOptions,
59
+ type PlanDiffEntry,
60
+ type PlanDiffEntryKind,
61
+ type PlanResult,
62
+ } from "./plan";
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Deploy inference context — the bundle of facts an inferer (heuristic
3
+ * or brain) consumes when deciding a route's `DeployIntent`.
4
+ *
5
+ * Issue #250 — Phase 1.
6
+ *
7
+ * The context is intentionally narrow. Anything that's not statically
8
+ * derivable from the route source and manifest entry stays out — we
9
+ * want inference to be deterministic enough that two identical inputs
10
+ * always yield the same intent.
11
+ */
12
+
13
+ import { promises as fs } from "fs";
14
+ import { createHash } from "crypto";
15
+ import path from "path";
16
+ import type { RouteSpec } from "../../spec/schema";
17
+
18
+ /**
19
+ * The dependency import classes the heuristic recognises. Mapped from
20
+ * raw import specifiers in `extractImports()` below. The key is the
21
+ * coarsest signal a route exposes about whether it can run at the
22
+ * edge — DB drivers and file IO push the route to `node`/`bun`, while
23
+ * stateless `fetch`/transform code happily runs at the edge.
24
+ */
25
+ export type DependencyClass =
26
+ | "db" // bun:sqlite, postgres, drizzle, prisma, mongodb, etc.
27
+ | "node-fs" // node:fs, fs/promises, fs
28
+ | "node-net" // node:net, http, dgram
29
+ | "node-child" // node:child_process, worker_threads
30
+ | "bun-native" // bun:sqlite, bun:ffi, Bun.s3, Bun.serve
31
+ | "ai-sdk" // @anthropic-ai/sdk, openai, ai (long latency)
32
+ | "heavy" // sharp, playwright, puppeteer, large native deps
33
+ | "fetch-only" // only http fetch / minor transforms
34
+ | "unknown";
35
+
36
+ export interface DeployInferenceContext {
37
+ /** Stable id from the manifest entry. */
38
+ routeId: string;
39
+ /** URL pattern (`/api/embed`, `/[lang]/page`). */
40
+ pattern: string;
41
+ /** `page` | `api` | `metadata`. */
42
+ kind: RouteSpec["kind"];
43
+ /** Pattern contains `[param]` or `[...rest]`. */
44
+ isDynamic: boolean;
45
+ /**
46
+ * Page route exports `generateStaticParams` AND has at least one
47
+ * static-param entry in the manifest. Adapters use this with
48
+ * `runtime: "static"` to know the route is genuinely prerenderable.
49
+ */
50
+ hasGenerateStaticParams: boolean;
51
+ /** Top-level imports from the handler module (deduped, sorted). */
52
+ imports: string[];
53
+ /** Coarse classification derived from `imports`. */
54
+ dependencyClasses: ReadonlySet<DependencyClass>;
55
+ /** Whether the handler exports a `default` Mandu.filling() instance. */
56
+ exportsFilling: boolean;
57
+ /**
58
+ * SHA-256 of the route source. Used by the cache to skip re-
59
+ * inference when nothing relevant changed.
60
+ */
61
+ sourceHash: string;
62
+ }
63
+
64
+ /**
65
+ * Build the inference context for a single route. `rootDir` is the
66
+ * project root; `route.module` is resolved relative to it.
67
+ */
68
+ export async function buildDeployInferenceContext(
69
+ rootDir: string,
70
+ route: RouteSpec,
71
+ ): Promise<DeployInferenceContext> {
72
+ const modulePath = path.resolve(rootDir, route.module);
73
+ let source = "";
74
+ try {
75
+ source = await fs.readFile(modulePath, "utf8");
76
+ } catch {
77
+ // A missing module file is unusual but not fatal — the inferer
78
+ // still gets the manifest metadata and falls back to defaults.
79
+ }
80
+
81
+ const imports = extractImports(source);
82
+ const dependencyClasses = classifyImports(imports);
83
+ const isDynamic = /\[(\.\.\.)?[^\]]+\]/.test(route.pattern);
84
+ const hasGenerateStaticParams =
85
+ route.kind === "page" &&
86
+ Array.isArray(route.staticParams) &&
87
+ route.staticParams.length > 0;
88
+ const exportsFilling = /\bMandu\.filling\b|\bfilling\(\)/m.test(source);
89
+
90
+ return {
91
+ routeId: route.id,
92
+ pattern: route.pattern,
93
+ kind: route.kind,
94
+ isDynamic,
95
+ hasGenerateStaticParams,
96
+ imports,
97
+ dependencyClasses,
98
+ exportsFilling,
99
+ sourceHash: hashSource(source),
100
+ };
101
+ }
102
+
103
+ // ─── Internals ────────────────────────────────────────────────────────
104
+
105
+ /** SHA-256 hex digest. Empty input still produces a stable hash. */
106
+ export function hashSource(source: string): string {
107
+ return createHash("sha256").update(source).digest("hex");
108
+ }
109
+
110
+ /**
111
+ * Extract bare import specifiers (`from "..."` / `import("...")`).
112
+ *
113
+ * Best-effort — a TS AST would be more precise but pulling in a parser
114
+ * just for this is heavy. False positives (matches in comments or
115
+ * strings) only mis-classify the route in the heuristic, never break
116
+ * deploys, and the brain inferer can override.
117
+ */
118
+ export function extractImports(source: string): string[] {
119
+ const out = new Set<string>();
120
+ const staticImport = /^\s*import\b[^"']*?["']([^"']+)["']/gm;
121
+ const dynamicImport = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
122
+ for (const re of [staticImport, dynamicImport]) {
123
+ let m: RegExpExecArray | null;
124
+ while ((m = re.exec(source)) !== null) {
125
+ const spec = m[1]!;
126
+ if (!spec.startsWith(".")) out.add(spec);
127
+ }
128
+ }
129
+ return [...out].sort();
130
+ }
131
+
132
+ /** Map import specifiers to coarse dependency classes. */
133
+ export function classifyImports(imports: string[]): ReadonlySet<DependencyClass> {
134
+ const classes = new Set<DependencyClass>();
135
+ for (const spec of imports) {
136
+ const cls = classifyOne(spec);
137
+ if (cls) classes.add(cls);
138
+ }
139
+ if (classes.size === 0) classes.add("fetch-only");
140
+ return classes;
141
+ }
142
+
143
+ function classifyOne(spec: string): DependencyClass | null {
144
+ const s = spec.toLowerCase();
145
+ if (
146
+ s === "bun:sqlite" ||
147
+ s === "bun:ffi" ||
148
+ s.startsWith("bun:")
149
+ ) {
150
+ return "bun-native";
151
+ }
152
+ if (s === "fs" || s === "fs/promises" || s === "node:fs" || s === "node:fs/promises" || s === "node:path") {
153
+ return "node-fs";
154
+ }
155
+ if (s === "net" || s === "node:net" || s === "node:dgram" || s === "node:tls") {
156
+ return "node-net";
157
+ }
158
+ if (s === "node:child_process" || s === "child_process" || s === "node:worker_threads" || s === "worker_threads") {
159
+ return "node-child";
160
+ }
161
+ if (
162
+ /^(postgres|pg|mysql2?|drizzle-orm(\/.*)?|@prisma\/client|prisma|mongodb|mongoose|@neondatabase\/.+|kysely|sqlite3|better-sqlite3|@planetscale\/.+)$/.test(s)
163
+ ) {
164
+ return "db";
165
+ }
166
+ if (/^(@anthropic-ai\/sdk|openai|ai|@ai-sdk\/.+|@google\/generative-ai|cohere-ai)$/.test(s)) {
167
+ return "ai-sdk";
168
+ }
169
+ if (/^(sharp|playwright|playwright-core|puppeteer|@sparticuz\/.+|canvas|jimp)$/.test(s)) {
170
+ return "heavy";
171
+ }
172
+ return null;
173
+ }