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.
@@ -1,18 +1,9 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import * as os from "node:os";
4
- import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs, bundledSkillFileContent } from "../skills/catalog";
3
+ import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs, bundledSkillFileContent, userSkillsDir } from "../skills/catalog";
5
4
  import { getLocalJeoDir } from "../agent/state";
6
- import { jeoEnv } from "../util/env";
7
-
8
- /** User-level bundled-skills install dir (`~/.jeo/skills`, honoring JEO_CONFIG_DIR /
9
- * $HOME overrides) — the destination `jeo skills sync` reconciles against, matching
10
- * the highest-precedence flat dir in {@link skillDirs}. */
11
- export function userSkillsDir(): string {
12
- const userHome = process.env.HOME || os.homedir();
13
- const home = jeoEnv("CONFIG_DIR") || path.join(userHome, ".jeo");
14
- return path.join(home, "skills");
15
- }
5
+
6
+ export { userSkillsDir };
16
7
 
17
8
  export type SkillSyncStatus = "missing" | "up-to-date" | "differs";
18
9
  export type SkillSyncAction = "installed" | "overwritten" | "preserved" | "unchanged" | "none";
@@ -11,6 +11,7 @@ Assess architecture, maintainability, correctness, and spec compliance with file
11
11
  - Prioritize spec/root-cause correctness before style comments.
12
12
  - Rate findings by severity: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`.
13
13
  - Never return `APPROVE` if CRITICAL/HIGH issues remain.
14
+ - A clean verdict is not the absence of inspection: do not return `CLEAR`/`APPROVE` merely because no problem surfaced. Base the verdict on files and paths you concretely examined, and say which ones.
14
15
  </constraints>
15
16
 
16
17
  <execution_loop>
@@ -29,6 +30,7 @@ Assess architecture, maintainability, correctness, and spec compliance with file
29
30
  Your final `done.reason` MUST be markdown with these sections:
30
31
  - `Summary:`
31
32
  - `Findings:`
33
+ - `Inspected:` the files/paths you actually examined (evidence for the verdict)
32
34
  - `Recommendations:`
33
35
  - `Architectural Status:` one of `CLEAR`, `WATCH`, `BLOCK`
34
36
  - `Code Review Recommendation:` one of `APPROVE`, `COMMENT`, `REQUEST CHANGES`
@@ -10,6 +10,7 @@ Decide whether a plan or approach is actionable before execution proceeds.
10
10
  - Read-only: never modify files.
11
11
  - Do not invent problems; reject only with concrete gaps.
12
12
  - Simulate representative tasks against inspected evidence before deciding.
13
+ - Honesty cuts both ways: if you catch yourself softening a real, blocking gap into `[ITERATE]` just to avoid blocking, that softening is the signal the gap is real — name it. But never manufacture a block: when gaps are concrete yet fixable in-flight prefer `[ITERATE]` over `[REJECT]`, and return `[OKAY]` once the plan is genuinely actionable.
13
14
  </constraints>
14
15
 
15
16
  <execution_loop>
@@ -261,6 +261,15 @@ export function skillSlashAliases(skill: SkillDoc): string[] {
261
261
  }
262
262
 
263
263
 
264
+ /** User-level bundled-skills install dir (`<config>/skills`, i.e. `~/.jeo/skills`
265
+ * honoring JEO_CONFIG_DIR / $HOME) — the highest-precedence flat skill dir and the
266
+ * destination `jeo skills sync` reconciles against. $HOME wins over os.homedir() so
267
+ * tests/sandboxes that re-point HOME are honored (see {@link skillDirs}). */
268
+ export function userSkillsDir(): string {
269
+ const userHome = process.env.HOME || os.homedir();
270
+ return path.join(jeoEnv("CONFIG_DIR") || path.join(userHome, ".jeo"), "skills");
271
+ }
272
+
264
273
  /** Global + per-project skill-doc directories (user-configurable SKILL.md files).
265
274
  * Ordered lowest → highest precedence (a later dir's skill overrides an earlier
266
275
  * one with the same name): foreign-ecosystem roots first, jeo-native last.
@@ -283,7 +292,7 @@ export function skillDirs(cwd: string = process.cwd()): string[] {
283
292
  path.join(userHome, ".claude", "skills"),
284
293
  path.join(home, "agent", "skills"),
285
294
  path.join(userHome, ".agents", "skills"),
286
- path.join(home, "skills"),
295
+ userSkillsDir(),
287
296
  path.join(cwd, ".claude", "skills"),
288
297
  path.join(cwd, ".agents", "skills"),
289
298
  path.join(cwd, ".jeo", "skills"),
@@ -512,6 +521,20 @@ export function getSkillBySlash(skills: SkillDoc[], command: string): SkillDoc |
512
521
  return skills.find(s => skillSlashAliases(s).some(a => a.toLowerCase() === q));
513
522
  }
514
523
 
524
+ /** Resolve a leading `/alias` slash command to the skill that DECLARES it, e.g.
525
+ * `/obsidian-capture note text` → { skill, intent: "note text", invokedAs: "/obsidian-capture" }.
526
+ * Returns null when the first token is not a declared skill slash alias. This is what lets an
527
+ * installed skill add real, dispatchable `/` commands (not just routing-hint metadata): only the
528
+ * FIRST token is matched, and the rest of the line becomes the intent passed to the skill. */
529
+ export function parseSkillSlashInvocation(input: string, skills: SkillDoc[]): SkillInvocation | null {
530
+ const trimmed = input.trim();
531
+ if (!trimmed.startsWith("/")) return null;
532
+ const command = trimmed.split(/\s+/, 1)[0] ?? "";
533
+ const skill = getSkillBySlash(skills, command);
534
+ if (!skill) return null;
535
+ return { skill, intent: trimmed.slice(command.length).trim(), invokedAs: command };
536
+ }
537
+
515
538
  export interface SkillInvocation {
516
539
  skill: SkillDoc;
517
540
  intent: string;
@@ -13,7 +13,7 @@
13
13
  * completer never blocks on the network. Static data (slash names, catalog ids,
14
14
  * provider names, role ids) is filled by `staticCompletionContext()`.
15
15
  */
16
- import { SLASH_COMMANDS } from "./slash";
16
+ import { SLASH_COMMANDS, SLASH_COMMAND_DESCRIPTIONS } from "./slash";
17
17
  import { catalogIds } from "../../ai/model-catalog-compat";
18
18
  import { PROVIDER_NAMES } from "../../ai/provider-status";
19
19
  import { SUBAGENT_ROLES } from "../../agent/subagents";
@@ -84,6 +84,82 @@ function prefixHits(pool: string[], token: string): string[] {
84
84
  return pool.filter(c => c.toLowerCase().startsWith(q));
85
85
  }
86
86
 
87
+ /**
88
+ * Fuzzy subsequence test (gjc parity): every char of `query` appears in
89
+ * `target` in order (not necessarily adjacent). Empty query matches everything.
90
+ */
91
+ export function fuzzyMatch(query: string, target: string): boolean {
92
+ if (!query) return true;
93
+ let qi = 0;
94
+ for (let ti = 0; ti < target.length && qi < query.length; ti++) {
95
+ if (target[ti] === query[qi]) qi++;
96
+ }
97
+ return qi === query.length;
98
+ }
99
+
100
+ /**
101
+ * Score a match (gjc parity): exact=100 > startsWith=80 > includes=60 >
102
+ * subsequence(40 − gaps×5, min 1). Returns 0 when `query` is not a subsequence.
103
+ */
104
+ export function fuzzyScore(query: string, target: string): number {
105
+ if (!query) return 80;
106
+ if (query === target) return 100;
107
+ if (target.startsWith(query)) return 80;
108
+ if (target.includes(query)) return 60;
109
+ let qi = 0;
110
+ let gaps = 0;
111
+ let lastMatch = -1;
112
+ for (let ti = 0; ti < target.length && qi < query.length; ti++) {
113
+ if (target[ti] === query[qi]) {
114
+ if (lastMatch !== -1 && ti !== lastMatch + 1) gaps++;
115
+ lastMatch = ti;
116
+ qi++;
117
+ }
118
+ }
119
+ if (qi < query.length) return 0;
120
+ return Math.max(1, 40 - gaps * 5);
121
+ }
122
+
123
+ /**
124
+ * Fuzzy command-name matches, ranked best-first (gjc §2.1): higher fuzzyScore
125
+ * first, ties broken by registration order so the list stays stable. Prefix
126
+ * matches always outrank looser subsequence hits, so `/mod`→`/model` is kept
127
+ * while `/mdl`→`/model` now also completes.
128
+ */
129
+ function fuzzyHits(pool: string[], token: string): string[] {
130
+ const q = token.toLowerCase();
131
+ return pool
132
+ .map((c, index) => ({ c, score: fuzzyScore(q, c.toLowerCase()), index }))
133
+ .filter(x => x.score > 0)
134
+ .sort((a, b) => b.score - a.score || a.index - b.index)
135
+ .map(x => x.c);
136
+ }
137
+
138
+ /**
139
+ * Command-name completion with a description fallback (gjc §2.1). Name matches
140
+ * win outright (so `/mod`→`/model` stays precise); only when NOTHING matches the
141
+ * name do we fall back to substring-or-better description hits, so intent-style
142
+ * queries with no literal name match still resolve — e.g. `/oauth`→`/login`,
143
+ * `/transcript`→`/dump`. The description fallback requires ≥2 query chars and a
144
+ * real substring (not a loose subsequence) to avoid flooding the dropdown.
145
+ */
146
+ function fuzzyCommandHits(pool: string[], token: string): string[] {
147
+ const nameHits = fuzzyHits(pool, token);
148
+ if (nameHits.length > 0) return nameHits;
149
+
150
+ const q = token.replace(/^\//, "").toLowerCase();
151
+ if (q.length < 2) return [];
152
+ return pool
153
+ .map((c, index) => {
154
+ const desc = SLASH_COMMAND_DESCRIPTIONS.get(c);
155
+ const hit = desc?.includes(q) ?? false;
156
+ return { c, score: hit ? fuzzyScore(q, desc!) * 0.5 : 0, index };
157
+ })
158
+ .filter(x => x.score > 0)
159
+ .sort((a, b) => b.score - a.score || a.index - b.index)
160
+ .map(x => x.c);
161
+ }
162
+
87
163
  /** De-duplicate (case-insensitive, first wins), preserving order, capped. */
88
164
  function dedupeCap(items: string[], cap = MAX_COMPLETIONS): string[] {
89
165
  const seen = new Set<string>();
@@ -129,7 +205,7 @@ export function complete(line: string, ctx: CompletionContext): CompletionResult
129
205
  // `/command` mention completion mid-line (the leading-token case is the
130
206
  // dedicated command branch below, which also completes arguments).
131
207
  if (token.startsWith("/")) {
132
- return { completions: dedupeCap(prefixHits(ctx.slashCommands, token)), token, kind: "command" };
208
+ return { completions: dedupeCap(fuzzyCommandHits(ctx.slashCommands, token)), token, kind: "command" };
133
209
  }
134
210
  return { completions: [], token: line, kind: "none" };
135
211
  }
@@ -137,7 +213,7 @@ export function complete(line: string, ctx: CompletionContext): CompletionResult
137
213
  // Completing the command name itself (single token, still typing it).
138
214
  if (tokens.length <= 1 && !trailingSpace) {
139
215
  const token = tokens[0] ?? "/";
140
- return { completions: dedupeCap(prefixHits(ctx.slashCommands, token)), token, kind: "command" };
216
+ return { completions: dedupeCap(fuzzyCommandHits(ctx.slashCommands, token)), token, kind: "command" };
141
217
  }
142
218
 
143
219
  const cmd = tokens[0]!.toLowerCase();
@@ -106,3 +106,43 @@ export function liveModelPicker(entries: PickEntry[], opts: LiveModelPickerOptio
106
106
  export function renderLiveModelPicker(list: SelectList<PickEntry>, opts: RenderSelectOptions = {}): string[] {
107
107
  return renderSelectList(list, { title: "Select a live model", rows: 12, ...opts });
108
108
  }
109
+
110
+ /** The five reasoning levels in display order, lightest → heaviest. */
111
+ export const THINKING_LEVEL_ORDER = ["minimal", "low", "medium", "high", "xhigh"] as const;
112
+
113
+ const THINKING_LEVEL_DESCRIPTION: Record<string, string> = {
114
+ minimal: "lightest reasoning",
115
+ low: "light reasoning",
116
+ medium: "moderate reasoning",
117
+ high: "deep reasoning",
118
+ xhigh: "maximum reasoning",
119
+ };
120
+
121
+ export interface ThinkingLevelChoiceOptions {
122
+ /** Prepend an "inherit" row (role targets only, which can follow the default). */
123
+ inheritLabel?: string;
124
+ /** Per-level reasoning-budget hint, e.g. "~32k tokens". Return undefined to omit. */
125
+ tokenHint?: (level: string) => string | undefined;
126
+ }
127
+
128
+ /**
129
+ * gajae-code-parity reasoning menu (gjc's `#renderThinkingMenu`): the per-target
130
+ * "Reasoning for …" choices shown after a model is picked. Each level renders as
131
+ * `<level> — <description> (<tokens>)` with the current level flagged. Role
132
+ * targets gain a leading `inherit` row. Pure — fully unit-testable.
133
+ */
134
+ export function buildThinkingLevelChoices(
135
+ current: string | undefined,
136
+ opts: ThinkingLevelChoiceOptions = {},
137
+ ): SelectItem<string>[] {
138
+ const items: SelectItem<string>[] = [];
139
+ if (opts.inheritLabel) {
140
+ items.push({ value: "inherit", label: opts.inheritLabel, hint: current === undefined ? "current" : "" });
141
+ }
142
+ for (const level of THINKING_LEVEL_ORDER) {
143
+ const tokens = opts.tokenHint?.(level);
144
+ const label = `${level} — ${THINKING_LEVEL_DESCRIPTION[level]}${tokens ? ` (${tokens})` : ""}`;
145
+ items.push({ value: level, label, hint: current === level ? "current" : "" });
146
+ }
147
+ return items;
148
+ }
@@ -65,6 +65,11 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
65
65
 
66
66
  export const SLASH_COMMANDS = SLASH_COMMAND_DETAILS.map(c => c.command);
67
67
 
68
+ /** Bundled command name → lowercased description (gjc §2.1 description matching). */
69
+ export const SLASH_COMMAND_DESCRIPTIONS = new Map<string, string>(
70
+ SLASH_COMMAND_DETAILS.map(c => [c.command, c.description.toLowerCase()]),
71
+ );
72
+
68
73
  export function mergeSlashCommandDetails(extra: readonly SlashCommandInfo[] = []): SlashCommandInfo[] {
69
74
  const byCommand = new Map<string, SlashCommandInfo>();
70
75
  for (const d of [...SLASH_COMMAND_DETAILS, ...extra]) byCommand.set(d.command, d);
@@ -73,8 +78,10 @@ export function mergeSlashCommandDetails(extra: readonly SlashCommandInfo[] = []
73
78
 
74
79
  /** Return the slash commands that match `input` (case-insensitive). Prefix matches
75
80
  * come first; a fuzzy subsequence fallback (gjc-style, e.g. `/expt` → `/export`)
76
- * follows so typos / abbreviations still surface a command. A bare `/` lists every
77
- * command. Empty for non-slash input. */
81
+ * follows so typos / abbreviations still surface a command. When NOTHING matches
82
+ * the name, fall back to a description substring match (gjc §2.1) so intent-style
83
+ * queries with no literal name match still resolve — e.g. `/oauth` → `/login`,
84
+ * `/clipboard` → `/dump`. A bare `/` lists every command. Empty for non-slash input. */
78
85
  export function matchSlash(input: string, commands: string[] = SLASH_COMMANDS): string[] {
79
86
  if (!input.startsWith("/")) return [];
80
87
  const q = input.toLowerCase();
@@ -82,7 +89,12 @@ export function matchSlash(input: string, commands: string[] = SLASH_COMMANDS):
82
89
  if (body === "") return [...commands];
83
90
  const starts = commands.filter(c => c.toLowerCase().startsWith(q));
84
91
  const fuzzy = commands.filter(c => !starts.includes(c) && subsequence(body, c.slice(1).toLowerCase()));
85
- return [...starts, ...fuzzy];
92
+ const named = [...starts, ...fuzzy];
93
+ if (named.length > 0) return named;
94
+ // Description fallback: only when no name matches, and only for queries of ≥2
95
+ // chars with a real substring hit (not a loose subsequence), to avoid flooding.
96
+ if (body.length < 2) return [];
97
+ return commands.filter(c => SLASH_COMMAND_DESCRIPTIONS.get(c)?.includes(body) ?? false);
86
98
  }
87
99
 
88
100
  /** True when `input` looks like a slash command (starts with "/" and has no space). */
@@ -18,6 +18,54 @@ import type { ImageAttachment } from "../ai/types";
18
18
 
19
19
  const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|bmp)$/i;
20
20
 
21
+
22
+ /**
23
+ * Normalize the whitespace around every `[image #N]` tag to a single space:
24
+ * - one space before a tag that follows other text (none at line start),
25
+ * - one space after a tag that is followed by more text,
26
+ * - any run of spaces/tabs collapsed to one, and the whole string trimmed.
27
+ *
28
+ * A tag at the very end keeps NO trailing space (callers that want the caret to sit
29
+ * past it add exactly one). Terminals pad a dragged-and-dropped path with their own
30
+ * spacing and the Ctrl+V insert adds a separator, so without this the caret parks
31
+ * several columns past the tag — the "input point looks pushed by a few spaces" bug.
32
+ * Idempotent: re-normalizing already-clean text is a no-op.
33
+ */
34
+ export function normalizeImageTags(text: string): string {
35
+ return text
36
+ .replace(/[ \t]*(\[image #\d+\])[ \t]*/g, (m, tag: string, off: number, s: string) =>
37
+ (off > 0 ? " " : "") + tag + (off + m.length < s.length ? " " : ""),
38
+ )
39
+ .replace(/[ \t]{2,}/g, " ")
40
+ .trim();
41
+ }
42
+
43
+ /** Caret offset just past the `[image #n]` tag in `text` (and its single trailing
44
+ * space when present); falls back to end-of-text when the tag is absent. */
45
+ export function caretAfterTag(text: string, n: number): number {
46
+ const tag = `[image #${n}]`;
47
+ const idx = text.indexOf(tag);
48
+ if (idx < 0) return text.length;
49
+ let end = idx + tag.length;
50
+ if (text[end] === " ") end += 1; // sit after the separating space, not on it
51
+ return end;
52
+ }
53
+
54
+ /**
55
+ * Insert an `[image #n]` tag at `cursor` within `line` (the Ctrl+V clipboard-image
56
+ * path), normalizing surrounding whitespace so the tag is flanked by exactly one
57
+ * space. A tag landing at end-of-line gets one trailing space so the user can keep
58
+ * typing; the returned caret sits right after the tag (and that space).
59
+ */
60
+ export function insertImageTag(line: string, cursor: number, n: number): { text: string; cursor: number } {
61
+ const at = Math.max(0, Math.min(cursor, line.length));
62
+ const tag = `[image #${n}]`;
63
+ let text = normalizeImageTags(line.slice(0, at) + tag + line.slice(at));
64
+ const idx = text.indexOf(tag);
65
+ if (idx >= 0 && idx + tag.length >= text.length) text += " "; // trailing space at EOL for typing
66
+ return { text, cursor: caretAfterTag(text, n) };
67
+ }
68
+
21
69
  /** Detect an image media type from magic bytes, or null when the bytes are not a
22
70
  * recognised image. Used as the authoritative check (extension only gates the
23
71
  * candidate scan; the bytes decide). */
@@ -114,6 +162,10 @@ export interface AttachResult {
114
162
  text: string;
115
163
  /** The newly-read image attachments, in source order. */
116
164
  images: ImageAttachment[];
165
+ /** Caret offset to park after the swap: just past the trailing space of the LAST
166
+ * inserted tag (so the live box places the cursor right after the attachment,
167
+ * ready for the user's prompt). Equals `text.length` when nothing was attached. */
168
+ cursor: number;
117
169
  }
118
170
 
119
171
  /**
@@ -131,7 +183,7 @@ export async function attachImagePaths(
131
183
  read: FileReader = defaultReader,
132
184
  ): Promise<AttachResult> {
133
185
  const tokens = findImagePathTokens(text);
134
- if (tokens.length === 0) return { text, images: [] };
186
+ if (tokens.length === 0) return { text, images: [], cursor: text.length };
135
187
 
136
188
  const reads = await Promise.all(
137
189
  tokens.map(async (t) => {
@@ -154,5 +206,9 @@ export async function attachImagePaths(
154
206
  n += 1;
155
207
  }
156
208
  out += text.slice(cursor);
157
- return { text: out, images };
209
+ if (images.length === 0) return { text: out, images, cursor: out.length };
210
+ // Collapse the spacing terminals add around a dropped path so the swapped-in tag
211
+ // (and the caret parked after it) is not pushed several columns to the right.
212
+ const normalized = normalizeImageTags(out);
213
+ return { text: normalized, images, cursor: caretAfterTag(normalized, n - 1) };
158
214
  }