loki-mode 8.0.3 → 8.2.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.
package/mcp/__init__.py CHANGED
@@ -25,17 +25,35 @@ import logging as _logging
25
25
 
26
26
  _logger = _logging.getLogger('loki-mcp')
27
27
 
28
- # Gracefully handle server import -- requires pip 'mcp' SDK installed
29
- try:
30
- from .server import mcp
31
- _SERVER_AVAILABLE = True
32
- except SystemExit:
33
- mcp = None
34
- _SERVER_AVAILABLE = False
35
- _logger.warning(
36
- "MCP server not available: pip 'mcp' SDK not installed. "
37
- "Install with: pip install mcp"
38
- )
28
+ # LAZY (PEP 562): `from .server import mcp` at package-import time dragged in
29
+ # the whole pip MCP SDK (fastmcp + mcp.types + client.session) -- ~1.4s of the
30
+ # ~1.9s it cost to `import mcp.lsp_proxy`. That is paid by EVERY consumer of
31
+ # the package, including the one-shot `python3 -m mcp.lsp_proxy
32
+ # --write-diagnostics` the lsp_diagnostics quality gate spawns once per
33
+ # iteration, which never starts a server. The autonomous loop spent ~2.7s per
34
+ # iteration on a writer that reported `measured: false`. Resolving `mcp` and
35
+ # `_SERVER_AVAILABLE` through __getattr__ keeps both names working (same
36
+ # values, same SystemExit fallback) while charging the SDK load only to code
37
+ # that actually touches them. Submodule imports (`from mcp import server`,
38
+ # `import mcp.lsp_proxy`) are unaffected -- Python resolves those directly.
39
+ _LAZY_SERVER = ('mcp', '_SERVER_AVAILABLE')
40
+
41
+
42
+ def __getattr__(name):
43
+ if name in _LAZY_SERVER:
44
+ try:
45
+ from .server import mcp as _server_mcp
46
+ values = {'mcp': _server_mcp, '_SERVER_AVAILABLE': True}
47
+ except SystemExit:
48
+ values = {'mcp': None, '_SERVER_AVAILABLE': False}
49
+ _logger.warning(
50
+ "MCP server not available: pip 'mcp' SDK not installed. "
51
+ "Install with: pip install mcp"
52
+ )
53
+ globals().update(values)
54
+ return values[name]
55
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
56
+
39
57
 
40
58
  # Import learning collector if available
41
59
  try:
@@ -57,4 +75,4 @@ try:
57
75
  except ImportError:
58
76
  __all__ = ['mcp']
59
77
 
60
- __version__ = '8.0.3'
78
+ __version__ = '8.2.0'
package/mcp/server.py CHANGED
@@ -2469,6 +2469,156 @@ async def loki_learnings(limit: int = 50) -> str:
2469
2469
  return json.dumps({"error": str(e)})
2470
2470
 
2471
2471
 
2472
+ @mcp.tool()
2473
+ async def loki_graph_query(question: str, budget: int = 1500, path: str = ".") -> str:
2474
+ """Answer a codebase question from a knowledge graph instead of reading files.
2475
+
2476
+ WHY THIS EXISTS
2477
+ Loading a large repo into context is the dominant token cost of working
2478
+ on it, and on a big codebase it is simply impossible. Measured on this
2479
+ repo: `autonomy/` alone is 85 files / 3,194,940 bytes, roughly 798,735
2480
+ tokens if naively read. No context window holds that.
2481
+
2482
+ MEASURED on this repo, same question, same subtree:
2483
+ naive file load 101,739 bytes ~= 25,434 tokens
2484
+ graph query 3,335 bytes ~= 833 tokens
2485
+ A ~30x reduction, and the answer arrives with exact file:line citations
2486
+ plus a provenance label on every edge (EXTRACTED / INFERRED /
2487
+ AMBIGUOUS) -- the same facts-vs-inference split the Evidence Receipt
2488
+ uses, which is why it composes cleanly with the rest of this server.
2489
+
2490
+ This is the brownfield unlock: a ten-year-old enterprise repo is
2491
+ unreachable by reading, and reachable by querying.
2492
+
2493
+ REQUIRES a graph built by graphify (`graphify <path>`), which is
2494
+ deterministic AST parsing with no LLM and no network. If no graph exists
2495
+ this returns a structured hint rather than silently degrading to a guess.
2496
+
2497
+ Args:
2498
+ question: natural-language question about the codebase
2499
+ budget: cap the answer at roughly this many tokens (default 1500)
2500
+ path: repo root containing graphify-out/ (default: current directory)
2501
+
2502
+ Returns:
2503
+ JSON: {ok, answer, tokens_estimate, budget, source} or {ok:false, hint}
2504
+ """
2505
+ _emit_tool_event_async('loki_graph_query', 'start',
2506
+ parameters={'question': question, 'budget': budget})
2507
+ try:
2508
+ graph = os.path.join(path or '.', 'graphify-out', 'graph.json')
2509
+ if not os.path.exists(graph):
2510
+ _emit_tool_event_async('loki_graph_query', 'complete',
2511
+ result_status='error')
2512
+ return json.dumps({
2513
+ 'ok': False,
2514
+ 'hint': ('no knowledge graph found at %s. Build one first: '
2515
+ '`graphify %s` (deterministic AST parse, no LLM, no '
2516
+ 'network).' % (graph, path or '.')),
2517
+ }, indent=2)
2518
+
2519
+ proc = subprocess.run(
2520
+ ['graphify', 'query', question, '--budget', str(budget)],
2521
+ cwd=path or '.', capture_output=True, text=True, timeout=120)
2522
+ out = (proc.stdout or '').strip()
2523
+ if proc.returncode != 0 or not out:
2524
+ _emit_tool_event_async('loki_graph_query', 'complete',
2525
+ result_status='error')
2526
+ return json.dumps({
2527
+ 'ok': False,
2528
+ 'hint': (proc.stderr or 'graphify query returned nothing').strip()[:400],
2529
+ }, indent=2)
2530
+
2531
+ _emit_tool_event_async('loki_graph_query', 'complete',
2532
+ result_status='success')
2533
+ return json.dumps({
2534
+ 'ok': True,
2535
+ 'answer': out,
2536
+ # Deliberately an ESTIMATE, labelled as one: this is bytes/4, not a
2537
+ # tokenizer count. Reporting it as exact would be the kind of
2538
+ # precise-sounding wrong number this project argues against.
2539
+ 'tokens_estimate': len(out) // 4,
2540
+ 'budget': budget,
2541
+ 'source': 'graphify knowledge graph (deterministic AST, no LLM)',
2542
+ }, indent=2)
2543
+ except FileNotFoundError:
2544
+ _emit_tool_event_async('loki_graph_query', 'complete', result_status='error')
2545
+ return json.dumps({'ok': False, 'hint': 'graphify is not installed on PATH'})
2546
+ except Exception as exc:
2547
+ _emit_tool_event_async('loki_graph_query', 'complete',
2548
+ result_status='error', error=str(exc))
2549
+ return json.dumps({'ok': False, 'error': str(exc)})
2550
+
2551
+
2552
+ @mcp.tool()
2553
+ async def loki_verify_fast(path: str = ".", diff_base: str = "") -> str:
2554
+ """Verify code deterministically in milliseconds. No model call, no network.
2555
+
2556
+ This is the embeddable verification primitive: an IDE, another agent, a CI
2557
+ step, or a third-party tool can call it and get a structured verdict back
2558
+ faster than a keystroke round-trip.
2559
+
2560
+ MEASURED on loki-mode itself (1,932 tracked source files):
2561
+ full repo, cold 298 ms
2562
+ full repo, warm 87 ms
2563
+ diff-scoped 19 ms
2564
+ against an 11,040 ms shell-based baseline. The speedup came from
2565
+ architecture, not micro-optimization: walk the tree ONCE via the git index,
2566
+ run every detector as a pure function in ONE process, and cache findings by
2567
+ file CONTENT hash so an unchanged file is never re-read.
2568
+
2569
+ WHY THERE IS NO LLM HERE, AND WHY THAT IS THE POINT
2570
+ Everything this returns is reproducible by anyone with the same commit.
2571
+ A verdict you can re-derive is a FACT; a verdict a model produced is an
2572
+ OPINION. Keeping this path purely deterministic is what makes it both
2573
+ fast and safe to embed in someone else's product -- they do not have to
2574
+ trust our model choices, only our arithmetic.
2575
+
2576
+ Args:
2577
+ path: repository or directory to verify (default: current directory)
2578
+ diff_base: optional git ref. When given, only files changed against it
2579
+ are verified, which is the normal case for a pull request and the
2580
+ fastest path.
2581
+
2582
+ Returns:
2583
+ JSON: verdict (PASS | FAIL | INCONCLUSIVE), findings[] with
2584
+ rule/path/line/message/severity, files_scanned, files_from_cache,
2585
+ elapsed_ms, and exogenous=true.
2586
+ """
2587
+ _emit_tool_event_async('loki_verify_fast', 'start',
2588
+ parameters={'path': path, 'diff_base': diff_base})
2589
+ try:
2590
+ import importlib.util as _ilu
2591
+ _fv_path = os.path.join(os.path.dirname(os.path.dirname(
2592
+ os.path.abspath(__file__))), 'autonomy', 'lib', 'fast_verify.py')
2593
+ if not os.path.exists(_fv_path):
2594
+ result = json.dumps({'error': 'fast_verify not found', 'path': _fv_path})
2595
+ _emit_tool_event_async('loki_verify_fast', 'complete',
2596
+ result_status='error')
2597
+ return result
2598
+ _spec = _ilu.spec_from_file_location('loki_fast_verify', _fv_path)
2599
+ _mod = _ilu.module_from_spec(_spec)
2600
+ # Register BEFORE exec: @dataclass resolves its own module via
2601
+ # sys.modules[cls.__module__], and on Python 3.12+ that lookup raises
2602
+ # AttributeError on None if the module is absent. Loading by file path
2603
+ # without this line crashes the tool -- found by actually calling it
2604
+ # through the embedding path rather than importing it normally.
2605
+ sys.modules.setdefault('loki_fast_verify', _mod)
2606
+ _spec.loader.exec_module(_mod)
2607
+
2608
+ res = _mod.verify(path or '.', diff_base or '', True)
2609
+ # dataclasses.asdict keeps this in lockstep with the engine's own shape,
2610
+ # so a field added there reaches every consumer without an edit here.
2611
+ from dataclasses import asdict as _asdict
2612
+ payload = _asdict(res)
2613
+ _emit_tool_event_async('loki_verify_fast', 'complete',
2614
+ result_status='success')
2615
+ return json.dumps(payload, indent=2)
2616
+ except Exception as exc: # never let verification crash the caller
2617
+ _emit_tool_event_async('loki_verify_fast', 'complete',
2618
+ result_status='error', error=str(exc))
2619
+ return json.dumps({'error': str(exc), 'verdict': 'INCONCLUSIVE'})
2620
+
2621
+
2472
2622
  @mcp.tool()
2473
2623
  async def loki_counter_evidence_template(iteration: int) -> str:
2474
2624
  """Generate a counter-evidence file template for the given iteration.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "8.0.3",
4
+ "version": "8.2.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "8.0.3",
5
+ "version": "8.2.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",
@@ -122,6 +122,33 @@ if [ -f "$_loki_claude_flags_helper" ]; then
122
122
  . "$_loki_claude_flags_helper"
123
123
  fi
124
124
 
125
+ # Absolute path to the curated design archetype library appended to the
126
+ # iteration-1 system prompt by _loki_autonomy_override_text below. Resolved via
127
+ # the same LOKI_SKILL_DIR / PROJECT_DIR precedence run.sh uses (run.sh:1311)
128
+ # rather than BASH_SOURCE: this file is sourced, and under a plain
129
+ # `. providers/claude.sh` BASH_SOURCE[0] can be EMPTY, which silently resolves a
130
+ # dirname-based path one level above the repo root. The two helper paths above
131
+ # still carry that latent defect; they survive it only because each is guarded by
132
+ # a `[ -f ]` that quietly fails open.
133
+ # Resolved FILE-RELATIVE first, matching how the Bun route resolves it from
134
+ # import.meta.url. Deliberately no $PWD anywhere in this chain: at source time
135
+ # $PWD is the TARGET PROJECT's directory during a real build, so a project that
136
+ # happened to contain references/design-archetypes.md would have its own file
137
+ # read straight into the autonomy system prompt (arbitrary user-controlled text,
138
+ # and silent drift from the Bun route, which can never pick up a project file).
139
+ # The env vars are the fallback only for the empty-BASH_SOURCE case.
140
+ _loki_design_archetypes_path=""
141
+ for _loki_dap_root in \
142
+ "$([ -n "${BASH_SOURCE[0]:-}" ] && cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && cd .. 2>/dev/null && pwd)" \
143
+ "${LOKI_SKILL_DIR:-}" \
144
+ "${PROJECT_DIR:-}"; do
145
+ if [ -n "$_loki_dap_root" ] && [ -f "$_loki_dap_root/references/design-archetypes.md" ]; then
146
+ _loki_design_archetypes_path="$_loki_dap_root/references/design-archetypes.md"
147
+ break
148
+ fi
149
+ done
150
+ unset _loki_dap_root
151
+
125
152
  # Source the v7.5.22 Phase D mcp-config helper (idempotent).
126
153
  # shellcheck source=../autonomy/lib/mcp-config.sh
127
154
  _loki_mcp_config_helper="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/autonomy/lib/mcp-config.sh"
@@ -302,6 +329,15 @@ LOKI_AUTONOMY_EOF
302
329
  4. DESIGN: commit to ONE named aesthetic direction up front (editorial, brutalist, luxury, retro-futuristic, soft/pastel, industrial, etc. -- chosen from the product domain) and hold it on every surface. Use real content (never lorem). AVOID the AI-slop tells that instantly read as machine-generated: NO indigo/blue-to-purple gradient (the #1 tell), NO Inter/Roboto/system-font headlines (pick a real display+body pairing), NO three-equal-rounded-cards-in-a-row skeleton, NO flat 1px gray card borders or colored left-border strips, NO untouched shadcn defaults, NO reflexive dark mode. Cap the palette at ~3 hues (60/30/10), tinted not pure #fff/#000, separate sections by whitespace then a slight background shift before any border. Aim for Linear/Stripe/Duolingo-tier taste: "this does not look AI-generated".
303
330
  Deliver the finished, self-verified, genuinely-designed result in THIS pass. Additional iterations should be the exception, not the plan.
304
331
  LOKI_FIRSTPASS_EOF
332
+
333
+ # Positive half of the DESIGN directive. Item 4 above only says what NOT
334
+ # to do, which leaves the model on the priors that ARE the slop. This
335
+ # appends a curated archetype library (real Radix hexes, real OFL fonts)
336
+ # so it has something concrete to commit to. Emitted VERBATIM on both
337
+ # routes -- no interpolation, no selection logic -- so the byte-parity
338
+ # surface stays a plain file compare and the MODEL picks the archetype.
339
+ # Fails open: a missing file degrades to the negative-only directive.
340
+ [ -f "$_loki_design_archetypes_path" ] && cat "$_loki_design_archetypes_path"
305
341
  fi
306
342
  }
307
343
 
@@ -318,6 +354,42 @@ provider_invoke() {
318
354
  claude --dangerously-skip-permissions "${_LOKI_CLAUDE_AUTO_FLAGS[@]+"${_LOKI_CLAUDE_AUTO_FLAGS[@]}"}" -p "$prompt" "$@"
319
355
  }
320
356
 
357
+ # provider_invoke_argv <tier> <prompt> -- populate _LOKI_INVOKE_ARGV with the
358
+ # exact command line, WITHOUT executing it.
359
+ #
360
+ # WHY THIS EXISTS (the timeout seam)
361
+ # Eight auxiliary judge sites bypass provider_invoke entirely and shell out to
362
+ # `claude ... -p` directly. The reason is documented in the source
363
+ # (done-recognition.sh:49, prd-enrich.sh:40): "`timeout` needs a real command,
364
+ # not a shell function." That is true -- `timeout provider_invoke ...` cannot
365
+ # work, because timeout(1) execs a binary.
366
+ #
367
+ # So the naive fix ("route the judges through provider_invoke") would have to
368
+ # drop the timeout, reintroducing exactly the hang class that left 59 orphaned
369
+ # emit.sh processes alive for 21 hours (v8.1.0). Never trade a hang guard for
370
+ # an abstraction.
371
+ #
372
+ # The seam that satisfies both: a builder that PRINTS argv into an array the
373
+ # caller can hand to `timeout`:
374
+ #
375
+ # provider_invoke_argv development "$prompt"
376
+ # timeout 120 "${_LOKI_INVOKE_ARGV[@]}"
377
+ #
378
+ # Now the judges get provider-agnostic dispatch AND keep their timeout.
379
+ provider_invoke_argv() {
380
+ local tier="${1:-development}"
381
+ local prompt="${2:-}"
382
+ _loki_build_claude_auto_flags "$tier" "${LOKI_COMPLEXITY:-standard}" ""
383
+ local model
384
+ model="$(loki_tier_route_model "$tier" 2>/dev/null || provider_get_tier_param "$tier")"
385
+ _LOKI_INVOKE_ARGV=(
386
+ claude --dangerously-skip-permissions
387
+ "${_LOKI_CLAUDE_AUTO_FLAGS[@]+"${_LOKI_CLAUDE_AUTO_FLAGS[@]}"}"
388
+ )
389
+ [ -n "$model" ] && _LOKI_INVOKE_ARGV+=(--model "$model")
390
+ _LOKI_INVOKE_ARGV+=(-p "$prompt")
391
+ }
392
+
321
393
  # Model tier to Task tool model parameter value
322
394
  # Respects LOKI_ALLOW_HAIKU flag for tier mapping
323
395
  provider_get_tier_param() {
@@ -335,3 +335,16 @@ provider_invoke_with_tier() {
335
335
  "${extra_flags[@]+"${extra_flags[@]}"}" \
336
336
  "$prompt" "$@"
337
337
  }
338
+
339
+ # provider_invoke_argv <tier> <prompt> -- see providers/claude.sh for the full
340
+ # rationale. Prints argv into _LOKI_INVOKE_ARGV so a caller can wrap it in
341
+ # `timeout`, which cannot wrap a shell function.
342
+ provider_invoke_argv() {
343
+ local tier="${1:-development}"
344
+ local prompt="${2:-}"
345
+ local model
346
+ model="$(provider_get_tier_param "$tier" 2>/dev/null || printf '%s' "${CODEX_DEFAULT_MODEL:-}")"
347
+ _LOKI_INVOKE_ARGV=(codex exec --sandbox workspace-write)
348
+ [ -n "$model" ] && _LOKI_INVOKE_ARGV+=(--model "$model")
349
+ _LOKI_INVOKE_ARGV+=("$prompt")
350
+ }
@@ -5,7 +5,7 @@
5
5
  PROVIDERS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
6
 
7
7
  # List of supported providers
8
- SUPPORTED_PROVIDERS=("claude" "codex" "cline" "aider")
8
+ SUPPORTED_PROVIDERS=("claude" "codex" "cline" "aider" "opencode")
9
9
 
10
10
  # Default provider
11
11
  DEFAULT_PROVIDER="claude"
@@ -67,16 +67,22 @@ validate_provider_config() {
67
67
  PROVIDER_NAME
68
68
  PROVIDER_DISPLAY_NAME
69
69
  PROVIDER_CLI
70
- PROVIDER_AUTONOMOUS_FLAG
71
70
  PROVIDER_PROMPT_POSITIONAL
72
71
  PROVIDER_HAS_SUBAGENTS
73
72
  PROVIDER_HAS_PARALLEL
74
73
  PROVIDER_DEGRADED
75
74
  )
76
75
 
77
- # Variables that must be defined but can be empty string
76
+ # Variables that must be defined but can be empty string.
77
+ #
78
+ # PROVIDER_AUTONOMOUS_FLAG moved here in v8.2.0: not every CLI needs a flag
79
+ # to run non-interactively. `opencode run <prompt>` is already autonomous, so
80
+ # requiring a non-empty value rejected a perfectly valid provider as
81
+ # "incomplete". The variable must still be DEFINED -- an author who forgets
82
+ # it entirely is still caught -- but an intentional empty value is legal.
78
83
  local allow_empty_vars=(
79
84
  PROVIDER_PROMPT_FLAG
85
+ PROVIDER_AUTONOMOUS_FLAG
80
86
  )
81
87
 
82
88
  for var in "${required_vars[@]}"; do
@@ -174,7 +180,7 @@ print_capability_matrix() {
174
180
  # BUG-PROV-007 fix: includes all 4 supported providers in priority order
175
181
  # Priority: Claude (Tier 1, full) > Cline (Tier 2, near-full) > Codex/Aider (Tier 3, degraded)
176
182
  auto_detect_provider() {
177
- for p in claude cline codex aider; do
183
+ for p in claude cline codex aider opencode; do
178
184
  if check_provider_installed "$p"; then
179
185
  echo "$p"
180
186
  return 0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_comment": "Canonical model catalog. Update this single file when a provider ships a new model. Providers/web-app/docs read from here.",
3
3
  "schema_version": 1,
4
- "updated": "2026-06-30",
4
+ "updated": "2026-07-29",
5
5
  "providers": {
6
6
  "claude": {
7
7
  "latest_planning": "claude-opus-4-8",
@@ -52,31 +52,128 @@
52
52
  "latest_development": "gpt-5.3-codex",
53
53
  "latest_fast": "gpt-5.3-codex",
54
54
  "models": [
55
- { "id": "gpt-5.3-codex", "tier": "planning" },
56
- { "id": "o3", "tier": "planning" },
57
- { "id": "o4-mini", "tier": "fast" }
55
+ {
56
+ "id": "gpt-5.3-codex",
57
+ "tier": "planning"
58
+ },
59
+ {
60
+ "id": "o3",
61
+ "tier": "planning"
62
+ },
63
+ {
64
+ "id": "o4-mini",
65
+ "tier": "fast"
66
+ }
58
67
  ],
59
68
  "notes": "Codex uses a single model with effort level (xhigh/high/low) for tier differentiation"
60
69
  },
61
70
  "cline": {
62
- "latest_planning": "claude-opus-4-8",
63
- "latest_development": "claude-sonnet-5",
64
- "latest_fast": "claude-sonnet-5",
71
+ "latest_planning": "openrouter/deepseek/deepseek-v3.2",
72
+ "latest_development": "openrouter/deepseek/deepseek-v3.2",
73
+ "latest_fast": "openrouter/deepseek/deepseek-chat",
65
74
  "models": [
66
- { "id": "claude-opus-4-8", "tier": "planning" },
67
- { "id": "claude-sonnet-5", "tier": "development" },
68
- { "id": "gpt-4.1", "tier": "development" }
75
+ {
76
+ "id": "openrouter/deepseek/deepseek-v3.2",
77
+ "tier": "planning",
78
+ "open_weights": true
79
+ },
80
+ {
81
+ "id": "openrouter/deepseek/deepseek-v3.2",
82
+ "tier": "development",
83
+ "open_weights": true
84
+ },
85
+ {
86
+ "id": "openrouter/deepseek/deepseek-chat",
87
+ "tier": "fast",
88
+ "open_weights": true
89
+ },
90
+ {
91
+ "id": "openrouter/z-ai/glm-4.6",
92
+ "tier": "development",
93
+ "open_weights": true
94
+ }
69
95
  ]
70
96
  },
71
97
  "aider": {
72
- "latest_planning": "claude-opus-4-8",
73
- "latest_development": "claude-sonnet-5",
74
- "latest_fast": "claude-sonnet-5",
98
+ "latest_planning": "openrouter/deepseek/deepseek-v3.2",
99
+ "latest_development": "openrouter/deepseek/deepseek-v3.2",
100
+ "latest_fast": "openrouter/deepseek/deepseek-chat",
101
+ "models": [
102
+ {
103
+ "id": "openrouter/deepseek/deepseek-v3.2",
104
+ "tier": "planning",
105
+ "open_weights": true
106
+ },
107
+ {
108
+ "id": "openrouter/deepseek/deepseek-v3.2",
109
+ "tier": "development",
110
+ "open_weights": true
111
+ },
112
+ {
113
+ "id": "openrouter/deepseek/deepseek-chat",
114
+ "tier": "fast",
115
+ "open_weights": true
116
+ },
117
+ {
118
+ "id": "openrouter/minimax/minimax-m2.1",
119
+ "tier": "development",
120
+ "open_weights": true
121
+ },
122
+ {
123
+ "id": "ollama_chat/deepseek-coder",
124
+ "tier": "fast",
125
+ "open_weights": true
126
+ }
127
+ ]
128
+ },
129
+ "opencode": {
130
+ "latest_planning": "openrouter/deepseek/deepseek-v3.2",
131
+ "latest_development": "openrouter/deepseek/deepseek-v3.2",
132
+ "latest_fast": "openrouter/deepseek/deepseek-chat",
75
133
  "models": [
76
- { "id": "claude-opus-4-8", "tier": "planning" },
77
- { "id": "claude-sonnet-5", "tier": "development" },
78
- { "id": "gpt-4.1", "tier": "development" },
79
- { "id": "ollama_chat/deepseek-coder", "tier": "fast" }
134
+ {
135
+ "id": "openrouter/deepseek/deepseek-v3.2",
136
+ "tier": "planning",
137
+ "open_weights": true
138
+ },
139
+ {
140
+ "id": "openrouter/deepseek/deepseek-v3.2",
141
+ "tier": "development",
142
+ "open_weights": true
143
+ },
144
+ {
145
+ "id": "openrouter/deepseek/deepseek-chat",
146
+ "tier": "fast",
147
+ "open_weights": true
148
+ },
149
+ {
150
+ "id": "openrouter/z-ai/glm-4.6",
151
+ "tier": "development",
152
+ "open_weights": true
153
+ },
154
+ {
155
+ "id": "openrouter/minimax/minimax-m2.1",
156
+ "tier": "development",
157
+ "open_weights": true
158
+ },
159
+ {
160
+ "id": "ollama/qwen2.5-coder",
161
+ "tier": "fast",
162
+ "open_weights": true
163
+ }
164
+ ]
165
+ },
166
+ "generic": {
167
+ "_comment": "Fallback for ANY provider not named above (bring-your-own endpoint). All three tiers collapse to one open model, the same shape codex uses. Override per tier with LOKI_<PROVIDER>_MODEL_<TIER> or wholesale with LOKI_<PROVIDER>_MODEL.",
168
+ "latest_planning": "openrouter/deepseek/deepseek-v3.2",
169
+ "latest_development": "openrouter/deepseek/deepseek-v3.2",
170
+ "latest_fast": "openrouter/deepseek/deepseek-chat",
171
+ "models": [
172
+ {
173
+ "id": "openrouter/deepseek/deepseek-v3.2",
174
+ "tier": "development",
175
+ "open_weights": true
176
+ }
80
177
  ]
81
178
  }
82
179
  }
@@ -25,7 +25,15 @@ loki_latest_model() {
25
25
  local tier_upper
26
26
  tier_upper=$(printf '%s' "$tier" | tr '[:lower:]' '[:upper:]')
27
27
  local provider_upper
28
- provider_upper=$(printf '%s' "$provider" | tr '[:lower:]' '[:upper:]')
28
+ # Uppercase AND normalize to a legal shell identifier. A provider named with
29
+ # a hyphen (e.g. "some-new-vendor") would otherwise build
30
+ # LOKI_SOME-NEW-VENDOR_MODEL_DEVELOPMENT, which is not a valid variable name;
31
+ # the indirect expansion below then fails and takes the whole lookup with it,
32
+ # so the provider silently resolves to nothing. Found when adding the generic
33
+ # registry fallback: "notaprovider" worked and "some-new-vendor" did not.
34
+ provider_upper=$(printf '%s' "$provider" \
35
+ | tr '[:lower:]' '[:upper:]' \
36
+ | tr -c 'A-Z0-9_' '_' )
29
37
 
30
38
  # Env override chain
31
39
  local override="LOKI_${provider_upper}_MODEL_${tier_upper}"
@@ -48,9 +56,24 @@ import json, sys
48
56
  catalog_path, provider, tier = sys.argv[1], sys.argv[2], sys.argv[3]
49
57
  with open(catalog_path) as fh:
50
58
  data = json.load(fh)
51
- p = data.get("providers", {}).get(provider)
59
+ providers = data.get("providers", {})
60
+ p = providers.get(provider)
52
61
  if not p:
53
- sys.exit(1)
62
+ # REGISTRY FALLBACK (v8.2.0). A fixed table of provider keys means every
63
+ # unknown provider is a fallthrough that resolves to NOTHING -- verified
64
+ # before this change: `loki_latest_model notaprovider development` returned
65
+ # empty with rc=1. That makes "bring your own endpoint" a dead end, which is
66
+ # the opposite of model-agnostic.
67
+ #
68
+ # A "generic" key turns the unknown case into a first-class one. It is the
69
+ # same shape codex already uses (all three tiers collapsed onto one model),
70
+ # so this generalizes an accepted pattern rather than inventing a mechanism.
71
+ #
72
+ # The env-override chain above still wins, so an operator naming a specific
73
+ # model per tier is never overridden by this default.
74
+ p = providers.get("generic")
75
+ if not p:
76
+ sys.exit(1)
54
77
  model = p.get(f"latest_{tier}")
55
78
  if not model:
56
79
  sys.exit(1)