agentlas 0.9.5 → 0.9.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.7 — 2026-07-25
4
+
5
+ - Fix: the resident judge now reaches API/Ollama/BYOK runtimes, not only CLI
6
+ subprocess runtimes. Previously, when your connected runtime was Ollama or a
7
+ BYOK API model, every route, intent, and classification silently used the
8
+ deterministic wordlist fallback because the judge was wired to null — so a
9
+ non-English request the keyword lists could not read never got a model
10
+ verdict. The judge now runs on whatever runtime you actually have connected,
11
+ and its timeout signal aborts the underlying request cleanly.
12
+ - Fix: the judge is installed at startup, before the first routing decision.
13
+ It was previously wired only inside the run turn, which happens after routing,
14
+ so the very first auto-route in a one-shot always fell back.
15
+ - Routing gives the model a more generous deadline (a one-shot pre-run gate), so
16
+ a slower local model is judged rather than frequently falling back. When it
17
+ still cannot answer in time, the route receipt says so explicitly.
18
+
19
+ ## 0.9.6 — 2026-07-25
20
+
21
+ - Agent and App Builder routing is now decided by the connected model: lexical
22
+ scores only recruit candidates, and the model can route a request the keyword
23
+ lists never matched (any language). The App Builder consent handshake is
24
+ unchanged, and every route receipt says whether the connected model or the
25
+ deterministic fallback decided.
26
+ - Whether an agent produces images (and therefore which runtime runs it) is now
27
+ judged by the connected model from the agent's own identity, with the old
28
+ keyword list demoted to reference hints. Conservative non-image vetoes stay.
29
+ - Task classification, routing, and image judgments all fail over to the
30
+ previous deterministic behavior — explicitly labeled — when no connected
31
+ model is available.
32
+ - Publication gates pin the Agentlas OS v1.1.61 runtime commit, which ships the
33
+ same judgment-engine migration across the bundled Core runtime.
34
+
3
35
  ## 0.9.4 — 2026-07-23
4
36
 
5
37
  - `plugin add` no longer registers a code-hosting page (GitHub/GitLab/Bitbucket
@@ -63,7 +63,10 @@ const IMAGE_TOOL_MARKERS = [/nano-?banana/i, /\bimagen\b/i, /gpt-image/i, /grok\
63
63
  // 겹치는 정규식 여러 개를 동시에 때려도 1클러스터다.
64
64
  const MIN_BODY_IMAGE_SENTENCES = 3;
65
65
  const BODY_SCAN_CAP = 16000; // 로컬 임포트 상한과 동일 — 클라우드 무제한 프롬프트의 전문 스캔 방지
66
- function needsImage(agent) {
66
+ // 어휘 판정 — 연결 모델이 없을 때의 결정적 폴백이자, 모델 판정 전의 참고 프리필터.
67
+ // 하우스 룰: 단어목록은 최종 결정을 내리지 않는다. 최종 판정은 resolveNeedsImage가
68
+ // 상주 판정 서비스(judgeLabels)에 묻고, 이 함수는 그 폴백으로만 살아남는다.
69
+ function needsImageLexical(agent) {
67
70
  if (!agent) return false;
68
71
  if (NON_IMAGE_ROLES.has(String(agent.role || "").toLowerCase())) return false;
69
72
  // 정체성 존은 사용자가 선언한 이름/태그라인만 — slug는 폴더명에서 기계 파생되므로
@@ -87,6 +90,82 @@ function needsImage(agent) {
87
90
  return false;
88
91
  }
89
92
 
93
+ // ── 상주 판정 서비스 배선 — 모델이 의미로 최종 판정, IMAGE_HINTS는 참고 힌트 ──────
94
+ // needsImage 호출자(REPL 배지·autoRuntimeFor·routingNote)는 동기라서 warm-cache 패턴:
95
+ // 비동기 경로(resolveNeedsImage)가 먼저 판정해 캐시를 데우고, 동기 needsImage는 캐시만
96
+ // 읽는다. 캐시 미스 = 어휘 폴백 그대로 — imageJudgmentSource가 어느 쪽이었는지 라벨한다.
97
+ const IMAGE_VERDICT_CACHE_MAX = 200;
98
+ const imageVerdicts = new Map();
99
+ function imageJudgeInput(agent) {
100
+ const identity = [agent.slug, agent.name, agent.name_en, agent.tagline, agent.tagline_en].filter(Boolean).join(" | ");
101
+ return `${identity}\n---\n${String(agent.system_prompt || "").slice(0, 6000)}`;
102
+ }
103
+ // 역할/팀 베토는 보수적 하드 가드로 유지 — 조율 두뇌가 부서 소개 문장("Design HQ")으로
104
+ // 이미지 팀이 되던 사고(vibecoder/appbridge)는 모델 판정 대상에서 아예 뺀다.
105
+ function imageJudgeVetoed(agent) {
106
+ if (!agent) return true;
107
+ if (NON_IMAGE_ROLES.has(String(agent.role || "").toLowerCase())) return true;
108
+ if (String(agent.entity_kind || "").toLowerCase() === "team") return true;
109
+ return false;
110
+ }
111
+ // 이 에이전트의 직무가 이미지 생산인지를 연결 모델이 의미로 판정한다.
112
+ // 러너 없음/타임아웃/정크 → 어휘 판정을 "fallback"으로 라벨해 반환 (조용한 회귀 금지).
113
+ async function resolveNeedsImage(agent) {
114
+ const lexical = needsImageLexical(agent);
115
+ if (imageJudgeVetoed(agent)) return { image: lexical, source: "deterministic" };
116
+ let judgment;
117
+ try {
118
+ judgment = require("./agentlas-judgment.cjs");
119
+ } catch {
120
+ judgment = null;
121
+ }
122
+ if (!judgment || !judgment.hasJudgmentRunner()) return { image: lexical, source: "fallback" };
123
+ const input = imageJudgeInput(agent);
124
+ const cached = imageVerdicts.get(input);
125
+ if (cached) return cached;
126
+ const verdict = await judgment.judgeLabels({
127
+ kind: "agent-produces-images",
128
+ question:
129
+ "Does this agent's OWN job include producing images (generating or designing visual assets such as thumbnails, banners, logos, posters, product shots)?",
130
+ labels: ["image", "not-image"],
131
+ multi: false,
132
+ input,
133
+ hints: { image: IMAGE_HINTS.map((re) => re.source) },
134
+ guidance:
135
+ "Judge the agent's role from its identity and instructions, in any language. Mentioning images is not " +
136
+ "producing them: builders, orchestrators, PMs, curators, and coordination brains that commission or " +
137
+ "delegate image work are 'not-image'. Refusals or prohibitions ('never generate images') declare the " +
138
+ "opposite of a capability.",
139
+ fallback: [lexical ? "image" : "not-image"],
140
+ });
141
+ if (verdict.source !== "llm" || !verdict.labels.length) return { image: lexical, source: "fallback" };
142
+ const out = { image: verdict.labels[0] === "image", source: "llm", reason: verdict.reason || "" };
143
+ imageVerdicts.set(input, out);
144
+ if (imageVerdicts.size > IMAGE_VERDICT_CACHE_MAX) {
145
+ const oldest = imageVerdicts.keys().next().value;
146
+ if (oldest !== undefined) imageVerdicts.delete(oldest);
147
+ }
148
+ return out;
149
+ }
150
+ // Does this agent's job involve generating/handling images? Sync surface for badges and
151
+ // autoRuntimeFor: model verdict from the warm cache wins; miss = deterministic lexical fallback.
152
+ function needsImage(agent) {
153
+ if (!agent) return false;
154
+ if (!imageJudgeVetoed(agent)) {
155
+ const cached = imageVerdicts.get(imageJudgeInput(agent));
156
+ if (cached) return cached.image;
157
+ }
158
+ return needsImageLexical(agent);
159
+ }
160
+ // 라벨용 — 이 에이전트의 현재 이미지 판정이 모델("llm")인지 결정적 경로("deterministic")인지.
161
+ function imageJudgmentSource(agent) {
162
+ if (agent && !imageJudgeVetoed(agent) && imageVerdicts.has(imageJudgeInput(agent))) return "llm";
163
+ return "deterministic";
164
+ }
165
+ function clearImageJudgments() {
166
+ imageVerdicts.clear();
167
+ }
168
+
90
169
  // Auto-pick a runtime spec for an agent given installed CLI kinds and the session default spec.
91
170
  // Image agents route to an installed image-capable runtime; otherwise keep the session default.
92
171
  function autoRuntimeFor(agent, { installedKinds, activeSpec }) {
@@ -103,4 +182,17 @@ function badge(spec) {
103
182
  return c.image ? "🖼" : "";
104
183
  }
105
184
 
106
- module.exports = { RUNTIME_CAPS, CLI_KINDS, capsFor, specOf, runtimeFromSpec, needsImage, autoRuntimeFor, badge };
185
+ module.exports = {
186
+ RUNTIME_CAPS,
187
+ CLI_KINDS,
188
+ capsFor,
189
+ specOf,
190
+ runtimeFromSpec,
191
+ needsImage,
192
+ needsImageLexical,
193
+ resolveNeedsImage,
194
+ imageJudgmentSource,
195
+ clearImageJudgments,
196
+ autoRuntimeFor,
197
+ badge,
198
+ };
@@ -97,22 +97,20 @@ const SEMVER_RE = /^v?[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/;
97
97
  const ENV_RE = /^[A-Z][A-Z0-9_]*$/;
98
98
  const SAFE_IDEMPOTENCY_RE = /^[A-Za-z0-9._:-]{8,200}$/;
99
99
 
100
- const SECRET_PATTERNS = [
101
- /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/i,
102
- /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/i,
103
- /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/,
104
- /\bAKIA[0-9A-Z]{16}\b/,
105
- /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i,
106
- /\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|private[_-]?key|cookie)\b\s*[:=]\s*['"]?[^\s'"]{8,}/i,
107
- /\bauthorization\b\s*[:=]\s*['"]?(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/i,
108
- ];
100
+ const { SECRET_PATTERNS } = require("./agentlas-secret-patterns.cjs");
109
101
  const PII_PATTERNS = [
110
102
  /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
111
103
  /(?<!\w)(?:\+?\d[\d ().-]{8,}\d)(?!\w)/,
112
104
  /\b(?:account|customer|client|tenant|workspace|user)[ _-]?(?:id|key|number|no)\s*[:=#]?\s*[A-Za-z0-9_-]{4,}\b|(?:계정|고객|사용자)[ _-]?(?:id|아이디|번호)\s*[:=#]?\s*[A-Za-z0-9_-]{4,}/i,
113
105
  ];
106
+ // Absolute LOCAL paths and file URLs only. The previous alternation matched any
107
+ // slash-containing token, so ordinary prose lost its experience: "TCP/IP", "read/write",
108
+ // "and/or", and web routes like "GET /api/users" were all reported as a local path. A
109
+ // leading-slash path now has to look like a real filesystem root (or start from a home /
110
+ // relative marker, a Windows drive, or a UNC share); a lone `/word` — which is what a web
111
+ // route looks like — no longer counts, and neither does `word/word` inside a sentence.
114
112
  const LOCAL_PATH_PATTERNS = [
115
- /(?:file:\/\/|(?:^|[\s"'`()\[\]{}=:,;])(?:\.\.[/\\]|~[/\\]|\/(?!\/|\s)(?:[^/\s"'`<>]+\/)*[^/\s"'`<>]+|[A-Za-z]:[/\\]\S+|\\\\[^\\/\s]+[\\/][^\\/\s]+))/i,
113
+ /(?:file:\/\/|(?:^|[\s"'`()\[\]{}=:,;])(?:\.\.[/\\]|~[/\\]|\/(?:Users|home|root|private|var|tmp|opt|etc|srv|mnt|media|Volumes|Applications|System|Library|usr)\/[^\s"'`<>]+|[A-Za-z]:[/\\]\S+|\\\\[^\\/\s]+[\\/][^\\/\s]+))/i,
116
114
  ];
117
115
  const RAW_INTERACTION_PATTERNS = [
118
116
  /(?:^|\n)\s*(?:system|assistant|user|tool|customer|agent)\s*:\s+/i,
@@ -1530,7 +1528,13 @@ function isCanonicalTaskId(value) {
1530
1528
  function keywordOccurs(normalizedPrompt, rawKeyword) {
1531
1529
  const keyword = normalizeClassificationText(rawKeyword);
1532
1530
  if (!keyword) return false;
1533
- if (/[가-힣]/.test(keyword)) return normalizedPrompt.includes(keyword);
1531
+ if (/[가-힣]/.test(keyword)) {
1532
+ // Korean has no word boundary, so a raw includes() matched inside longer compounds:
1533
+ // 번역 hit 번역기, 금융 hit compound finance words, 영업 hit 영업일 (business day).
1534
+ // Require the keyword not be glued to another Hangul syllable on either side.
1535
+ const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1536
+ return new RegExp(`(?<![가-힣])${escaped}(?![가-힣])`).test(normalizedPrompt);
1537
+ }
1534
1538
  return ` ${normalizedPrompt} `.includes(` ${keyword} `);
1535
1539
  }
1536
1540
 
@@ -1557,6 +1561,56 @@ function deriveCanonicalTaskClasses(prompt, options = {}) {
1557
1561
  return { taskIds: matches, source: "deterministic-keyword-map", matchedTaskClasses: matches, invalidDeclaredCount: 0 };
1558
1562
  }
1559
1563
 
1564
+ /**
1565
+ * Meaning-aware task classification. The resident judge decides; TASK_CLASS_KEYWORDS is
1566
+ * passed as reference hints only. This is what a keyword map cannot do: Korean compounds
1567
+ * (번역 inside 번역기), particle inflection (고객 문의를), dialect, slang, and any other
1568
+ * language all hinge on meaning, and no list enumerates them.
1569
+ *
1570
+ * An explicit declared task class still wins outright (it is data, not a guess), and with
1571
+ * no connected model the deterministic keyword prefilter is the fallback so classification
1572
+ * never stops working.
1573
+ */
1574
+ async function resolveCanonicalTaskClasses(prompt, options = {}) {
1575
+ const declaredRaw = options.declaredTaskClasses ?? options.declaredTaskClass;
1576
+ if (declaredRaw != null && (Array.isArray(declaredRaw) ? declaredRaw.length : String(declaredRaw).trim())) {
1577
+ return deriveCanonicalTaskClasses(prompt, options);
1578
+ }
1579
+ const prefilter = deriveCanonicalTaskClasses(prompt, options);
1580
+ const judgment = require("./agentlas-judgment.cjs");
1581
+ if (!judgment.hasJudgmentRunner()) return prefilter;
1582
+
1583
+ const hints = {};
1584
+ for (const slug of CANONICAL_TASK_SLUGS) {
1585
+ const words = TASK_CLASS_KEYWORDS[slug];
1586
+ if (Array.isArray(words) && words.length) hints[slug] = words;
1587
+ }
1588
+ const verdict = await judgment.judgeLabels({
1589
+ kind: "experience-task-class",
1590
+ question:
1591
+ "Which kinds of work does this request actually involve? Judge the user's real task, not words that merely appear.",
1592
+ labels: CANONICAL_TASK_SLUGS,
1593
+ input: String(prompt || ""),
1594
+ guidance:
1595
+ "Return a label only when that kind of work is genuinely part of the request. A word inside an " +
1596
+ "unrelated compound or a different sense of the word does not count. Return an empty list for " +
1597
+ "content with no identifiable task (hashes, ids, random strings).",
1598
+ hints,
1599
+ fallback: [],
1600
+ signal: options.signal,
1601
+ });
1602
+ if (verdict.source !== "llm") return prefilter;
1603
+ const taskIds = CANONICAL_TASK_IDS.filter((id) =>
1604
+ verdict.labels.some((slug) => id === `${CANONICAL_TASK_PREFIX}${slug}`));
1605
+ return {
1606
+ taskIds,
1607
+ source: "model-judgment",
1608
+ matchedTaskClasses: taskIds,
1609
+ invalidDeclaredCount: 0,
1610
+ ...(verdict.reason ? { judgmentReason: verdict.reason } : {}),
1611
+ };
1612
+ }
1613
+
1560
1614
  function parseEnvironmentConstraint(value) {
1561
1615
  const normalized = normalizedTaxonomyAtom(value);
1562
1616
  const contract = EXPERIENCE_TAXONOMY_V1.environment;
@@ -2140,6 +2194,7 @@ module.exports = {
2140
2194
  environmentConstraintsMatch,
2141
2195
  selectApplicablePortableItems,
2142
2196
  deriveCanonicalTaskClasses,
2197
+ resolveCanonicalTaskClasses,
2143
2198
  readExactLocalBaseMarker,
2144
2199
  exactTaskSignatureInPrompt,
2145
2200
  resolveRuntimeExperienceForAgent,
@@ -111,6 +111,8 @@ const STRINGS = {
111
111
  "team.usage": "usage: /team · /team <agent> <claude-code|codex|gemini|auto>",
112
112
  "team.set": "%s → %s",
113
113
  "routedImage": "routed to %s for image support",
114
+ "judge.source.llm": "judged by the connected model",
115
+ "judge.source.fallback": "deterministic fallback (no connected-model verdict)",
114
116
  "usageBar": "tokens",
115
117
  "config.title": "Engine auto engagement — explicit on/off (default: off)",
116
118
  "config.storm": "Stormbreaker auto-engage on direct-routed real work",
@@ -325,6 +327,8 @@ const STRINGS = {
325
327
  "team.usage": "사용법: /team · /team <에이전트> <claude-code|codex|gemini|auto>",
326
328
  "team.set": "%s → %s",
327
329
  "routedImage": "이미지 지원을 위해 %s로 라우팅",
330
+ "judge.source.llm": "판정: 연결 모델",
331
+ "judge.source.fallback": "판정: 결정적 폴백(연결 모델 없음)",
328
332
  "usageBar": "토큰",
329
333
  "config.title": "엔진 자동 개입 — 명시적 on/off (기본: off)",
330
334
  "config.storm": "직답 라우팅된 실작업에 Stormbreaker 자동 개입",
Binary file
@@ -30,17 +30,7 @@ const FINAL_SCOPES = new Set(["user_global", "team", "agent", "project", "sessio
30
30
  const SEMANTIC_DISPOSITIONS = new Set(["retain", "session", "discard", "review"]);
31
31
  const CONFIDENCE_LEVELS = new Set(["high", "medium", "low"]);
32
32
 
33
- const SECRET_PATTERNS = [
34
- /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/i,
35
- /\b(?:sk|rk|pk)-(?:ant|proj|live|test)?-?[A-Za-z0-9_-]{16,}\b/i,
36
- /\bgh[pousr]_[A-Za-z0-9]{20,}\b/i,
37
- /\bxox[baprs]-[A-Za-z0-9-]{16,}\b/i,
38
- /\bAIza[A-Za-z0-9_-]{30,}\b/,
39
- /\bAKIA[A-Z0-9]{16}\b/,
40
- /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
41
- /\b(?:password|passwd|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*[^\s,;]{6,}/i,
42
- /\bauthorization\s*:\s*bearer\s+[^\s,;]{8,}/i,
43
- ];
33
+ const { SECRET_PATTERNS } = require("./agentlas-secret-patterns.cjs");
44
34
  const ABSOLUTE_PATH_PATTERNS = [
45
35
  /(?:^|[\s("'`])~\/[A-Za-z0-9._-]/,
46
36
  /(?:^|[\s("'`])\/(?:Users|home|private|var|tmp|opt|etc|Volumes|Applications|System|Library)\//,
@@ -30,12 +30,7 @@ const ALWAYS_KEEP_HINT = /(team[-_ ]?memory|glossary|dossier|handoff|safety|scop
30
30
 
31
31
  // Minimal secret guard (a subset of the shared secret-patterns chokepoint) so a
32
32
  // stray key in legacy notes never becomes a durable memory row.
33
- const SECRET_RE =
34
- /(sk-[A-Za-z0-9]{20,}|sk_live_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|gh[opsu]_[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16})/;
35
-
36
- function looksSecret(content) {
37
- return SECRET_RE.test(String(content || ""));
38
- }
33
+ const { looksSecret } = require("./agentlas-secret-patterns.cjs");
39
34
 
40
35
  function substantiveBody(body) {
41
36
  const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
@@ -643,12 +643,26 @@ function startRepl(opts) {
643
643
  state.modelPinned = false;
644
644
  state.native = {};
645
645
  }
646
+ // 이미지 능력 판정 warm-cache — 동기 호출자(applyRuntimeFor/배지)가 읽기 전에 비동기
647
+ // 경로에서 상주 판정 서비스를 먼저 데운다. 러너 없음/실패는 어휘 폴백(라벨은 routingNote가 찍음).
648
+ async function warmImageJudgment(agentRow) {
649
+ if (!agentRow || typeof caps.resolveNeedsImage !== "function") return;
650
+ try {
651
+ await caps.resolveNeedsImage(agentRow);
652
+ } catch {
653
+ /* lexical fallback */
654
+ }
655
+ }
646
656
  // Tell the user when we routed to an image-capable runtime, or when the current one can't make images.
647
657
  function routingNote(subject) {
648
658
  if (!subject || !caps.needsImage(subject.capAgent)) return;
649
659
  const spec = caps.specOf(state.runtime);
650
660
  if (caps.capsFor(spec).image) {
651
- if (spec !== caps.specOf(baseRuntime)) ui.info(ui.t("routedImage", spec));
661
+ if (spec !== caps.specOf(baseRuntime)) {
662
+ // 판정 주체 라벨 — 모델 판정인지 결정적 폴백인지 반드시 밝힌다(조용한 폴백 금지).
663
+ const judged = typeof caps.imageJudgmentSource === "function" && caps.imageJudgmentSource(subject.capAgent) === "llm";
664
+ ui.info(ui.t("routedImage", spec) + " — " + ui.t(judged ? "judge.source.llm" : "judge.source.fallback"));
665
+ }
652
666
  } else {
653
667
  ui.warn(ui.t("guard.imageWarn", caps.capsFor(spec).label || spec));
654
668
  }
@@ -701,10 +715,11 @@ function startRepl(opts) {
701
715
  state.routePreambleOnce = null;
702
716
  applyRuntimeFor(state.subject);
703
717
  }
704
- function switchSubject(kind, query) {
718
+ async function switchSubject(kind, query) {
705
719
  if (kind === "agent") {
706
720
  const agent = H.resolveAgent(db, query);
707
721
  if (!agent) return ui.error(ui.t("noAgent", query));
722
+ await warmImageJudgment(agent); // 런타임 자동 배정 전에 모델 판정을 데운다
708
723
  setSubjectAgent(agent);
709
724
  } else {
710
725
  const firm = H.resolveFirm(db, query);
@@ -933,11 +948,11 @@ function startRepl(opts) {
933
948
  }
934
949
  case "agent":
935
950
  if (!arg) return ui.warn(ui.t("agentUsage")), true;
936
- switchSubject("agent", arg);
951
+ await switchSubject("agent", arg);
937
952
  return true;
938
953
  case "firm":
939
954
  if (!arg) return ui.warn(ui.t("firmUsage")), true;
940
- switchSubject("firm", arg);
955
+ await switchSubject("firm", arg);
941
956
  return true;
942
957
  case "runtime":
943
958
  setRuntime(arg);
@@ -1364,7 +1379,8 @@ function startRepl(opts) {
1364
1379
  }
1365
1380
 
1366
1381
  // ── interactive picker (when no agent was given) ──
1367
- function chooseAndStart(setter, row) {
1382
+ async function chooseAndStart(setter, row) {
1383
+ if (setter === setSubjectAgent) await warmImageJudgment(row); // firm은 팀 베토라 판정 대상 아님
1368
1384
  setter(row);
1369
1385
  ui.ok(ui.t("switched", state.subject.label));
1370
1386
  routingNote(state.subject);
@@ -1402,12 +1418,14 @@ function startRepl(opts) {
1402
1418
  if (a) return chooseAndStart(setSubjectAgent, a);
1403
1419
  const f = H.resolveFirm(db, t);
1404
1420
  if (f) return chooseAndStart(setSubjectFirm, f);
1405
- if (H.autoRouteAgent) {
1406
- const choice = H.autoRouteAgent(db, t, ui.lang);
1421
+ if (H.autoRouteAgent || H.resolveAutoRoute) {
1422
+ // 연결 모델이 라우트를 최종 판정한다(resolveAutoRoute) 없으면 어휘 폴백.
1423
+ const choice = H.resolveAutoRoute ? await H.resolveAutoRoute(db, t, ui.lang) : H.autoRouteAgent(db, t, ui.lang);
1407
1424
  if (choice) {
1408
1425
  if (choice.direct) {
1409
1426
  setSubjectDirect();
1410
1427
  } else {
1428
+ await warmImageJudgment(choice.agent);
1411
1429
  setSubjectAgent(choice.agent);
1412
1430
  }
1413
1431
  state.routePreambleOnce = H.autoRoutePreamble ? H.autoRoutePreamble(choice, ui.lang) : null;
@@ -1442,6 +1460,7 @@ function startRepl(opts) {
1442
1460
  if (/^\d+$/.test(t)) {
1443
1461
  const n = parseInt(t, 10);
1444
1462
  if (n >= 1 && n <= ags.length) {
1463
+ await warmImageJudgment(ags[n - 1]);
1445
1464
  setSubjectAgent(ags[n - 1]);
1446
1465
  ui.ok(ui.t("switched", state.subject.label));
1447
1466
  routingNote(state.subject);
@@ -1450,13 +1469,14 @@ function startRepl(opts) {
1450
1469
  }
1451
1470
  if (single) {
1452
1471
  const a = H.resolveAgent(db, t);
1453
- if (a) { setSubjectAgent(a); ui.ok(ui.t("switched", state.subject.label)); routingNote(state.subject); return; }
1472
+ if (a) { await warmImageJudgment(a); setSubjectAgent(a); ui.ok(ui.t("switched", state.subject.label)); routingNote(state.subject); return; }
1454
1473
  const f = H.resolveFirm(db, t);
1455
1474
  if (f) { setSubjectFirm(f); ui.ok(ui.t("switched", state.subject.label)); routingNote(state.subject); return; }
1456
1475
  }
1457
1476
  }
1458
- if (H.autoRouteAgent) {
1459
- const choice = H.autoRouteAgent(db, t, ui.lang);
1477
+ if (H.autoRouteAgent || H.resolveAutoRoute) {
1478
+ // 연결 모델이 라우트를 최종 판정한다(resolveAutoRoute) 없으면 어휘 폴백.
1479
+ const choice = H.resolveAutoRoute ? await H.resolveAutoRoute(db, t, ui.lang) : H.autoRouteAgent(db, t, ui.lang);
1460
1480
  if (choice) {
1461
1481
  if (choice.direct) {
1462
1482
  setSubjectDirect();
@@ -1466,6 +1486,7 @@ function startRepl(opts) {
1466
1486
  ui.info(H.autoRouteNote ? H.autoRouteNote(choice, ui.lang) : `direct answer (no agent)`);
1467
1487
  }
1468
1488
  } else {
1489
+ await warmImageJudgment(choice.agent);
1469
1490
  setSubjectAgent(choice.agent);
1470
1491
  state.routePreambleOnce = H.autoRoutePreamble ? H.autoRoutePreamble(choice, ui.lang) : null;
1471
1492
  ui.info(H.autoRouteNote ? H.autoRouteNote(choice, ui.lang) : `auto-routed to ${choice.agent.name}`);
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ // Credential/secret detection for every terminal write boundary that must never persist
3
+ // or transmit a live key. Three inline copies had drifted apart (memory import missed
4
+ // JWTs, generic `key=` assignments and bearer headers; governance missed sk_live_/
5
+ // github_pat_/glpat-; experience exchange missed Google AIza and JWTs), so the same
6
+ // secret was caught at one boundary and stored in plain text at another. One list, one
7
+ // behaviour: extend HERE, not at a call site. Mirrors the desktop's
8
+ // shared/secret-patterns.ts.
9
+ //
10
+ // Scope rule: match *credential shapes*, not the words around them. Ordinary prose that
11
+ // mentions "token", or a hyphenated phrase like "risk-management-notes", must not trip
12
+ // this — a false positive silently drops a user's memory, which is its own data loss.
13
+
14
+ /** Live-credential shapes across the providers this product actually touches. */
15
+ const SECRET_SHAPES = [
16
+ // GitHub: classic PAT, OAuth/user/server/refresh tokens, fine-grained PAT.
17
+ /gh[pousr]_[A-Za-z0-9]{20,}/,
18
+ /github_pat_[A-Za-z0-9_]{20,}/,
19
+ // Slack bot/user/app tokens.
20
+ /xox[baprs]-[A-Za-z0-9-]{20,}/,
21
+ // AWS access key ids (long-lived and STS).
22
+ /(?:AKIA|ASIA)[0-9A-Z]{16}/,
23
+ // Google / Firebase API keys.
24
+ /AIza[0-9A-Za-z_-]{30,}/,
25
+ // Stripe and similar: secret/restricted/publishable, live or test.
26
+ /(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/,
27
+ // OpenAI / Anthropic, including provider-segmented forms. The \b prevents an ordinary
28
+ // hyphenated phrase ("ask-forgiveness-not-permission") from matching.
29
+ /\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{12,}/,
30
+ // HuggingFace, GitLab, npm.
31
+ /hf_[A-Za-z0-9]{20,}/,
32
+ /glpat-[A-Za-z0-9_-]{20,}/,
33
+ /npm_[A-Za-z0-9]{20,}/,
34
+ // JWTs (three base64url segments) — bearer tokens frequently land in pasted logs.
35
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/,
36
+ // Telegram bot tokens.
37
+ /\b[0-9]{8,}:[A-Za-z0-9_-]{25,}\b/,
38
+ // Private key blocks.
39
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
40
+ ];
41
+
42
+ /** `password: hunter2` style assignments, where the value shape alone proves nothing. */
43
+ const SECRET_ASSIGNMENT_RE =
44
+ /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|cookie|bearer)\b\s*[:=]\s*['"]?[^\s,;'"]{6,}/i;
45
+
46
+ /** `Authorization: Bearer …` / `Basic …` headers. */
47
+ const AUTH_HEADER_RE = /\bauthorization\b\s*[:=]\s*['"]?(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/i;
48
+
49
+ /** Single source of truth. Case-insensitive: providers are inconsistent about casing. */
50
+ const SECRET_PATTERNS = [
51
+ ...SECRET_SHAPES.map((re) => new RegExp(re.source, "i")),
52
+ SECRET_ASSIGNMENT_RE,
53
+ AUTH_HEADER_RE,
54
+ ];
55
+
56
+ /** True when the text contains something that looks like a live credential. */
57
+ function looksSecret(content) {
58
+ const text = String(content || "");
59
+ return SECRET_PATTERNS.some((re) => re.test(text));
60
+ }
61
+
62
+ /** Replace credential-shaped substrings with a marker, preserving surrounding text. */
63
+ function redactSecrets(content, marker = "[redacted-secret]") {
64
+ let out = String(content || "");
65
+ for (const re of SECRET_PATTERNS) {
66
+ out = out.replace(new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`), marker);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ module.exports = { SECRET_PATTERNS, looksSecret, redactSecrets };
@@ -580,6 +580,24 @@ function directSystemPrompt(lang) {
580
580
  ? "당신은 Agentlas 터미널의 기본 어시스턴트입니다. 특별한 페르소나 없이 사용자의 요청에 정확하고 간결하게 바로 답하세요. 에이전트 라우팅이나 이미지 생성 능력을 스스로 언급하지 마세요."
581
581
  : "You are the Agentlas terminal's default assistant. Answer the user's request directly and concisely, with no special persona. Do not bring up agent routing or image-generation capabilities on your own.";
582
582
  }
583
+ // 어휘 스코어링 — 설치 에이전트 전원을 점수순으로 나열한다. 하우스 룰에 따라 이 점수는
584
+ // (1) resolveAutoRoute의 후보 모집과 (2) 라벨 붙은 오프라인 폴백(autoRouteAgent)으로만
585
+ // 쓰인다 — 연결 모델이 있으면 최종 라우트 결정은 상주 판정 서비스가 의미로 내린다.
586
+ function rankRouteAgents(db, prompt, lang) {
587
+ const resolvedLang = lang || prefsLang();
588
+ const agents = listRoutableAgents(db).filter((agent) => !NON_GENERIC_ROUTE_SLUGS.has(agent.slug));
589
+ if (!agents.length) return [];
590
+ let terms = routeTokenize(prompt);
591
+ // 헤이스택은 한 번만 계산해 IDF와 스코어링 양쪽에서 재사용한다.
592
+ const hays = agents.map((agent) => ({ identityHay: routeIdentityHaystack(agent), haystack: routeHaystack(agent) }));
593
+ // IDF 근사 — 설치 에이전트 절반 이상의 haystack에 나오는 단어("ai","도구" 등)는 판별력이 없어 제외.
594
+ if (agents.length >= 3) {
595
+ terms = terms.filter((term) => hays.filter((h) => h.haystack.includes(term)).length * 2 <= agents.length);
596
+ }
597
+ return agents
598
+ .map((agent, i) => scoreRouteAgent(prompt, terms, agent, resolvedLang, hays[i]))
599
+ .sort((a, b) => b.score - a.score);
600
+ }
583
601
  function autoRouteAgent(db, prompt, lang) {
584
602
  const resolvedLang = lang || prefsLang();
585
603
  // 명확한 "에이전트/팀/회사 만들기" 의도 → 메타-빌더로 직행 (약한 키워드 점수에 밀리지 않게).
@@ -598,35 +616,129 @@ function autoRouteAgent(db, prompt, lang) {
598
616
  };
599
617
  }
600
618
  }
601
- const agents = listRoutableAgents(db).filter((agent) => !NON_GENERIC_ROUTE_SLUGS.has(agent.slug));
602
- if (!agents.length) return directRouteChoice(resolvedLang);
603
- let terms = routeTokenize(prompt);
604
- // 헤이스택은 한 번만 계산해 IDF와 스코어링 양쪽에서 재사용한다.
605
- const hays = agents.map((agent) => ({ identityHay: routeIdentityHaystack(agent), haystack: routeHaystack(agent) }));
606
- // IDF 근사 — 설치 에이전트 절반 이상의 haystack에 나오는 단어("ai","도구" 등)는 판별력이 없어 제외.
607
- if (agents.length >= 3) {
608
- terms = terms.filter((term) => hays.filter((h) => h.haystack.includes(term)).length * 2 <= agents.length);
609
- }
610
- const ranked = agents
611
- .map((agent, i) => scoreRouteAgent(prompt, terms, agent, resolvedLang, hays[i]))
612
- .sort((a, b) => b.score - a.score);
619
+ const ranked = rankRouteAgents(db, prompt, resolvedLang);
620
+ if (!ranked.length) return directRouteChoice(resolvedLang);
613
621
  // 1위가 아니라 "임계값+strong을 모두 만족하는 최고 순위"를 뽑는다 — 장황한 프롬프트의
614
622
  // 약한 단어 적중이 점수 1위를 먹어도, 자격 있는 전문 에이전트가 직답으로 밀려나지 않는다.
615
623
  const pick = ranked.find((r) => r.score >= MIN_ROUTE_SCORE && r.strong);
616
624
  if (pick) return pick;
617
625
  return directRouteChoice(resolvedLang);
618
626
  }
627
+ // ── 모델 최종 라우팅 판정 ──────────────────────────────────
628
+ // 하우스 룰: 단어목록/정규식은 최종 라우트 결정을 내리지 않는다. 연결 모델이 있으면
629
+ // judgeLabels가 후보(어휘 모집 + 설치 전원, 상한) + 합성 라벨(meta-builder/app-builder/
630
+ // direct) 중에서 의미로 하나를 고른다 — 어휘 점수 0점인 에이전트(아랍어 등 어떤 언어의
631
+ // 요청이든)도 모델은 뽑을 수 있다. 닫힌형 가드는 결정적으로 유지: 잡담 short-circuit
632
+ // (isTrivialRoutePrompt)과 경로 스트리핑(ROUTE_PATH_RE)은 모델 호출 전에 그대로 적용.
633
+ // 러너 없음/타임아웃/정크 → 기존 어휘 라우팅(autoRouteAgent)을 routeSource:"deterministic"
634
+ // 으로 라벨해 반환한다 — 조용한 폴백 금지(라우트 노트에 판정 주체가 찍힌다).
635
+ const ROUTE_JUDGE_CANDIDATE_CAP = 30;
636
+ const ROUTE_JUDGE_DIRECT_LABEL = "direct";
637
+ const ROUTE_JUDGE_META_LABEL = "meta-builder";
638
+ const ROUTE_JUDGE_APP_LABEL = "app-builder";
639
+ const APP_BUILDER_ROUTE_SLUG = "agentlas-app-builder";
640
+ async function resolveAutoRoute(db, prompt, lang) {
641
+ const resolvedLang = lang || prefsLang();
642
+ const prefilter = autoRouteAgent(db, prompt, resolvedLang);
643
+ ensureJudgmentRunnerInstalled(db);
644
+ let judgment;
645
+ try {
646
+ judgment = require("./agentlas-judgment.cjs");
647
+ } catch {
648
+ judgment = null;
649
+ }
650
+ if (!judgment || !judgment.hasJudgmentRunner()) return { ...prefilter, routeSource: "deterministic" };
651
+ const promptText = routeNormalize(routeStripPaths(prompt));
652
+ // 잡담("hi")까지 모델에 물으면 매 턴이 느려진다 — 정확 매칭 가드는 닫힌형이라 결정적 유지.
653
+ if (!promptText.trim() || isTrivialRoutePrompt(promptText)) return { ...prefilter, routeSource: "deterministic" };
654
+ const ranked = rankRouteAgents(db, prompt, resolvedLang);
655
+ const meta = resolveMetaBuilder(db);
656
+ if (!ranked.length && !meta) return { ...prefilter, routeSource: "deterministic" };
657
+ // App Builder는 합성 라벨로만 제시한다(동의 핸드셰이크가 걸린 특수 라우트임을 모델에 명시).
658
+ const appBuilder = ranked.find((r) => r.agent.slug === APP_BUILDER_ROUTE_SLUG) || null;
659
+ const candidates = ranked.filter((r) => r.agent.slug !== APP_BUILDER_ROUTE_SLUG).slice(0, ROUTE_JUDGE_CANDIDATE_CAP);
660
+ const bySlug = new Map(candidates.map((r) => [r.agent.slug, r]));
661
+ const labels = [...bySlug.keys()];
662
+ const hints = {};
663
+ const roster = [];
664
+ for (const r of candidates) {
665
+ const a = r.agent;
666
+ const name = [...new Set([a.name, a.name_en].filter(Boolean))].join(" / ");
667
+ const tagline = [...new Set([a.tagline, a.tagline_en].filter(Boolean))].join(" / ");
668
+ roster.push(`- ${a.slug}: ${String(name).slice(0, 80)}${tagline ? ` — ${String(tagline).slice(0, 120)}` : ""}`);
669
+ // 옛 단어목록은 힌트로 강등: 큐레이션 힌트 용어 + 이 프롬프트에서 어휘 스코어러가 맞춘 용어.
670
+ const curated = ROUTE_HINTS.find((h) => h.slug === a.slug);
671
+ const hintTerms = [...new Set([...(curated ? curated.terms : []), ...(r.terms || [])])];
672
+ if (hintTerms.length) hints[a.slug] = hintTerms;
673
+ }
674
+ if (appBuilder) {
675
+ labels.push(ROUTE_JUDGE_APP_LABEL);
676
+ roster.push(
677
+ `- ${ROUTE_JUDGE_APP_LABEL}: build a dedicated internal Agentlas App (recurring workflow, durable state, editing surfaces); explicit user consent is asked separately before anything is created`,
678
+ );
679
+ hints[ROUTE_JUDGE_APP_LABEL] = APP_BUILDER_EXPLICIT_TERMS;
680
+ }
681
+ if (meta) {
682
+ labels.push(ROUTE_JUDGE_META_LABEL);
683
+ roster.push(`- ${ROUTE_JUDGE_META_LABEL}: build a NEW agent/team/company itself (the meta-builder)`);
684
+ hints[ROUTE_JUDGE_META_LABEL] = AGENT_BUILD_TERMS;
685
+ }
686
+ labels.push(ROUTE_JUDGE_DIRECT_LABEL);
687
+ roster.push(`- ${ROUTE_JUDGE_DIRECT_LABEL}: no installed agent clearly fits; answer as the plain assistant`);
688
+ const verdict = await judgment.judgeLabels({
689
+ // 라벨 집합(설치 상태)이 바뀌면 캐시 키도 바뀌어야 한다 — kind에 라벨 지문을 넣는다.
690
+ kind: `terminal-auto-route:${crypto.createHash("sha256").update(labels.join("\n")).digest("hex").slice(0, 12)}`,
691
+ question: "Which installed agent should own this user request, if any?",
692
+ labels,
693
+ input: routeStripPaths(String(prompt || "")),
694
+ hints,
695
+ guidance: [
696
+ "Installed agents:",
697
+ ...roster,
698
+ "Mentioning a word is not intent — judge what the user actually asks to be done, in any language.",
699
+ `Pick "${ROUTE_JUDGE_META_LABEL}" only when the user asks to create a new agent/team/company itself.`,
700
+ `Pick "${ROUTE_JUDGE_DIRECT_LABEL}" when no installed agent clearly fits the request.`,
701
+ ].join("\n"),
702
+ multi: false,
703
+ fallback: [],
704
+ // Routing is a one-shot pre-run gate: correctness matters more than latency,
705
+ // and a local 30B model with a full roster can need well over the 20s default.
706
+ // The judge's abort signal is threaded into the request, so a genuinely hung
707
+ // model still aborts cleanly at this deadline rather than hanging forever.
708
+ timeoutMs: 40000,
709
+ });
710
+ if (verdict.source !== "llm" || !verdict.labels.length) return { ...prefilter, routeSource: "deterministic" };
711
+ const picked = verdict.labels[0];
712
+ const reason =
713
+ verdict.reason ||
714
+ (resolvedLang === "ko" ? "연결 모델이 요청의 의미로 판정했습니다" : "the connected model judged the request by meaning");
715
+ if (picked === ROUTE_JUDGE_DIRECT_LABEL) return { ...directRouteChoice(resolvedLang), reason, routeSource: "llm" };
716
+ if (picked === ROUTE_JUDGE_META_LABEL && meta) {
717
+ return { agent: meta, score: 1000, strong: true, terms: [], reason, routeSource: "llm" };
718
+ }
719
+ const chosen = picked === ROUTE_JUDGE_APP_LABEL ? appBuilder : bySlug.get(picked);
720
+ if (!chosen) return { ...prefilter, routeSource: "deterministic" };
721
+ // 모델 확답은 strong 계약을 충족한다 — 어휘 근거(terms/score)는 참고로 보존.
722
+ return { ...chosen, strong: true, reason, routeSource: "llm" };
723
+ }
724
+ // 라우트 영수증 라벨 — 누가 최종 판정했는지 반드시 찍는다(조용한 폴백 금지 하우스 룰).
725
+ function routeJudgeSourceNote(choice, lang) {
726
+ if (!choice || !choice.routeSource) return "";
727
+ if (choice.routeSource === "llm") return lang === "ko" ? " (판정: 연결 모델)" : " (judged by the connected model)";
728
+ return lang === "ko" ? " (판정: 결정적 폴백 — 연결 모델 없음)" : " (deterministic fallback — no connected-model verdict)";
729
+ }
619
730
  function autoRouteNote(choice, lang) {
620
731
  const resolvedLang = lang || prefsLang();
732
+ const sourceNote = routeJudgeSourceNote(choice, resolvedLang);
621
733
  if (choice.direct) {
622
734
  return resolvedLang === "ko"
623
- ? `사용 에이전트: 없음 — 바로 답합니다. 이유: ${choice.reason}.`
624
- : `Selected agent: none — answering directly. Reason: ${choice.reason}.`;
735
+ ? `사용 에이전트: 없음 — 바로 답합니다. 이유: ${choice.reason}.${sourceNote}`
736
+ : `Selected agent: none — answering directly. Reason: ${choice.reason}.${sourceNote}`;
625
737
  }
626
738
  const name = resolvedLang === "ko" ? choice.agent.name : choice.agent.name_en || choice.agent.name;
627
739
  return resolvedLang === "ko"
628
- ? `사용 에이전트: ${name}. 이유: ${choice.reason}.`
629
- : `Selected agent: ${name}. Reason: ${choice.reason}.`;
740
+ ? `사용 에이전트: ${name}. 이유: ${choice.reason}.${sourceNote}`
741
+ : `Selected agent: ${name}. Reason: ${choice.reason}.${sourceNote}`;
630
742
  }
631
743
  function autoRoutePreamble(choice, lang) {
632
744
  const resolvedLang = lang || prefsLang();
@@ -8507,6 +8619,99 @@ const RUNTIME_BIN = {
8507
8619
  };
8508
8620
 
8509
8621
  // 활성 런타임 → 실행 방식 결정. CLI(claude/codex/gemini) 또는 API(BYOK/Ollama).
8622
+ /**
8623
+ * Wire the resident judgment service to whatever runtime the user actually has connected.
8624
+ * This is what lets classification be decided by MEANING instead of by a wordlist: the
8625
+ * judge asks the connected model, and the old keyword maps are passed only as hints. It is
8626
+ * invisible to the user (no output, read-only, no tools) and every judged call falls back
8627
+ * to the deterministic prefilter when no runtime answers.
8628
+ */
8629
+ function installJudgmentRunner(db, rt) {
8630
+ let judgment;
8631
+ try {
8632
+ judgment = require("./agentlas-judgment.cjs");
8633
+ } catch {
8634
+ return;
8635
+ }
8636
+ // CLI runtime (claude-code/codex/gemini): the judge runs a silent native turn.
8637
+ if (rt && rt.mode === "cli" && RUNTIME_BIN[rt.kind]) {
8638
+ judgment.setJudgmentRunner(async ({ system, prompt, signal }) => {
8639
+ const { runNativeTurn } = require("./agentlas-native-host.cjs");
8640
+ const { Ui } = require("./agentlas-ui.cjs");
8641
+ const silent = new Ui({ lang: prefsLang(), quiet: true });
8642
+ for (const method of ["write", "warn", "info", "tool", "result", "status", "line"]) {
8643
+ if (typeof silent[method] === "function") silent[method] = () => {};
8644
+ }
8645
+ const res = await runNativeTurn({
8646
+ kind: rt.kind,
8647
+ bin: RUNTIME_BIN[rt.kind],
8648
+ prompt,
8649
+ systemPrompt: system,
8650
+ cwd: projectCwd(),
8651
+ permission: "read",
8652
+ session: {},
8653
+ ui: silent,
8654
+ env: process.env,
8655
+ signal,
8656
+ });
8657
+ if (res && res.error) return "";
8658
+ return (res && res.text) || "";
8659
+ });
8660
+ return;
8661
+ }
8662
+
8663
+ // API runtime (BYOK / Ollama): the judge uses the same one-shot API path the
8664
+ // rest of the terminal uses, so "the connected model decides" holds for the
8665
+ // user's actual runtime — not only CLI subprocess runtimes. Without this, an
8666
+ // Ollama/BYOK user silently gets the deterministic wordlist fallback for every
8667
+ // route, intent, and classification decision.
8668
+ if (rt && rt.mode === "api" && rt.backend) {
8669
+ judgment.setJudgmentRunner(async ({ system, prompt, signal }) => {
8670
+ // Thread the judge's timeout signal into the actual HTTP request so a slow
8671
+ // model aborts the fetch instead of hanging past the deadline.
8672
+ const baseFetch = globalThis.fetch;
8673
+ const fetchImpl = typeof baseFetch === "function" && signal
8674
+ ? (url, init) => baseFetch(url, { ...(init || {}), signal })
8675
+ : baseFetch;
8676
+ try {
8677
+ return await runApi(rt.backend, rt.model, system, prompt, { fetch: fetchImpl });
8678
+ } catch {
8679
+ return "";
8680
+ }
8681
+ });
8682
+ return;
8683
+ }
8684
+
8685
+ judgment.setJudgmentRunner(null);
8686
+ }
8687
+
8688
+ // Make sure the resident judge can reach the connected model BEFORE the first
8689
+ // judged decision (routing, image capability, task class). The only other
8690
+ // install site is inside executeOnce, which runs AFTER routing — so a one-shot
8691
+ // `run`/auto-route would always fall to the deterministic label without this.
8692
+ // Uses the active runtime (CLI or API/Ollama/BYOK); a pinned override still
8693
+ // reinstalls in executeOnce for the actual task turn.
8694
+ function ensureJudgmentRunnerInstalled(db) {
8695
+ let judgment;
8696
+ try {
8697
+ judgment = require("./agentlas-judgment.cjs");
8698
+ } catch {
8699
+ return;
8700
+ }
8701
+ if (judgment.hasJudgmentRunner()) return;
8702
+ try {
8703
+ const ar = activeRuntime(db);
8704
+ let rt = null;
8705
+ if (ar && RUNTIME_BIN[ar.kind]) rt = { mode: "cli", kind: ar.kind, model: ar.model || null };
8706
+ else if (ar && ar.kind === "byok" && ar.backend) rt = { mode: "api", backend: ar.backend, model: ar.model };
8707
+ else if (ar && ar.kind === "ollama") rt = { mode: "api", backend: "ollama", model: ar.model };
8708
+ if (rt) installJudgmentRunner(db, rt);
8709
+ } catch {
8710
+ // No resolvable runtime → leave the runner unset; judged sites use their
8711
+ // labeled deterministic fallback rather than crashing.
8712
+ }
8713
+ }
8714
+
8510
8715
  function resolveRuntime(db, override) {
8511
8716
  const ar = activeRuntime(db);
8512
8717
  const activeCli = ar && RUNTIME_BIN[ar.kind]
@@ -8850,6 +9055,9 @@ async function executeOnce(db, system, prompt, override, ctx) {
8850
9055
  }
8851
9056
  const rt = resolveRuntime(db, override);
8852
9057
  memoryRuntime = rt;
9058
+ // The resident judge uses the same connected runtime, so wordlist-based classification is
9059
+ // replaced by meaning-based judgment for this turn.
9060
+ installJudgmentRunner(db, rt);
8853
9061
  if (rt.mode === "cli") {
8854
9062
  // 네이티브 CLI에도 같은 Memory emitter를 주입하되 guard가 화면의 JSON 블록을 숨긴다.
8855
9063
  // 큐레이터가 만든 구조화 Memory만 성공 RunReceipt 이후 Experience intake로 전달된다.
@@ -9502,6 +9710,7 @@ function buildHelpers(db) {
9502
9710
  listFirms,
9503
9711
  firmSystemPrompt,
9504
9712
  autoRouteAgent: (db_, prompt, lang) => autoRouteAgent(db_, prompt, lang),
9713
+ resolveAutoRoute: (db_, prompt, lang) => resolveAutoRoute(db_, prompt, lang),
9505
9714
  autoRouteNote: (choice, lang) => autoRouteNote(choice, lang),
9506
9715
  autoRoutePreamble: (choice, lang) => autoRoutePreamble(choice, lang),
9507
9716
  directSystemPrompt: (lang) => directSystemPrompt(lang),
@@ -10185,7 +10394,8 @@ async function cmdRun(db, query, prompt, runtimeOverride, runtimeExperience = nu
10185
10394
 
10186
10395
  async function cmdAutoRun(db, prompt, runtimeOverride, runtimeExperience = null) {
10187
10396
  const lang = prefsLang();
10188
- const choice = autoRouteAgent(db, prompt, lang);
10397
+ // 연결 모델이 라우트를 최종 판정한다 — 어휘 스코어는 후보 모집/라벨 붙은 폴백 전용.
10398
+ const choice = await resolveAutoRoute(db, prompt, lang);
10189
10399
  if (!choice) fail("No agent is available for automatic routing. Check installation with agentlas list.");
10190
10400
  if (choice.direct) {
10191
10401
  // 전문 에이전트 확신 없음 → 페르소나/능력 라우팅 없이 현재 런타임으로 직답.
@@ -11725,6 +11935,12 @@ async function main() {
11725
11935
  // Agentlas 아키텍처 빌트인 에이전트를 보장(앱과 동일, 멱등·버전 게이팅). 스키마가 준비됐을 때만.
11726
11936
  try { seedBuiltins(db); } catch { /* best-effort */ }
11727
11937
 
11938
+ // Wire the resident judge to the connected runtime up front, so the FIRST
11939
+ // routing / image-capability / task-class decision is judged by the model —
11940
+ // in the REPL and in one-shot commands alike — instead of silently falling to
11941
+ // the deterministic label until executeOnce eventually installs it.
11942
+ ensureJudgmentRunnerInstalled(db);
11943
+
11728
11944
  // 인자 없이 `agentlas` → 에이전트 1개면 바로 대화형, 아니면 목록 + 사용법
11729
11945
  if (cmd === "") {
11730
11946
  const agents = listAgents(db);
@@ -11982,9 +12198,14 @@ module.exports = {
11982
12198
  ANTHROPIC_COMPAT_API,
11983
12199
  // 자동 라우팅 회귀 테스트 표면 — 약한 매치 직답/오라우팅 방지 규칙 검증용.
11984
12200
  autoRouteAgent,
12201
+ // 모델 최종 라우팅 — 어휘 점수는 후보 모집 전용, 연결 모델이 의미로 판정.
12202
+ resolveAutoRoute,
11985
12203
  autoRouteNote,
11986
12204
  autoRoutePreamble,
11987
12205
  directSystemPrompt,
12206
+ // Test bridge: prove the resident judge is wired to API/Ollama/BYOK runtimes,
12207
+ // not only CLI subprocess runtimes.
12208
+ installJudgmentRunner,
11988
12209
  // Hub 플러그인 설치 회귀 테스트 표면 — 레포 URL을 MCP 서버로 등록하지 않는 규칙 검증용.
11989
12210
  pluginMcpRowCli,
11990
12211
  planPluginMcpInstallCli,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlas",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Agentlas agent terminal — chat with your installed AI agents and teams from the terminal, Claude Code style. Standalone: no desktop app required.",
5
5
  "bin": {
6
6
  "agentlas": "bin/agentlas.cjs"