@cruxy/cli 0.16.0 → 0.18.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.
Files changed (58) hide show
  1. package/dist/agent/loop.d.ts +15 -0
  2. package/dist/agent/loop.js +13 -2
  3. package/dist/agent/prompts.d.ts +7 -0
  4. package/dist/agent/prompts.js +6 -0
  5. package/dist/agent/session.d.ts +14 -0
  6. package/dist/agent/session.js +10 -1
  7. package/dist/brand/index.d.ts +1 -1
  8. package/dist/brand/index.js +1 -1
  9. package/dist/brand/voice.d.ts +20 -0
  10. package/dist/brand/voice.js +54 -0
  11. package/dist/cli/commands/memory.d.ts +8 -0
  12. package/dist/cli/commands/memory.js +98 -0
  13. package/dist/cli/commands/pr.js +9 -1
  14. package/dist/cli/program.js +2 -0
  15. package/dist/cli/session-factory.js +31 -1
  16. package/dist/config/schema.d.ts +114 -28
  17. package/dist/config/schema.js +38 -0
  18. package/dist/constants.d.ts +11 -0
  19. package/dist/constants.js +11 -0
  20. package/dist/errors/constructors.d.ts +23 -0
  21. package/dist/errors/constructors.js +86 -6
  22. package/dist/errors/types.d.ts +12 -0
  23. package/dist/errors/types.js +20 -0
  24. package/dist/hooks/types.d.ts +1 -1
  25. package/dist/memory/index.d.ts +7 -0
  26. package/dist/memory/index.js +7 -0
  27. package/dist/memory/recall.d.ts +32 -0
  28. package/dist/memory/recall.js +73 -0
  29. package/dist/memory/remember-tool.d.ts +25 -0
  30. package/dist/memory/remember-tool.js +56 -0
  31. package/dist/memory/secrets.d.ts +29 -0
  32. package/dist/memory/secrets.js +61 -0
  33. package/dist/memory/service.d.ts +92 -0
  34. package/dist/memory/service.js +164 -0
  35. package/dist/memory/store.d.ts +32 -0
  36. package/dist/memory/store.js +100 -0
  37. package/dist/memory/trust.d.ts +52 -0
  38. package/dist/memory/trust.js +106 -0
  39. package/dist/memory/types.d.ts +101 -0
  40. package/dist/memory/types.js +58 -0
  41. package/dist/plan/service.d.ts +9 -0
  42. package/dist/plan/service.js +6 -0
  43. package/dist/render/state.js +4 -1
  44. package/dist/render/types.d.ts +7 -1
  45. package/dist/routing/index.d.ts +2 -0
  46. package/dist/routing/index.js +5 -0
  47. package/dist/routing/resolve.d.ts +17 -0
  48. package/dist/routing/resolve.js +18 -0
  49. package/dist/routing/router.d.ts +47 -0
  50. package/dist/routing/router.js +84 -0
  51. package/dist/routing/types.d.ts +42 -0
  52. package/dist/routing/types.js +27 -0
  53. package/dist/subagent/orchestrator.d.ts +6 -0
  54. package/dist/subagent/orchestrator.js +2 -0
  55. package/dist/subagent/types.d.ts +6 -0
  56. package/dist/vcs/generate.d.ts +3 -1
  57. package/dist/vcs/generate.js +4 -1
  58. package/package.json +2 -2
@@ -2,6 +2,7 @@ import type { Message, Provider, Usage } from "@cruxy/sdk";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
3
  import type { HookEvent } from "../hooks/index.js";
4
4
  import type { StreamRenderer } from "../render/index.js";
5
+ import { type Router, type TaskClass } from "../routing/index.js";
5
6
  import type { ToolContext } from "../tools/index.js";
6
7
  import { ToolRegistry } from "../tools/index.js";
7
8
  /** The lifecycle-hook firing seam (C.19). Structural so the loop stays
@@ -39,6 +40,9 @@ export interface RunAgentArgs {
39
40
  } | null;
40
41
  /** Project instructions (e.g. from CRUXY.md) folded into the system prompt. */
41
42
  projectInstructions?: string | null;
43
+ /** Persistent memory (C.29): the pre-rendered recall block for the system
44
+ * prompt, or null. Reference data only — never influences the U.3 gate. */
45
+ recalledMemory?: string | null;
42
46
  /** Plan mode's propose phase (C.31): inject the plan-first system directive. */
43
47
  planMode?: boolean;
44
48
  /** Subagent runs (C.14): inject the bounded-subtask system directive. */
@@ -57,6 +61,17 @@ export interface RunAgentArgs {
57
61
  * no-hooks path, so their tools never fire hooks.
58
62
  */
59
63
  hooks?: LifecycleHookRunner;
64
+ /**
65
+ * Multi-model routing (C.30): when set, the tier for `taskClass` is resolved
66
+ * ONCE up front (fail loud before any model turn) and its wire model overrides
67
+ * the provider default for every turn in this run; the tier is surfaced on the
68
+ * live line. Omitted → no override, the provider's default model is used
69
+ * (unchanged behavior).
70
+ */
71
+ router?: Router;
72
+ /** The declared task class for routing; defaults to `main-turn`. Ignored
73
+ * unless `router` is set. */
74
+ taskClass?: TaskClass;
60
75
  }
61
76
  /**
62
77
  * The budget seam for {@link runAgent}: implementations track their own caps
@@ -1,4 +1,5 @@
1
1
  import { CruxyError, providerUnsupported } from "../errors/index.js";
2
+ import { resolveTaskModel, } from "../routing/index.js";
2
3
  import { buildSystemPrompt } from "./prompts.js";
3
4
  /** Tools whose successful call is a file change (drives the `on-file-change`
4
5
  * hook). Kept in sync with the file-mutating tool set. */
@@ -17,9 +18,16 @@ export async function runAgent(args) {
17
18
  if (!provider.supportsTools) {
18
19
  throw providerUnsupported(config.model.provider);
19
20
  }
21
+ // Resolve the routing tier ONCE, before any model turn (C.30). A tier the
22
+ // gateway does not offer throws CRUXY_E_ROUTING_TIER_UNAVAILABLE here — so a
23
+ // misrouted run never reaches provider.stream (no request sent with the wrong
24
+ // tier), and never silently falls back to a different one.
25
+ const routed = args.router
26
+ ? resolveTaskModel(args.router, args.taskClass ?? "main-turn")
27
+ : null;
20
28
  renderer?.beginTurn();
21
29
  try {
22
- return await driveLoop(args, renderer);
30
+ return await driveLoop(args, renderer, routed);
23
31
  }
24
32
  finally {
25
33
  // Always leave the terminal clean: no orphaned status line, no held text —
@@ -28,7 +36,7 @@ export async function runAgent(args) {
28
36
  }
29
37
  }
30
38
  /** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
31
- async function driveLoop(args, renderer) {
39
+ async function driveLoop(args, renderer, routed) {
32
40
  const { provider, registry, config, ctx } = args;
33
41
  const { logger } = ctx;
34
42
  // Work on a copy so we never mutate the caller's array as a side effect; the
@@ -48,6 +56,7 @@ async function driveLoop(args, renderer) {
48
56
  .map((tool) => ({ name: tool.name, description: tool.description })),
49
57
  git: args.git ?? null,
50
58
  projectInstructions: args.projectInstructions ?? null,
59
+ recalledMemory: args.recalledMemory ?? null,
51
60
  planMode: args.planMode ?? false,
52
61
  subagent: args.subagent ?? false,
53
62
  });
@@ -80,11 +89,13 @@ async function driveLoop(args, renderer) {
80
89
  tokens: usage.input_tokens + usage.output_tokens > 0
81
90
  ? { input: usage.input_tokens, output: usage.output_tokens }
82
91
  : undefined,
92
+ tier: routed?.tier,
83
93
  });
84
94
  for await (const ev of provider.stream({
85
95
  system,
86
96
  messages,
87
97
  tools,
98
+ ...(routed ? { model: routed.model } : {}),
88
99
  })) {
89
100
  switch (ev.type) {
90
101
  case "text_delta":
@@ -28,6 +28,13 @@ export interface PromptContext {
28
28
  } | null;
29
29
  /** Optional extra instructions (e.g. from a project CRUXY.md). */
30
30
  projectInstructions?: string | null;
31
+ /**
32
+ * Persistent memory (C.29): the pre-rendered, demarcated recall block injected
33
+ * at session start. It is REFERENCE DATA, not instructions — the block carries
34
+ * its own data-only framing (see memory/recall.ts) and is appended as an
35
+ * ordinary section; the U.3 gate never reads it. Null when nothing is recalled.
36
+ */
37
+ recalledMemory?: string | null;
31
38
  /** Plan mode's propose phase (C.31): inject the plan-first directive. */
32
39
  planMode?: boolean;
33
40
  /** Subagent run (C.14): inject the bounded-subtask directive. */
@@ -91,6 +91,12 @@ export function buildSystemPrompt(ctx) {
91
91
  if (ctx.projectInstructions?.trim()) {
92
92
  sections.push(`## Project instructions\nThe following came from this project's configuration; honor it unless it conflicts with the rules above:\n\n${ctx.projectInstructions.trim()}`);
93
93
  }
94
+ // Persistent memory (C.29). Appended last, as reference DATA — the block is
95
+ // pre-rendered with its own un-spoofable data-only demarcation, so it is added
96
+ // verbatim (never re-wrapped as an instruction). Absent when nothing recalled.
97
+ if (ctx.recalledMemory?.trim()) {
98
+ sections.push(ctx.recalledMemory.trim());
99
+ }
94
100
  return sections.join("\n\n");
95
101
  }
96
102
  /**
@@ -1,6 +1,7 @@
1
1
  import type { Message, Provider, Usage } from "@cruxy/sdk";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
3
  import type { StreamRenderer } from "../render/index.js";
4
+ import { type Router } from "../routing/index.js";
4
5
  import type { ToolContext } from "../tools/index.js";
5
6
  import type { ToolRegistry } from "../tools/index.js";
6
7
  import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
@@ -13,6 +14,7 @@ import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
13
14
  export type PlanRunner = (args: {
14
15
  messages: Message[];
15
16
  projectInstructions: string | null;
17
+ recalledMemory: string | null;
16
18
  renderer?: StreamRenderer;
17
19
  }) => Promise<AgentResult>;
18
20
  export interface SessionArgs {
@@ -31,6 +33,13 @@ export interface SessionArgs {
31
33
  } | null;
32
34
  /** Project instructions (e.g. CRUXY.md) folded into every turn's system prompt. */
33
35
  projectInstructions?: string | null;
36
+ /**
37
+ * Persistent memory (C.29): the pre-rendered recall block, injected into every
38
+ * turn's system prompt as reference data. Built once at session start (user
39
+ * memory + trusted project memory); null when memory is off or empty. Fixed
40
+ * for the session — it does not re-read mid-session.
41
+ */
42
+ recalledMemory?: string | null;
34
43
  /** Start in plan mode (C.31). Toggleable at runtime via `setPlanMode`. */
35
44
  planMode?: boolean;
36
45
  /** The plan-mode turn runner; required for plan mode to actually engage. */
@@ -42,6 +51,11 @@ export interface SessionArgs {
42
51
  * `before-tool`/`after-tool`/`on-file-change`.
43
52
  */
44
53
  hooks?: LifecycleHookRunner;
54
+ /**
55
+ * Multi-model routing (C.30). When set, main turns route on `main-turn` and
56
+ * context compaction on `summarize`; omitted → the provider default (unchanged).
57
+ */
58
+ router?: Router;
45
59
  }
46
60
  /**
47
61
  * Estimate the token footprint of a message list with a cheap chars/4 heuristic
@@ -1,4 +1,5 @@
1
1
  import { loadProjectInstructions } from "../config/index.js";
2
+ import { resolveTaskModel } from "../routing/index.js";
2
3
  import { runAgent, } from "./loop.js";
3
4
  import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
4
5
  /**
@@ -98,11 +99,13 @@ export class Session {
98
99
  ? await this.args.planRunner({
99
100
  messages: this.messages,
100
101
  projectInstructions: this.projectInstructions,
102
+ recalledMemory: this.args.recalledMemory ?? null,
101
103
  renderer,
102
104
  })
103
105
  : await runAgent({
104
106
  messages: this.messages,
105
- ...this.args,
107
+ ...this.args, // carries `router` through to the loop
108
+ taskClass: "main-turn",
106
109
  // After the spread so a mid-session `/reload` wins over the initial value.
107
110
  projectInstructions: this.projectInstructions,
108
111
  planMode: false, // the plan directive belongs only to the runner's propose phase
@@ -226,9 +229,15 @@ export class Session {
226
229
  const transcript = renderTranscript(prefix);
227
230
  const usage = { input_tokens: 0, output_tokens: 0 };
228
231
  let text = "";
232
+ // Compaction is mechanical work — route it on `summarize` (C.30) when routing
233
+ // is active; omitted → the provider default, unchanged.
234
+ const routed = this.args.router
235
+ ? resolveTaskModel(this.args.router, "summarize")
236
+ : null;
229
237
  for await (const ev of this.args.provider.stream({
230
238
  system: SUMMARY_SYSTEM,
231
239
  messages: [{ role: "user", content: transcript }],
240
+ ...(routed ? { model: routed.model } : {}),
232
241
  })) {
233
242
  switch (ev.type) {
234
243
  case "text_delta":
@@ -1 +1 @@
1
- export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, type ForbiddenTerm, type LexiconViolation, } from "./voice.js";
1
+ export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_NAME_PLACEHOLDER, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, scrubModelNames, type ForbiddenTerm, type LexiconViolation, } from "./voice.js";
@@ -1 +1 @@
1
- export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, } from "./voice.js";
1
+ export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_NAME_PLACEHOLDER, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, scrubModelNames, } from "./voice.js";
@@ -59,6 +59,26 @@ export declare const FORBIDDEN_TERMS: readonly ForbiddenTerm[];
59
59
  * absent: they are real bring-your-own-provider config values, not model names.
60
60
  */
61
61
  export declare const FORBIDDEN_MODEL_NAMES: RegExp;
62
+ /** The neutral token an upstream model name is replaced with in user-facing copy. */
63
+ export declare const MODEL_NAME_PLACEHOLDER = "\u00ABmodel\u00BB";
64
+ /**
65
+ * Replace every upstream model IDENTIFIER in an arbitrary, externally-sourced
66
+ * string with {@link MODEL_NAME_PLACEHOLDER}, collapsing a whole `name-name-vers`
67
+ * run to a single token.
68
+ *
69
+ * The U.8 gag is structural *inside the process* (the routing package can't
70
+ * produce an upstream id — see `resolveModelId`), but a gateway/provider error
71
+ * body is an arbitrary external string that bypasses that guarantee. This is the
72
+ * scrub applied at the error-construction boundary so no upstream id reaches a
73
+ * user-facing `CruxyError.cause`.
74
+ *
75
+ * Scrubs model ids as whole TOKENS, never a bare vendor word or a substring
76
+ * inside an unrelated word/path — so legitimate output (`/home/claude`, the
77
+ * `opus` codec, a `claude-bot` username) is left intact (see {@link isModelId}).
78
+ * Provider names (`anthropic`/`openai`) are absent from the set, so bring-your-own
79
+ * provider config values survive too.
80
+ */
81
+ export declare function scrubModelNames(text: string): string;
62
82
  /** One lexicon violation found in a scanned string. */
63
83
  export interface LexiconViolation {
64
84
  /** The forbidden term (or "model-name" for a gag violation). */
@@ -55,6 +55,60 @@ export const FORBIDDEN_TERMS = [
55
55
  * absent: they are real bring-your-own-provider config values, not model names.
56
56
  */
57
57
  export const FORBIDDEN_MODEL_NAMES = /\b(claude|sonnet|opus|haiku|gpt-?\d|gemini|llama|mistral)\b/i;
58
+ /** The neutral token an upstream model name is replaced with in user-facing copy. */
59
+ export const MODEL_NAME_PLACEHOLDER = "«model»";
60
+ /**
61
+ * A model-id-SHAPED run in free text: a self-identifying `gpt-4`/`gpt-4o`, or a
62
+ * model word carrying a hyphen/dot tail (`claude-sonnet-4-6`, `gemini-1.5-pro`).
63
+ * Deliberately GENEROUS — it also matches bare words and non-model hyphenated
64
+ * tokens; {@link isModelId} does the real filtering in the scrub callback, and
65
+ * matching the whole run (not each word) is what collapses `claude-sonnet-4-6`
66
+ * to ONE placeholder instead of `«model»-«model»-4-6`. The leading
67
+ * `(?<![/\\\w])` stops it firing inside a longer word (`gptools`) or on a path
68
+ * segment (`/home/claude/…`) — both of which the bare `\b` form corrupts.
69
+ */
70
+ const MODEL_ID_CANDIDATE = /(?<![/\\\w])(?:gpt-?\d[a-z0-9]*(?:[-.][a-z0-9]+)*|(?:claude|sonnet|opus|haiku|gemini|llama|mistral|gpt)(?:[-.][a-z0-9]+)*)/gi;
71
+ /** The bare model words, for the digitless-compound test in {@link isModelId}. */
72
+ const MODEL_WORD = /(?:claude|sonnet|opus|haiku|gemini|llama|mistral|gpt)/gi;
73
+ /**
74
+ * Is a {@link MODEL_ID_CANDIDATE} run an actual upstream model reference (scrub),
75
+ * or a legitimate token that merely contains a model word (keep)? A reference is:
76
+ * a self-identifying `gpt-<n>`; OR a hyphen/dot run carrying a version digit
77
+ * (`claude-sonnet-4-6`, `gemini-1.5-pro`); OR a digitless compound of ≥2 distinct
78
+ * model words (`claude-sonnet`). A bare word (`opus`, `haiku`), a path segment
79
+ * (`/home/claude`), or a plain hyphenated token (`claude-bot`) is none of these,
80
+ * so it survives verbatim — the gag scrubs model IDS, never legitimate output.
81
+ */
82
+ function isModelId(run) {
83
+ if (/^gpt-?\d/i.test(run))
84
+ return true; // gpt-4, gpt4, gpt-4o
85
+ if (!/[-.]/.test(run))
86
+ return false; // bare word — never a model reference here
87
+ if (/\d/.test(run))
88
+ return true; // version digit ⇒ an id
89
+ const words = run.match(MODEL_WORD) ?? [];
90
+ return new Set(words.map((w) => w.toLowerCase())).size >= 2; // claude-sonnet
91
+ }
92
+ /**
93
+ * Replace every upstream model IDENTIFIER in an arbitrary, externally-sourced
94
+ * string with {@link MODEL_NAME_PLACEHOLDER}, collapsing a whole `name-name-vers`
95
+ * run to a single token.
96
+ *
97
+ * The U.8 gag is structural *inside the process* (the routing package can't
98
+ * produce an upstream id — see `resolveModelId`), but a gateway/provider error
99
+ * body is an arbitrary external string that bypasses that guarantee. This is the
100
+ * scrub applied at the error-construction boundary so no upstream id reaches a
101
+ * user-facing `CruxyError.cause`.
102
+ *
103
+ * Scrubs model ids as whole TOKENS, never a bare vendor word or a substring
104
+ * inside an unrelated word/path — so legitimate output (`/home/claude`, the
105
+ * `opus` codec, a `claude-bot` username) is left intact (see {@link isModelId}).
106
+ * Provider names (`anthropic`/`openai`) are absent from the set, so bring-your-own
107
+ * provider config values survive too.
108
+ */
109
+ export function scrubModelNames(text) {
110
+ return text.replace(MODEL_ID_CANDIDATE, (run) => isModelId(run) ? MODEL_NAME_PLACEHOLDER : run);
111
+ }
58
112
  /**
59
113
  * Scan one user-facing string for lexicon violations — deprecated synonyms and
60
114
  * upstream model names. Returns every violation (empty ⇒ on-voice). Pure; the
@@ -0,0 +1,8 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `cruxy memory` — inspect and control persistent memory (C.29). `list` shows
4
+ * user + project entries and whether project memory is trusted (recalled);
5
+ * `forget`/`clear` remove entries; `trust` records the explicit decision to
6
+ * recall a repo's project memory, bound to its current fingerprint.
7
+ */
8
+ export declare function memoryCommand(): Command;
@@ -0,0 +1,98 @@
1
+ import path from "node:path";
2
+ import { Command } from "commander";
3
+ import { loadConfig } from "../../config/index.js";
4
+ import { shouldUseColor } from "../../errors/index.js";
5
+ import { themeForColor } from "../../theme/index.js";
6
+ import { MemoryService } from "../../memory/index.js";
7
+ import { logger } from "../../utils/logger.js";
8
+ /**
9
+ * `cruxy memory` — inspect and control persistent memory (C.29). `list` shows
10
+ * user + project entries and whether project memory is trusted (recalled);
11
+ * `forget`/`clear` remove entries; `trust` records the explicit decision to
12
+ * recall a repo's project memory, bound to its current fingerprint.
13
+ */
14
+ export function memoryCommand() {
15
+ const cmd = new Command("memory").description("inspect and control persistent memory recalled across sessions");
16
+ const service = () => {
17
+ const { config } = loadConfig();
18
+ return new MemoryService({ cwd: process.cwd(), config: config.memory });
19
+ };
20
+ cmd
21
+ .command("list", { isDefault: true })
22
+ .description("list saved memory entries and project-memory trust status")
23
+ .action(() => {
24
+ const t = themeForColor(shouldUseColor(process.stdout));
25
+ const { config } = loadConfig();
26
+ const status = service().status();
27
+ logger.print(`${t.strong("memory:")} ${config.memory.enabled ? t.success("enabled") : t.warning("disabled (memory.enabled = false)")}`);
28
+ logger.print(`\n${t.heading("user memory")} ${t.muted("(trusted)")}`);
29
+ printEntries(status.user, t);
30
+ const trustLabel = status.projectTrusted
31
+ ? t.success("trusted — recalled")
32
+ : t.danger("NOT trusted — not recalled; run `cruxy memory trust .`");
33
+ logger.print(`\n${t.heading("project memory")} ${trustLabel}`);
34
+ printEntries(status.project, t);
35
+ if (status.errors.length > 0) {
36
+ logger.print(`\n${t.danger(t.heading("excluded (invalid or secret — never recalled):"))}`);
37
+ for (const e of status.errors) {
38
+ logger.print(` ${t.muted(`[${e.scope}]`)} ${t.strong(e.id)} — ${e.message}`);
39
+ }
40
+ }
41
+ });
42
+ cmd
43
+ .command("forget <id>")
44
+ .description("forget one saved memory entry by id")
45
+ .action((id) => {
46
+ const t = themeForColor(shouldUseColor(process.stdout));
47
+ const removed = service().forget(id);
48
+ logger.print(removed
49
+ ? `${t.success("forgotten")} — entry ${t.strong(id)} removed`
50
+ : t.warning(`no memory entry with id ${id}`));
51
+ });
52
+ cmd
53
+ .command("clear")
54
+ .description("clear saved memory entries in a scope")
55
+ .option("--scope <scope>", "which scope to clear: user | project | all", "all")
56
+ .action((opts) => {
57
+ const t = themeForColor(shouldUseColor(process.stdout));
58
+ const scope = opts.scope;
59
+ if (scope !== "user" && scope !== "project" && scope !== "all") {
60
+ logger.print(t.danger(`invalid --scope ${scope} (use user | project | all)`));
61
+ process.exitCode = 2;
62
+ return;
63
+ }
64
+ const removed = service().clear(scope);
65
+ const where = scope === "all" ? "across all scopes" : `from ${scope} memory`;
66
+ logger.print(`${t.success("cleared")} — removed ${removed} ` +
67
+ `entr${removed === 1 ? "y" : "ies"} ${where}`);
68
+ });
69
+ cmd
70
+ .command("trust [path]")
71
+ .description("trust this repo's project memory after reviewing it (records the decision)")
72
+ .action((target) => {
73
+ const t = themeForColor(shouldUseColor(process.stdout));
74
+ const { config } = loadConfig();
75
+ const root = path.resolve(target ?? process.cwd());
76
+ const svc = new MemoryService({ cwd: root, config: config.memory });
77
+ const status = svc.status();
78
+ if (status.project.length === 0) {
79
+ logger.print(t.muted(`no project memory found under ${root}`));
80
+ return;
81
+ }
82
+ logger.print(t.strong(`trusting ${status.project.length} project entr${status.project.length === 1 ? "y" : "ies"}:`));
83
+ printEntries(status.project, t);
84
+ const count = svc.trustProject();
85
+ logger.print(`${t.success("trusted")} — ${count} project entr${count === 1 ? "y" : "ies"} will now be recalled for ${root}. ` +
86
+ t.muted("changing them will require re-trusting."));
87
+ });
88
+ return cmd;
89
+ }
90
+ function printEntries(entries, t) {
91
+ if (entries.length === 0) {
92
+ logger.print(t.muted(" (none)"));
93
+ return;
94
+ }
95
+ for (const e of entries) {
96
+ logger.print(` ${t.muted(e.id.slice(0, 8))} ${t.strong(`(${e.kind})`)} ${e.content}`);
97
+ }
98
+ }
@@ -5,6 +5,7 @@ import { loadConfig, resolveApiKey } from "../../config/index.js";
5
5
  import { authMissingKey, shouldUseColor } from "../../errors/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
7
  import { ApprovalService } from "../../approval/index.js";
8
+ import { resolveTaskModel, routerForConfig } from "../../routing/index.js";
8
9
  import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
9
10
  /**
10
11
  * `cruxy pr` — turn the current changes into a pull request (C.15). Generates the
@@ -37,6 +38,13 @@ export function prCommand() {
37
38
  temperature: config.model.temperature,
38
39
  gatewayUrl: config.cruxy.gatewayUrl,
39
40
  });
41
+ // Multi-model routing (C.30): PR/commit text is `commit-msg` work. Null
42
+ // unless routing is configured → the provider default, unchanged. A
43
+ // misrouted tier fails loud here before any request.
44
+ const router = routerForConfig(config);
45
+ const genModel = router
46
+ ? resolveTaskModel(router, "commit-msg").model
47
+ : undefined;
40
48
  // resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
41
49
  const forge = createForgeProvider(resolveForgeToken());
42
50
  const guidance = await loadCommitGuidance(cwd);
@@ -53,7 +61,7 @@ export function prCommand() {
53
61
  ...i,
54
62
  scopes: guidance.scopes,
55
63
  skillBody: guidance.skillBody,
56
- }),
64
+ }, { model: genModel }),
57
65
  });
58
66
  logger.info(t.muted("generating pull request content…"));
59
67
  const outcome = await service.openPullRequest({
@@ -15,6 +15,7 @@ import { checkpointCommand } from "./commands/checkpoint.js";
15
15
  import { rollbackCommand } from "./commands/rollback.js";
16
16
  import { testCommand } from "./commands/test.js";
17
17
  import { hooksCommand } from "./commands/hooks.js";
18
+ import { memoryCommand } from "./commands/memory.js";
18
19
  import { loadConfig } from "../config/index.js";
19
20
  import { maybeRunOnboarding } from "./onboard.js";
20
21
  export function buildProgram() {
@@ -45,6 +46,7 @@ export function buildProgram() {
45
46
  program.addCommand(rollbackCommand());
46
47
  program.addCommand(testCommand());
47
48
  program.addCommand(hooksCommand());
49
+ program.addCommand(memoryCommand());
48
50
  // Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
49
51
  // means an unknown command (Commander runs the default action with it as an
50
52
  // operand rather than erroring), so reject it as a usage error.
@@ -7,6 +7,8 @@ import { shouldUseColor } from "../errors/index.js";
7
7
  import { buildDefaultRegistry } from "../tools/index.js";
8
8
  import { Session, } from "../agent/index.js";
9
9
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
10
+ import { routerForConfig } from "../routing/index.js";
11
+ import { MemoryService, rememberTool } from "../memory/index.js";
10
12
  import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
11
13
  /**
12
14
  * Wrap a PromptIO so the live region yields before any prompt text lands
@@ -96,9 +98,30 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
96
98
  temperature: config.model.temperature,
97
99
  gatewayUrl: config.cruxy.gatewayUrl,
98
100
  });
101
+ // Multi-model routing (C.30): null unless routing is configured on a cruxy
102
+ // session, so the default path threads `undefined` and behaves exactly as
103
+ // before. One router is shared by the main loop, subagents, and plan mode.
104
+ const router = routerForConfig(config) ?? undefined;
99
105
  const execRegistry = buildDefaultRegistry();
100
106
  const git = getGitInfo(cwd);
101
107
  const projectInstructions = loadProjectInstructions(cwd);
108
+ // Persistent memory (C.29): register the write tool and build the recall block
109
+ // ONCE at session start. User memory is always eligible; project memory is
110
+ // recalled only when trusted (a cloned repo's notes never inject silently).
111
+ // Fully inert when disabled. Recall is best-effort — a load problem degrades to
112
+ // "no memory", never a hard failure at session start.
113
+ let recalledMemory = null;
114
+ if (config.memory.enabled) {
115
+ execRegistry.register(rememberTool);
116
+ const recall = new MemoryService({ cwd, config: config.memory }).recall();
117
+ recalledMemory = recall.block;
118
+ if (recall.projectPresentButUntrusted) {
119
+ logger.info("project memory found but not trusted — run `cruxy memory trust .` to recall it");
120
+ }
121
+ for (const e of recall.errors) {
122
+ logger.warn(`memory: excluded ${e.scope} entry ${e.id} — ${e.message}`);
123
+ }
124
+ }
102
125
  // One io shared by every prompt in the session (plan approval, the U.3 gate,
103
126
  // and any gate inside a subagent), so they all coordinate with the same live
104
127
  // region. The full wrapper stack around an ApprovalService is factored here
@@ -114,6 +137,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
114
137
  const orchestrator = new SubagentOrchestrator({
115
138
  provider,
116
139
  config,
140
+ router,
117
141
  parentRegistry: execRegistry,
118
142
  cwd,
119
143
  logger,
@@ -144,7 +168,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
144
168
  requestApproval: gate(approval),
145
169
  sandbox,
146
170
  };
147
- const planRunner = ({ messages, projectInstructions, renderer: turnRenderer, }) => runPlanSession({
171
+ const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, }) => runPlanSession({
148
172
  provider,
149
173
  config,
150
174
  ctx,
@@ -155,7 +179,9 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
155
179
  messages,
156
180
  git,
157
181
  projectInstructions,
182
+ recalledMemory: turnMemory,
158
183
  renderer: turnRenderer,
184
+ router,
159
185
  });
160
186
  return new Session({
161
187
  provider,
@@ -164,9 +190,11 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
164
190
  ctx,
165
191
  git,
166
192
  projectInstructions,
193
+ recalledMemory,
167
194
  planMode: true,
168
195
  planRunner,
169
196
  hooks,
197
+ router,
170
198
  });
171
199
  }
172
200
  const approval = new ApprovalService({
@@ -182,6 +210,8 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
182
210
  ctx,
183
211
  git,
184
212
  projectInstructions,
213
+ recalledMemory,
185
214
  hooks,
215
+ router,
186
216
  });
187
217
  }