jeo-code 0.7.16 → 0.7.18

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.
@@ -202,6 +202,10 @@ export interface Concept {
202
202
  tags: string[];
203
203
  /** high | medium | low — distiller defaults to "high"; drives core selection. */
204
204
  confidence: string;
205
+ /** Load-bearing invariant (frontmatter `pinned: true`): selected into the injection
206
+ * budget BEFORE any query/recency fill, so the cap can never evict it (philosophy
207
+ * synthesis R8 / consensus #3 — pinned invariants survive on a reserved budget). */
208
+ pinned: boolean;
205
209
  /** Bundle-relative path, e.g. `commands/bun-test.md`. */
206
210
  relPath: string;
207
211
  }
@@ -284,6 +288,7 @@ async function loadConceptsFromBundle(bundleDir: string): Promise<Concept[]> {
284
288
  body: parsed.body.trim(),
285
289
  tags: Array.isArray(fm.tags) ? fm.tags.filter((t): t is string => typeof t === "string") : [],
286
290
  confidence: typeof fm.confidence === "string" ? fm.confidence : "high",
291
+ pinned: fm.pinned === true,
287
292
  relPath,
288
293
  };
289
294
  cacheParsedConcept(file, sig, concept);
@@ -339,10 +344,16 @@ export function searchConcepts(concepts: Concept[], query: string): { concept: C
339
344
  .sort((a, b) => b.score - a.score);
340
345
  }
341
346
 
342
- /** Priority order for injection: high-confidence "core" concepts first, then by
343
- * query relevance (descending), then concepts the relevant ones LINK TO (1-hop
344
- * graph expansion — Sprint 04: a directly-hit concept pulls its neighbours in as
345
- * context ahead of unrelated noise), preserving input order as a stable tiebreak. */
347
+ /** Priority order for injection. Failure-first: a query-relevant FailedAttempt is
348
+ * surfaced AHEAD of everything else — resurfacing a known dead end is higher-leverage
349
+ * than reinforcing what already works ("못하는 게 없도록" — close the gaps, don't just
350
+ * polish strengths), and it is the mechanism by which the loop gets more precise the
351
+ * more it repeats (llm-wiki: accumulated failure knowledge sharpens future runs). Then
352
+ * high-confidence "core" concepts, then query relevance (descending), then concepts the
353
+ * relevant ones LINK TO (1-hop graph expansion — Sprint 04: a directly-hit concept pulls
354
+ * its neighbours in as context ahead of unrelated noise), preserving input order as a
355
+ * stable tiebreak. The failure boost only fires when the query actually hits the concept
356
+ * (score > 0), so an unrelated FailedAttempt never crowds out relevant context. */
346
357
  function priorityOrder(concepts: Concept[], query?: string): Concept[] {
347
358
  const tokens = tokenize(query);
348
359
  // 1-hop graph expansion: seed from concepts the query directly hits, then mark
@@ -354,14 +365,22 @@ function priorityOrder(concepts: Concept[], query?: string): Concept[] {
354
365
  for (const id of expandByGraph(seeds, graph, 1)) related.add(id);
355
366
  }
356
367
  return concepts
357
- .map((concept, i) => ({
358
- concept,
359
- i,
360
- core: concept.confidence === "high",
361
- score: scoreConcept(concept, tokens),
362
- related: related.has(conceptId(concept.relPath)),
363
- }))
368
+ .map((concept, i) => {
369
+ const score = scoreConcept(concept, tokens);
370
+ return {
371
+ concept,
372
+ i,
373
+ // Query-relevant past failure → resurface first so the loop is reminded of
374
+ // what NOT to repeat for the current task. Gated on score > 0: a stale,
375
+ // unrelated dead end stays out of the way.
376
+ failure: concept.type === "FailedAttempt" && score > 0,
377
+ core: concept.confidence === "high",
378
+ score,
379
+ related: related.has(conceptId(concept.relPath)),
380
+ };
381
+ })
364
382
  .sort((a, b) => {
383
+ if (a.failure !== b.failure) return a.failure ? -1 : 1;
365
384
  if (a.core !== b.core) return a.core ? -1 : 1;
366
385
  if (b.score !== a.score) return b.score - a.score;
367
386
  if (a.related !== b.related) return a.related ? -1 : 1;
@@ -404,14 +423,22 @@ function renderConcepts(concepts: Concept[]): string {
404
423
 
405
424
  /** Greedily select concepts (in priority order) whose grouped render stays within
406
425
  * `budget` chars, dropping the lowest-priority concepts first. At least the
407
- * top-priority concept is always kept (the framing/backstop cap still applies). */
426
+ * top-priority concept is always kept (the framing/backstop cap still applies).
427
+ *
428
+ * Pinned (load-bearing) invariants get a RESERVED budget: they are selected FIRST,
429
+ * before any query/recency fill, so a tight cap can never evict an invariant the
430
+ * gates depend on (philosophy-synthesis consensus #3). The remaining budget then
431
+ * holds the failure-first / core / query-relevant fill. */
408
432
  function selectWithinBudget(concepts: Concept[], query: string | undefined, budget: number): Concept[] {
409
- const ordered = priorityOrder(concepts, query);
410
433
  const selected: Concept[] = [];
411
- for (const c of ordered) {
412
- if (renderConcepts([...selected, c]).length <= budget) selected.push(c);
413
- }
414
- if (selected.length === 0 && ordered.length > 0) selected.push(ordered[0]!);
434
+ const take = (pool: Concept[]) => {
435
+ for (const c of priorityOrder(pool, query)) {
436
+ if (renderConcepts([...selected, c]).length <= budget) selected.push(c);
437
+ }
438
+ };
439
+ take(concepts.filter(c => c.pinned)); // reserved budget: invariants survive the cap
440
+ take(concepts.filter(c => !c.pinned)); // recency/query fill in the remaining budget
441
+ if (selected.length === 0 && concepts.length > 0) selected.push(priorityOrder(concepts, query)[0]!);
415
442
  return selected;
416
443
  }
417
444
 
@@ -610,6 +637,55 @@ async function cleanupStalePendingFiles(dir: string): Promise<void> {
610
637
  }
611
638
  }
612
639
 
640
+ /** Upsert ONE concept document into the bundle: resolve its file (dedup by title —
641
+ * same title overwrites in place, a colliding slug gets a numeric suffix), merge
642
+ * over any existing frontmatter, and atomically write it. The single source of
643
+ * truth for a concept file's on-disk shape — shared by the session-exit distiller
644
+ * and the deterministic recordFailedAttempt path. Does NOT touch index/log (callers
645
+ * batch that). Assumes a non-empty title; unknown types fall back to facts/. */
646
+ async function upsertConceptFile(
647
+ bundleDir: string,
648
+ c: { type: string; title: string; description?: string; body?: string; tags?: string[]; confidence?: string; links?: string[]; pinned?: boolean },
649
+ ): Promise<string> {
650
+ const dir = DIR_BY_TYPE[c.type] ?? "facts";
651
+ await fs.mkdir(path.join(bundleDir, dir), { recursive: true });
652
+ let slug = slugify(c.title);
653
+ let relPath = `${dir}/${slug}.md`;
654
+ let fullPath = path.join(bundleDir, relPath);
655
+ let suffix = 1;
656
+ while (true) {
657
+ try {
658
+ if ((parseConcept(await fs.readFile(fullPath, "utf-8")).frontmatter.title || "") === c.title) break;
659
+ slug = `${slugify(c.title)}-${suffix}`;
660
+ relPath = `${dir}/${slug}.md`;
661
+ fullPath = path.join(bundleDir, relPath);
662
+ suffix++;
663
+ } catch {
664
+ break;
665
+ }
666
+ }
667
+ let existingFm: Record<string, unknown> = {};
668
+ try {
669
+ existingFm = parseConcept(await fs.readFile(fullPath, "utf-8")).frontmatter as Record<string, unknown>;
670
+ } catch {}
671
+ const frontmatter = {
672
+ ...existingFm,
673
+ type: c.type,
674
+ title: c.title,
675
+ description: c.description ?? "",
676
+ tags: c.tags ?? [],
677
+ timestamp: new Date().toISOString(),
678
+ confidence: c.confidence ?? "high",
679
+ last_verified: new Date().toISOString().split("T")[0],
680
+ links: c.links ?? [],
681
+ ...(c.pinned ? { pinned: true } : {}),
682
+ };
683
+ const tmpPath = `${fullPath}.tmp-${process.pid}`;
684
+ await fs.writeFile(tmpPath, serializeConcept(frontmatter, c.body ?? ""), "utf-8");
685
+ await fs.rename(tmpPath, fullPath);
686
+ return relPath;
687
+ }
688
+
613
689
  export async function distillSessionMemory(
614
690
  history: Message[],
615
691
  cwd: string,
@@ -721,57 +797,15 @@ export async function distillSessionMemory(
721
797
  const title = typeof concept.title === "string" ? concept.title.trim() : "";
722
798
  if (!type || !title) continue;
723
799
  try {
724
- // Unknown types fall back to facts/ (lenient — OKF tolerates extra types).
725
- const dir = DIR_BY_TYPE[type] ?? "facts";
726
-
727
- const targetDir = path.join(bundleDir, dir);
728
- await fs.mkdir(targetDir, { recursive: true });
729
-
730
- let slug = slugify(title);
731
- let relPath = `${dir}/${slug}.md`;
732
- let fullPath = path.join(bundleDir, relPath);
733
-
734
- let suffix = 1;
735
- while (true) {
736
- try {
737
- const existingContent = await fs.readFile(fullPath, "utf-8");
738
- const parsed = parseConcept(existingContent);
739
- const existingTitle = parsed.frontmatter.title || "";
740
- if (existingTitle === title) {
741
- break;
742
- }
743
- slug = `${slugify(title)}-${suffix}`;
744
- relPath = `${dir}/${slug}.md`;
745
- fullPath = path.join(bundleDir, relPath);
746
- suffix++;
747
- } catch {
748
- break;
749
- }
750
- }
751
-
752
- let existingFm = {};
753
- try {
754
- const existingContent = await fs.readFile(fullPath, "utf-8");
755
- existingFm = parseConcept(existingContent).frontmatter;
756
- } catch {}
757
-
758
- const frontmatter = {
759
- ...existingFm,
760
- type,
800
+ await upsertConceptFile(bundleDir, {
801
+ type, // unknown types fall back to facts/ inside the helper (lenient)
761
802
  title,
762
803
  description: typeof concept.description === "string" ? concept.description : "",
804
+ body: typeof concept.body === "string" ? concept.body : "",
763
805
  tags: Array.isArray(concept.tags) ? concept.tags.filter((t): t is string => typeof t === "string") : [],
764
- timestamp: new Date().toISOString(),
765
806
  confidence: typeof concept.confidence === "string" ? concept.confidence : "high",
766
- last_verified: new Date().toISOString().split("T")[0],
767
807
  links: Array.isArray(concept.links) ? concept.links.filter((l): l is string => typeof l === "string") : [],
768
- };
769
-
770
- const serialized = serializeConcept(frontmatter, typeof concept.body === "string" ? concept.body : "");
771
- const tmpPath = `${fullPath}.tmp-${process.pid}`;
772
- await fs.writeFile(tmpPath, serialized, "utf-8");
773
- await fs.rename(tmpPath, fullPath);
774
-
808
+ });
775
809
  updatedConcepts.push({ title, type });
776
810
  } catch {
777
811
  // Skip just this concept; keep distilling the rest of the batch.
@@ -819,6 +853,46 @@ export async function distillSessionMemory(
819
853
  }
820
854
  }
821
855
 
856
+ /** Mid-session, deterministic (no-LLM) capture of a dead end into the OKF bundle.
857
+ * The session-exit distill only learns AFTER a session ends; a turn that stalled
858
+ * (consecutive-failure / cycle / repeat guard fired) is a dead end the *next turn
859
+ * of the SAME session* should already avoid. This writes ONE FailedAttempt concept
860
+ * immediately so the per-turn memory injection resurfaces it (priorityOrder puts a
861
+ * query-relevant FailedAttempt first), closing the loop the user described: each
862
+ * iteration gets sharper by building on accumulated failure knowledge. Confidence
863
+ * is "medium" (not "high") so it never enters the always-injected "core" tier — it
864
+ * only surfaces when the query actually hits it, and the richer session-exit distill
865
+ * later consolidates or replaces it. Best-effort: a write failure is swallowed. */
866
+ export async function recordFailedAttempt(
867
+ cwd: string,
868
+ attempt: { title: string; description?: string; body?: string; tags?: string[] },
869
+ ): Promise<{ recorded: boolean; skipped?: string }> {
870
+ if (jeoEnv("NO_MEMORY") === "1") return { recorded: false, skipped: "disabled (JEO_NO_MEMORY=1)" };
871
+ const title = attempt.title?.trim();
872
+ if (!title) return { recorded: false, skipped: "empty title" };
873
+ try {
874
+ const bundleDir = path.join(cwd, ".jeo", "memory");
875
+ // confidence "medium" keeps it out of the always-injected core tier (priorityOrder)
876
+ // — it only surfaces when the query actually hits it.
877
+ await upsertConceptFile(bundleDir, {
878
+ type: "FailedAttempt",
879
+ title,
880
+ description: attempt.description ?? "",
881
+ body: attempt.body ?? "",
882
+ tags: Array.isArray(attempt.tags) ? attempt.tags.filter(t => typeof t === "string") : [],
883
+ confidence: "medium",
884
+ });
885
+ await withIndexLock(bundleDir, async () => {
886
+ await rebuildIndex(bundleDir);
887
+ await updateLog(bundleDir, [{ title, type: "FailedAttempt" }]);
888
+ });
889
+ invalidateConceptCache();
890
+ return { recorded: true };
891
+ } catch (err: any) {
892
+ return { recorded: false, skipped: `record failed: ${err?.message ?? String(err)}` };
893
+ }
894
+ }
895
+
822
896
 
823
897
 
824
898
  // ── Detached background distillation (round-16) ──
@@ -497,7 +497,9 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
497
497
  ` "constraints": ["Optional list of constraints once ambiguity is <= ${threshold}"],\n` +
498
498
  ` "acceptance_criteria": ["Concrete, testable acceptance criteria required before the seed can freeze"]\n` +
499
499
  `}\n` +
500
- `Ensure ambiguityScore drops dynamically as more detail is gathered. Do not report ambiguityScore <= ${threshold} unless acceptance_criteria is populated with concrete, testable checks.`
500
+ `Ensure ambiguityScore drops dynamically as more detail is gathered. Do not report ambiguityScore <= ${threshold} unless acceptance_criteria is populated with concrete, testable checks.\n` +
501
+ `Do not fill an ambiguous point with your own convenient assumption to push the score down — surface that assumption as the nextQuestion instead. Never supply unstated detail that makes the request look simpler or safer than the user actually wrote it.\n` +
502
+ `Conversely, when the user's answers genuinely resolve a dimension, lower the score accordingly — do not keep a dimension ambiguous just to prolong the interview. If you notice yourself quietly narrowing the user's idea to make it easier to score, that narrowing is the signal the dimension is still open: ask, don't assume.`
501
503
  },
502
504
  {
503
505
  role: "user",
@@ -4,7 +4,7 @@ import { PassThrough } from "node:stream";
4
4
  import { runAgentLoop, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, type AgentLoopEvents } from "../agent/engine";
5
5
  import { createOpikTracer, wrapEvents } from "../agent/opik-tracer";
6
6
  import { initialDynamicStepLimit } from "../agent/step-budget";
7
- import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
7
+ import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "../agent/memory";
8
8
  import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
9
9
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
10
10
  import { SubagentRegistry } from "../agent/subagent-registry";
@@ -14,7 +14,7 @@ import { runDeepInterviewEngine, type DeepInterviewEngineOptions } from "./deep-
14
14
  import { runRalplanEngine, type RalplanEngineOptions } from "./ralplan";
15
15
  import { runTeamEngine, type TeamEngineOptions } from "./team";
16
16
  import { runUltragoalEngine, type UltragoalEngineOptions } from "./ultragoal";
17
- import { skillsPromptSection, loadSkills, buildSkillTask, workflowSkillsForPrompt, parseSkillInvocation, parseSkillChain, looksLikeSkillEcho, skillInvocationCard, type SkillDoc, type SkillInvocation } from "../skills/catalog";
17
+ import { skillsPromptSection, loadSkills, buildSkillTask, workflowSkillsForPrompt, parseSkillInvocation, parseSkillChain, parseSkillSlashInvocation, skillSlashAliases, looksLikeSkillEcho, skillInvocationCard, type SkillDoc, type SkillInvocation } from "../skills/catalog";
18
18
  import { formatForgeBox, scaleForgeWidth } from "../tui/components/forge";
19
19
  import { interactiveOAuthLogin } from "./auth";
20
20
  import { logoutOAuth, OAUTH_PROVIDERS, API_KEY_ONLY_PROVIDERS, setApiKey } from "../auth";
@@ -43,7 +43,7 @@ import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../ag
43
43
  import { listAliases } from "../ai/model-registry";
44
44
  import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/openai-compatible-catalog";
45
45
 
46
- import { allSubagentRoles, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps, resolveSubagentThinking, parseMaxSteps, withSubagentSetting, clearSubagentSetting } from "../agent/subagents";
46
+ import { allSubagentRoles, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps, resolveSubagentThinking, parseMaxSteps, withSubagentSetting, clearSubagentSetting, applyTargetChoices } from "../agent/subagents";
47
47
  import { SelectList, renderSelectList, type SelectItem } from "../tui/components/select-list";
48
48
  import { SessionPicker, renderSessionPicker } from "../tui/components/session-picker";
49
49
  import {
@@ -57,7 +57,7 @@ import {
57
57
  formatPickListWithCapabilities,
58
58
  formatCapabilityLine,
59
59
  } from "../tui/components/config-panel";
60
- import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
60
+ import { liveModelPicker, renderLiveModelPicker, buildThinkingLevelChoices, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
61
61
 
62
62
  import { loginPicker, renderLoginPicker, onboardingPicker, renderOnboardingPicker, apiKeyPicker, renderApiKeyPicker, subscriptionLoginPicker, type OnboardingAction } from "../tui/components/provider-picker";
63
63
  import { sanitizeForTerminal } from "../tui/components/code-view";
@@ -68,7 +68,7 @@ import { renderInputFrame, type HighlightRange } from "../tui/components/input-b
68
68
  import { renderStatusBar } from "../tui/components/status";
69
69
  import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
70
70
  import { readClipboardImage } from "../util/clipboard-image";
71
- import { attachImagePaths } from "../util/file-attachment";
71
+ import { attachImagePaths, insertImageTag } from "../util/file-attachment";
72
72
  import { formatTranscript } from "../tui/components/transcript";
73
73
  import { copyTextToClipboard } from "../tui/clipboard";
74
74
  import { loadInputHistory, appendInputHistory } from "../agent/input-history";
@@ -522,10 +522,27 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
522
522
 
523
523
  const workflowSkills = workflowSkillsForPrompt(resolvedSkills);
524
524
  const resolvedSkillNames = resolvedSkills.map(s => s.name);
525
- // Skills are invoked ONLY via the `$<name>` entrypoint, never `/`. The `/` menu therefore
526
- // advertises NO skill slash commands — keeping `skillSlashDetails` empty leaves the slash
527
- // palette, autocomplete, and previews free of skill entries.
525
+ // Skills can ADD `/` commands: every slash alias a resolved skill declares (frontmatter
526
+ // `aliases:`/`slash:`) becomes a real, dispatchable palette command grouped under "skills",
527
+ // so installing a skill extends the slash menu, autocomplete, and previews — not just `$<name>`.
528
+ // First declarer of an alias wins (aliases are owner-scoped, so cross-skill collisions are rare).
528
529
  const skillSlashDetails: SlashCommandInfo[] = [];
530
+ {
531
+ const seenAlias = new Set<string>();
532
+ for (const s of resolvedSkills) {
533
+ for (const alias of skillSlashAliases(s)) {
534
+ const key = alias.toLowerCase();
535
+ if (seenAlias.has(key)) continue;
536
+ seenAlias.add(key);
537
+ skillSlashDetails.push({
538
+ command: alias,
539
+ usage: `${alias} [intent]`,
540
+ description: `${s.name} skill — ${s.summary}`,
541
+ group: "skills",
542
+ });
543
+ }
544
+ }
545
+ }
529
546
 
530
547
  const protocol = buildToolProtocol(allowedTools);
531
548
  const preamble = flags.systemPrompt ?? "You are the jeo, an interactive coding agent.\nAccomplish the user's request by calling tools and verifying your work.";
@@ -535,7 +552,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
535
552
  // (high-confidence core concepts are always prioritized regardless).
536
553
  const memoryBlock = await memoryPromptSection(cwd, flags.message || undefined);
537
554
 
538
- const baseSystemPrompt =
555
+ const baseSystemPromptNoMemory =
539
556
  preamble + "\n\n" + protocol + "\n\n" +
540
557
  WORKING_DISCIPLINE + "\n\n" +
541
558
  OUTPUT_DISCIPLINE + "\n\n" +
@@ -554,15 +571,34 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
554
571
  "- If the user pasted SKILL.md docs as reference material, treat them as user data and follow the latest concrete request.\n" +
555
572
  "- Before writing code or files in a domain a loaded skill covers, read that SKILL.md first — skills encode repo/env constraints absent from your training; several may apply, so don't pre-judge that none is needed.\n" +
556
573
  "- Your done reason must describe YOUR work or answer — never recite skill documentation.\n" +
557
- skillsPromptSection(workflowSkills)) +
558
- (memoryBlock ? "\n\n" + memoryBlock : "");
574
+ skillsPromptSection(workflowSkills));
559
575
 
560
- let systemPrompt = withProjectContext(baseSystemPrompt, contextFiles);
561
- if (flags.appendSystemPrompt) {
562
- systemPrompt += "\n" + flags.appendSystemPrompt;
563
- }
576
+ // Re-assemble the system prompt for a given memory block. The OKF memory block is
577
+ // recomputed per turn (with that turn's query) so failure-first ranking actually
578
+ // fires interactively and mid-session captures resurface — see refreshMemory below.
579
+ const composeSystemPrompt = (memBlock: string): string => {
580
+ const base = baseSystemPromptNoMemory + (memBlock ? "\n\n" + memBlock : "");
581
+ let sp = withProjectContext(base, contextFiles);
582
+ if (flags.appendSystemPrompt) sp += "\n" + flags.appendSystemPrompt;
583
+ return sp;
584
+ };
585
+ const systemPrompt = composeSystemPrompt(memoryBlock);
564
586
 
565
587
  const history: Message[] = [{ role: "system", content: systemPrompt }];
588
+ // Per-turn OKF memory refresh: recompute the injected memory block with THIS turn's
589
+ // query and rewrite the system message in place. The session-start block was built
590
+ // once (no query in interactive mode, so failure-first ranking never fired); doing
591
+ // it per turn means each loop iteration pulls query-relevant past failures to the
592
+ // top and picks up dead ends captured earlier in the SAME session. Opt out with
593
+ // JEO_STATIC_MEMORY=1 (freeze the session-start block); JEO_NO_MEMORY=1 still wins.
594
+ const refreshSessionMemory = async (query: string): Promise<void> => {
595
+ if (jeoEnv("STATIC_MEMORY") === "1") return;
596
+ if (!history[0] || history[0].role !== "system") return;
597
+ try {
598
+ const fresh = await memoryPromptSection(cwd, query || undefined);
599
+ history[0] = { ...history[0], content: composeSystemPrompt(fresh) };
600
+ } catch { /* keep the prior system prompt on any failure */ }
601
+ };
566
602
  let sessionModel: string | undefined = initialSessionModel;
567
603
  // Session thinking-level override (`/thinking`); falls back to the config level.
568
604
  let sessionThinking: "minimal" | "low" | "medium" | "high" | "xhigh" | undefined = flags.thinking ?? cfg.thinkingLevel;
@@ -764,6 +800,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
764
800
  interactiveTurnActive = true;
765
801
  tui.start();
766
802
  }
803
+ // Refresh injected OKF memory with THIS turn's query before the model call so
804
+ // failure-first ranking fires and same-session dead ends resurface.
805
+ await refreshSessionMemory(userInput);
767
806
  const compRes = await maybeCompact(history, {
768
807
  model: activeModel,
769
808
  contextTokens,
@@ -1062,6 +1101,30 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1062
1101
  sessionUsage.outputTokens += result.usage.outputTokens;
1063
1102
  }
1064
1103
  sessionUsage.turns++;
1104
+
1105
+ // Mid-session failure capture: a guard-detected stall (the model repeated /
1106
+ // cycled / consecutively failed without recovering) is a dead end the rest of
1107
+ // THIS session should avoid. Record it into OKF now (deterministic, no LLM) so
1108
+ // the next turn's memory refresh resurfaces it. Best-effort; never blocks the turn.
1109
+ if (result.stopClass) {
1110
+ const task = userInput.replace(/\s+/g, " ").trim();
1111
+ const excerpt = task.length > 70 ? task.slice(0, 70) + "…" : task;
1112
+ const why = result.stopClass === "consecutive_failure"
1113
+ ? "consecutive failing tool calls"
1114
+ : result.stopClass === "cycle"
1115
+ ? "cycling through the same tool calls"
1116
+ : "repeating the same tool call";
1117
+ const tags = Array.from(new Set(
1118
+ task.toLowerCase().match(/[a-z0-9][a-z0-9_-]{3,}/g) ?? [],
1119
+ )).slice(0, 8);
1120
+ void recordFailedAttempt(cwd, {
1121
+ title: `Stalled on: ${excerpt}`,
1122
+ description: `A prior turn stalled (${why}) on this task — change approach before retrying.`,
1123
+ body: `Task: ${task.slice(0, 240)}\n\nThe agent gave up after ${why} (${result.steps} steps) and could not recover. ` +
1124
+ `Do NOT repeat the same line of attack; try a different decomposition, tool, or verification path.`,
1125
+ tags,
1126
+ }).catch(() => {});
1127
+ }
1065
1128
  const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
1066
1129
 
1067
1130
  if (turnConfig.gitAutoCommit) {
@@ -1214,12 +1277,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1214
1277
  }
1215
1278
  return;
1216
1279
  }
1217
- // Slash / file-path / `/skill:` entrypoints (non-`$`) still resolve to a single skill.
1280
+ // A `$skill` invocation resolves to a single skill.
1218
1281
  const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
1219
1282
  if (skillInvocation) {
1220
1283
  await runOneSkillShot(skillInvocation);
1221
1284
  return;
1222
1285
  }
1286
+ // A declared skill `/alias` (e.g. `jeo "/obsidian-capture note"`) dispatches the owning skill.
1287
+ const slashSkill = parseSkillSlashInvocation(messageContent, resolvedSkills);
1288
+ if (slashSkill) {
1289
+ await runOneSkillShot(slashSkill);
1290
+ return;
1291
+ }
1223
1292
  try {
1224
1293
  const { reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
1225
1294
  if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
@@ -2403,15 +2472,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2403
2472
  current: ThinkLevel | undefined,
2404
2473
  inheritLabel?: string,
2405
2474
  ): Promise<ThinkLevel | "inherit" | undefined> => {
2406
- const mark = (lvl: string): string => (current === lvl ? "current" : "");
2407
- const levels: { value: string; label: string; hint?: string }[] = [
2408
- ...(inheritLabel ? [{ value: "inherit", label: inheritLabel, hint: current === undefined ? "current" : "" }] : []),
2409
- { value: "minimal", label: "minimal — lightest reasoning", hint: mark("minimal") },
2410
- { value: "low", label: `low — light reasoning (~${Math.round(thinkingMaxTokens("low") / 1000)}k tokens)`, hint: mark("low") },
2411
- { value: "medium", label: `medium — moderate reasoning (~${Math.round(thinkingMaxTokens("medium") / 1000)}k tokens)`, hint: mark("medium") },
2412
- { value: "high", label: `high — deep reasoning (~${Math.round(thinkingMaxTokens("high") / 1000)}k tokens)`, hint: mark("high") },
2413
- { value: "xhigh", label: `xhigh — maximum reasoning (~${Math.round(thinkingMaxTokens("xhigh") / 1000)}k tokens)`, hint: mark("xhigh") },
2414
- ];
2475
+ const levels = buildThinkingLevelChoices(current, {
2476
+ inheritLabel,
2477
+ tokenHint: lvl => (lvl === "minimal" ? undefined : `~${Math.round(thinkingMaxTokens(lvl as ThinkLevel) / 1000)}k tokens`),
2478
+ });
2415
2479
  const picked = await pickFromOptions(title, levels);
2416
2480
  return picked === "inherit" || isThinkingLevel(picked) ? picked : undefined;
2417
2481
  };
@@ -2439,57 +2503,63 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2439
2503
  return leaf.replace(/^gpt/i, "GPT");
2440
2504
  };
2441
2505
 
2442
- const modelActionChoices = (config: Awaited<ReturnType<typeof readGlobalConfig>>): SelectItem<string>[] => {
2443
- const levels: ThinkLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
2444
- const choices: SelectItem<string>[] = [];
2445
- const currentDefaultThinking = sessionThinking ?? config.thinkingLevel ?? "medium";
2446
- const appendChildren = (children: Array<Omit<SelectItem<string>, "depth" | "branch">>) => {
2447
- children.forEach((child, index) => {
2448
- choices.push({ ...child, depth: 1, branch: index === children.length - 1 ? "last" : "mid" });
2449
- });
2450
- };
2451
-
2452
- choices.push({
2453
- value: "heading:default",
2454
- label: "Set as DEFAULT (Default)",
2455
- hint: `${config.defaultModel} (${currentDefaultThinking}) · roles → /agents`,
2456
- disabled: true,
2457
- });
2458
- appendChildren([
2459
- { value: "default:keep", label: "Set model only", hint: `keep thinking ${currentDefaultThinking}` },
2460
- ...levels.map(level => ({
2461
- value: `default:${level}`,
2462
- label: `thinking ${level}`,
2463
- hint: level === currentDefaultThinking ? "current" : `~${Math.round(thinkingMaxTokens(level) / 1000)}k tokens`,
2464
- })),
2465
- ]);
2466
-
2467
- return choices;
2468
- };
2469
-
2470
-
2471
-
2472
-
2506
+ // gajae-code `/model` parity: after a model is picked, the interactive flow
2507
+ // runs TWO menus — gjc's `#renderActionMenu` ("Set as DEFAULT / EXECUTOR / …",
2508
+ // i.e. WHICH target uses this model) followed by its `#renderThinkingMenu`
2509
+ // ("Reasoning for <target>", the per-target reasoning budget). DEFAULT updates
2510
+ // the active session model; a subagent role writes ~/.jeo/config.json without
2511
+ // switching the chat model.
2473
2512
  const applyPickedModelWithTarget = async (target: string): Promise<boolean> => {
2474
2513
  if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
2475
2514
  const cfgForPick = await readGlobalConfig();
2476
- // `/model` only assigns the DEFAULT model + (optionally) the default thinking.
2477
- // Per-role model and thinking are configured in /agents (and /agents edit).
2478
- const choice = await pickFromOptions(`Model Name: ${displayModelName(target)}\n\nAction for: ${target}`, modelActionChoices(cfgForPick)) ?? "default:keep";
2479
- const [, action = "keep"] = choice.split(":", 2);
2480
- const { resolved, provider } = await describeModel(target);
2481
- const st = (await describeAllProviders(cfgForPick)).find(s => s.name === provider);
2482
- sessionModel = target;
2483
- await persistSessionModel();
2484
- const defaultThinking = isThinkingLevel(action) ? action : undefined;
2485
- if (defaultThinking) {
2486
- sessionThinking = defaultThinking;
2515
+ const targetId = await pickFromOptions(
2516
+ `Model Name: ${displayModelName(target)}\n\nAction for: ${target}`,
2517
+ applyTargetChoices(cfgForPick).map(c => ({ value: c.value, label: c.label, hint: c.hint })),
2518
+ );
2519
+ if (!targetId) {
2520
+ console.log("(cancelled)");
2521
+ return true;
2487
2522
  }
2488
- await saveConfigPatch(raw => ({
2489
- ...rememberModelPatch(raw, target),
2490
- ...(defaultThinking ? { thinkingLevel: defaultThinking } : {}),
2491
- }));
2492
- console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })}${defaultThinking ? ` · thinking ${defaultThinking}` : ""} — saved as default. Role models/thinking: /agents`);
2523
+
2524
+ if (targetId === "default") {
2525
+ const currentDefaultThinking = (sessionThinking ?? cfgForPick.thinkingLevel ?? "medium") as ThinkLevel;
2526
+ const level = await pickThinkingLevel(`Reasoning for default: ${target}`, currentDefaultThinking);
2527
+ if (level === undefined) {
2528
+ console.log("(cancelled)");
2529
+ return true;
2530
+ }
2531
+ const { resolved, provider } = await describeModel(target);
2532
+ const st = (await describeAllProviders(cfgForPick)).find(s => s.name === provider);
2533
+ sessionModel = target;
2534
+ await persistSessionModel();
2535
+ const defaultThinking = isThinkingLevel(level) ? level : undefined;
2536
+ if (defaultThinking) {
2537
+ sessionThinking = defaultThinking;
2538
+ }
2539
+ await saveConfigPatch(raw => ({
2540
+ ...rememberModelPatch(raw, target),
2541
+ ...(defaultThinking ? { thinkingLevel: defaultThinking } : {}),
2542
+ }));
2543
+ console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })}${defaultThinking ? ` · thinking ${defaultThinking}` : ""} — saved as default.`);
2544
+ return true;
2545
+ }
2546
+
2547
+ const role = getSubagentRole(targetId, cfgForPick);
2548
+ if (!role) return false;
2549
+ const currentRoleThinking = resolveSubagentThinking(role.id, cfgForPick) as ThinkLevel | undefined;
2550
+ const level = await pickThinkingLevel(
2551
+ `Reasoning for ${role.title}: ${target}`,
2552
+ currentRoleThinking,
2553
+ `inherit (default ${cfgForPick.thinkingLevel ?? "medium"})`,
2554
+ );
2555
+ if (level === undefined) {
2556
+ console.log("(cancelled)");
2557
+ return true;
2558
+ }
2559
+ const thinking = level === "inherit" ? undefined : (isThinkingLevel(level) ? level : undefined);
2560
+ await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: target, thinking }) }));
2561
+ const { provider } = await describeModel(target);
2562
+ console.log(`${role.title} model set to ${target} (${provider})${level === "inherit" ? " · thinking inherits default" : ` · thinking ${level}`} — saved to ~/.jeo/config.json.`);
2493
2563
  return true;
2494
2564
  };
2495
2565
 
@@ -2671,7 +2741,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2671
2741
  if (dropped.images.length === 0 || dropped.text === before) return;
2672
2742
  pendingImages.push(...dropped.images);
2673
2743
  rli.line = dropped.text;
2674
- rli.cursor = dropped.text.length; // drop lands at the caret (line end in the common case)
2744
+ rli.cursor = dropped.cursor; // park right after the swapped-in tag (whitespace normalized)
2675
2745
  typedLine = dropped.text;
2676
2746
  // Keep readline's internal screen model in step with the swapped-in tag so
2677
2747
  // the caret is not offset on the next keystroke (mirrors the Ctrl+V path).
@@ -2736,12 +2806,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2736
2806
  const img = await readClipboardImage();
2737
2807
  if (!img) return;
2738
2808
  pendingImages.push(img);
2739
- const tag = `[image #${pendingImages.length}]`;
2740
2809
  const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
2741
2810
  const at = typeof rli.cursor === "number" ? rli.cursor : rli.line.length;
2742
- const sep = rli.line.length > 0 && at > 0 && rli.line[at - 1] !== " " ? " " : "";
2743
- rli.line = rli.line.slice(0, at) + sep + tag + " " + rli.line.slice(at);
2744
- rli.cursor = at + sep.length + tag.length + 1;
2811
+ // Insert the [image #N] tag at the caret with normalized single-space
2812
+ // padding (shared with the drag-drop path) so the caret lands right after
2813
+ // the tag instead of being pushed several columns by stray spaces.
2814
+ const ins = insertImageTag(rli.line, at, pendingImages.length);
2815
+ rli.line = ins.text;
2816
+ rli.cursor = ins.cursor;
2745
2817
  typedLine = rli.line;
2746
2818
  // Sync readline's internal screen model to the injected tag (same as the
2747
2819
  // slash/tab completion accept paths): without it readline's stale row/cursor
@@ -4371,6 +4443,20 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4371
4443
  continue;
4372
4444
  }
4373
4445
  }
4446
+ // A declared skill slash alias (e.g. `/obsidian-capture note`) dispatches the owning skill,
4447
+ // passing the alias as the routing signal and the rest of the line as the intent. Placed
4448
+ // after every built-in slash handler (built-ins win) and before the unhandled-slash fallback.
4449
+ if (!flags.noSkills && input.startsWith("/")) {
4450
+ const slashSkill = parseSkillSlashInvocation(input, resolvedSkills);
4451
+ if (slashSkill) {
4452
+ try {
4453
+ await runSkillInvocation(slashSkill.skill, slashSkill.intent, slashSkill.invokedAs);
4454
+ } catch (err) {
4455
+ console.log(`! ${(err as Error).message}`);
4456
+ }
4457
+ continue;
4458
+ }
4459
+ }
4374
4460
  // Unhandled slash attempt → suggest, don't send the typo to the model.
4375
4461
  if (isSlashAttempt(input)) {
4376
4462
  const m = matchSlash(input, [...completionContext().slashCommands]);