@hncode/hncode 0.6.4 → 0.6.6
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/package.json +1 -1
- package/src/agent.js +5 -1
- package/src/skills.js +110 -2
- package/src/tools/activate-skill.js +76 -0
- package/src/tools/edit.js +6 -1
- package/src/tools/fetch-url.js +2 -2
- package/src/tools/index.js +2 -0
- package/src/tui.js +152 -48
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -942,7 +942,7 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
942
942
|
// cannot fit, the provider's context-length error is an honest, recoverable
|
|
943
943
|
// failure instead of a vanished transcript.
|
|
944
944
|
if (!summary && this.messages.length > 0) {
|
|
945
|
-
this.onEvent({ type: 'compaction_cancelled' });
|
|
945
|
+
this.onEvent({ type: 'compaction_cancelled', reason: 'the summary request failed, so nothing was trimmed' });
|
|
946
946
|
} else {
|
|
947
947
|
// NOW trim, at a split point the boundary rule allows.
|
|
948
948
|
const { split, dropped: droppedCount, kept } = this.applyCompactionSplit(maxCtx, summary);
|
|
@@ -952,6 +952,10 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
952
952
|
// point of mutation instead of relying on that check to fire.
|
|
953
953
|
this._ledger.reset();
|
|
954
954
|
usedNow = this.usedTokens();
|
|
955
|
+
// THE REPORT DESCRIBES THE REQUEST THAT WENT OUT, so after a trim it still describes the OLD one.
|
|
956
|
+
// Drop it: the next turn falls back to the local estimate until the provider reports again, instead of
|
|
957
|
+
// reading a stale figure and compacting a history that was just compacted.
|
|
958
|
+
this.ctxReportedTokens = 0;
|
|
955
959
|
this.onEvent({
|
|
956
960
|
type: 'compacted', before, after: usedNow, kept,
|
|
957
961
|
dropped: droppedCount, summary,
|
package/src/skills.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import fs from 'node:fs';
|
|
22
22
|
import os from 'node:os';
|
|
23
23
|
import path from 'node:path';
|
|
24
|
-
|
|
24
|
+
import { httpsRequest } from './tools/fetch-url.js';
|
|
25
25
|
export const SKILL_PREFIX = 'skill:';
|
|
26
26
|
|
|
27
27
|
// The skills directory. Kept next to the other hncode state (~/.hncode/…), and
|
|
@@ -45,7 +45,16 @@ export function normalizeSkillName(name) {
|
|
|
45
45
|
// or malformed block (then body === text and meta is empty).
|
|
46
46
|
export function parseFrontMatter(text) {
|
|
47
47
|
const src = String(text == null ? '' : text);
|
|
48
|
-
|
|
48
|
+
// A DELIMITER THAT WAS ESCAPED IS STILL A DELIMITER.
|
|
49
|
+
//
|
|
50
|
+
// `---\nname: x` is what a skill looks like when something wrote the block with the newlines escaped: the
|
|
51
|
+
// first and last delimiters carry a literal backslash-n while the fields between them use real newlines. The
|
|
52
|
+
// block then matched nothing and the skill listed as "(no description)" — the file reads correctly in an
|
|
53
|
+
// editor, so there was nothing to see. Only the two delimiters are unescaped; a body that contains `\n` on
|
|
54
|
+
// purpose is untouched, because the replacement stops at the first field.
|
|
55
|
+
const norm = src.replace(/^\uFEFF?---\\n(?=[A-Za-z0-9_-])/, '---\n')
|
|
56
|
+
.replace(/\\n---(\\n|$)/, '\n---$1');
|
|
57
|
+
const m = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(norm);
|
|
49
58
|
if (!m) return { meta: {}, body: src };
|
|
50
59
|
const meta = {};
|
|
51
60
|
for (const line of m[1].split(/\r?\n/)) {
|
|
@@ -171,6 +180,105 @@ export function skillCompletions(prefix = '') {
|
|
|
171
180
|
|
|
172
181
|
// Build the text a skill contributes to the conversation. A short header keeps
|
|
173
182
|
// the model from treating the body as the user's own words.
|
|
183
|
+
// ---- installing from a GitHub repository ------------------------------------------------
|
|
184
|
+
|
|
185
|
+
// A registry entry is `owner/repo`, optionally with a subdirectory: `owner/repo/skills`, or
|
|
186
|
+
// `owner/repo@ref`. Skills are FOUND, not assumed: every `SKILL.md` below the given path is one,
|
|
187
|
+
// because that is the layout the ecosystem uses — a folder per skill, each with its own file.
|
|
188
|
+
//
|
|
189
|
+
// The listing comes from the GitHub API, which needs no authentication for a public repository and
|
|
190
|
+
// is rate limited per IP without a token. Fetching goes through the same transport as FetchURL so a
|
|
191
|
+
// broken certificate chain on this network does not make the feature useless.
|
|
192
|
+
const GH_API = 'https://api.github.com';
|
|
193
|
+
|
|
194
|
+
/** Split `owner/repo[/sub][@ref]` into its parts, or null when the shape is wrong. */
|
|
195
|
+
export function parseRepoSpec(spec) {
|
|
196
|
+
const text = String(spec == null ? '' : spec).trim().replace(/^https?:\/\/github\.com\//i, '');
|
|
197
|
+
if (!text) return null;
|
|
198
|
+
const at = text.lastIndexOf('@');
|
|
199
|
+
const ref = at > 0 ? text.slice(at + 1) : '';
|
|
200
|
+
const rest = at > 0 ? text.slice(0, at) : text;
|
|
201
|
+
const parts = rest.split('/').filter(Boolean);
|
|
202
|
+
if (parts.length < 2) return null;
|
|
203
|
+
const [owner, repo, ...sub] = parts;
|
|
204
|
+
return { owner, repo: repo.replace(/\.git$/i, ''), sub: sub.join('/'), ref: ref || '' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function ghJson(apiPath, insecure) {
|
|
208
|
+
const res = await httpsRequest(`${GH_API}${apiPath}`, undefined, insecure);
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
if (res.status === 404) return { error: `not found: ${apiPath}` };
|
|
211
|
+
if (res.status === 403) return { error: 'GitHub refused the request (rate limit without a token, or a private repository)' };
|
|
212
|
+
return { error: `GitHub returned ${res.status}` };
|
|
213
|
+
}
|
|
214
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
215
|
+
try { return { data: JSON.parse(buf.toString('utf8')) }; } catch { return { error: 'GitHub returned something that is not JSON' }; }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Every `SKILL.md` at or below `path`, as { name, path }. */
|
|
219
|
+
async function findSkillFiles(owner, repo, dirPath, ref, insecure, depth = 0) {
|
|
220
|
+
if (depth > 4) return [];
|
|
221
|
+
const q = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
|
222
|
+
const r = await ghJson(`/repos/${owner}/${repo}/contents/${dirPath}${q}`, insecure);
|
|
223
|
+
if (r.error) return { error: r.error };
|
|
224
|
+
const entries = Array.isArray(r.data) ? r.data : [];
|
|
225
|
+
const found = [];
|
|
226
|
+
for (const e of entries) {
|
|
227
|
+
if (e.type === 'file' && /^skill\.md$/i.test(e.name)) found.push({ name: e.name, path: e.path, url: e.download_url });
|
|
228
|
+
}
|
|
229
|
+
// A directory that holds a SKILL.md is a skill; do not descend into it looking for more, or a bundle of
|
|
230
|
+
// reference files beside one would be walked for nothing.
|
|
231
|
+
if (found.length) return found;
|
|
232
|
+
for (const e of entries) {
|
|
233
|
+
if (e.type !== 'dir') continue;
|
|
234
|
+
const sub = await findSkillFiles(owner, repo, e.path, ref, insecure, depth + 1);
|
|
235
|
+
if (sub && sub.error) continue;
|
|
236
|
+
found.push(...sub);
|
|
237
|
+
}
|
|
238
|
+
return found;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Install every skill in a GitHub repository (or a subdirectory of one).
|
|
243
|
+
*
|
|
244
|
+
* Returns { ok: true, installed: [{ name, path, replaced }], skipped: [name] } or { ok: false, error }.
|
|
245
|
+
* A name that is already present is REPLACED — the same rule `/import-skill` uses.
|
|
246
|
+
*/
|
|
247
|
+
export async function installSkillFromGitHub(specText, { insecure = true } = {}) {
|
|
248
|
+
const spec = parseRepoSpec(specText);
|
|
249
|
+
if (!spec) return { ok: false, error: `Expected owner/repo[/subdir], got "${specText}"` };
|
|
250
|
+
const { owner, repo, sub, ref } = spec;
|
|
251
|
+
|
|
252
|
+
const files = await findSkillFiles(owner, repo, sub, ref, insecure);
|
|
253
|
+
if (files && files.error) return { ok: false, error: files.error };
|
|
254
|
+
if (!files.length) {
|
|
255
|
+
return { ok: false, error: `no SKILL.md found in ${owner}/${repo}${sub ? '/' + sub : ''}` };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const dir = skillsDir();
|
|
259
|
+
const installed = [];
|
|
260
|
+
const skipped = [];
|
|
261
|
+
for (const f of files) {
|
|
262
|
+
// The DIRECTORY name is the skill name — `skills/ds-explain/SKILL.md` is `ds-explain`. The front matter
|
|
263
|
+
// may say something else, but the folder is what the repository is organised by and what the user expects
|
|
264
|
+
// to type after `/skill:`.
|
|
265
|
+
const folder = f.path.split('/').slice(-2)[0] || repo;
|
|
266
|
+
const res = await httpsRequest(f.url, undefined, insecure);
|
|
267
|
+
if (!res.ok) { skipped.push(folder); continue; }
|
|
268
|
+
const text = Buffer.from(await res.arrayBuffer()).toString('utf8');
|
|
269
|
+
const name = normalizeSkillName(folder);
|
|
270
|
+
if (!name || /[\\/]/.test(name)) { skipped.push(folder); continue; }
|
|
271
|
+
const dest = path.join(dir, `${name}.md`);
|
|
272
|
+
const replaced = fs.existsSync(dest);
|
|
273
|
+
try {
|
|
274
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
275
|
+
fs.writeFileSync(dest, text, 'utf8');
|
|
276
|
+
installed.push({ name, path: dest, replaced });
|
|
277
|
+
} catch { skipped.push(folder); }
|
|
278
|
+
}
|
|
279
|
+
return { ok: installed.length > 0, installed, skipped, error: installed.length ? '' : 'nothing could be downloaded' };
|
|
280
|
+
}
|
|
281
|
+
|
|
174
282
|
export function skillPrompt(skill) {
|
|
175
283
|
if (!skill || !skill.body) return '';
|
|
176
284
|
return `[skill: ${skill.name}]\n\n${skill.body}`;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ActivateSkill tool: the model loads a skill by name.
|
|
3
|
+
*
|
|
4
|
+
* A skill is a stored procedure — a body of instructions for one kind of job (review a diff, debug a failure,
|
|
5
|
+
* design a UI). Until now the only way to run one was for the USER to type `/skill:<name>`, which means the model
|
|
6
|
+
* could not use a skill at all: it had no idea the directory existed.
|
|
7
|
+
*
|
|
8
|
+
* The system prompt now lists the names and descriptions, and this tool is the other half — the way to act on
|
|
9
|
+
* that list. It returns the BODY, which the model then follows in its own turn.
|
|
10
|
+
*
|
|
11
|
+
* WHY A TOOL AND NOT A SLASH COMMAND: a slash command is handled by the TUI before the model sees anything. This
|
|
12
|
+
* has to be callable mid-turn, in the middle of a multi-step job, when the model realises the remaining work is
|
|
13
|
+
* "review this" and a skill was written for exactly that.
|
|
14
|
+
*/
|
|
15
|
+
import { listSkills, readSkill, normalizeSkillName } from '../skills.js';
|
|
16
|
+
|
|
17
|
+
export const spec = {
|
|
18
|
+
name: 'ActivateSkill',
|
|
19
|
+
description: [
|
|
20
|
+
'Load a skill — a stored procedure for a particular kind of work — and return its instructions.',
|
|
21
|
+
'',
|
|
22
|
+
'The available skills are listed in the system prompt under "# Skills", with a one-line description each.',
|
|
23
|
+
'Call this when one of them fits the job in front of you, then follow the returned instructions.',
|
|
24
|
+
'',
|
|
25
|
+
'The body is the skill\'s full text. It is NOT a summary: read it and do what it says.',
|
|
26
|
+
'Use `list` to see everything installed, including anything added since the prompt was built.',
|
|
27
|
+
].join('\n'),
|
|
28
|
+
parameters: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
names: {
|
|
32
|
+
type: 'array',
|
|
33
|
+
items: { type: 'string' },
|
|
34
|
+
description: 'One or more skill names to load, as listed under "# Skills".',
|
|
35
|
+
},
|
|
36
|
+
list: {
|
|
37
|
+
type: 'boolean',
|
|
38
|
+
description: 'Return the installed skills with their descriptions instead of loading one.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async execute(args) {
|
|
43
|
+
if (args && args.list) {
|
|
44
|
+
const all = listSkills();
|
|
45
|
+
if (!all.length) return 'No skills are installed. The user can add one with /import-skill.';
|
|
46
|
+
return all.map((s) => `- ${s.name}${s.description ? `: ${s.description}` : ''}`).join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const raw = args && args.names;
|
|
50
|
+
const wanted = (Array.isArray(raw) ? raw : (raw ? [raw] : []))
|
|
51
|
+
.map((n) => normalizeSkillName(n))
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
if (!wanted.length) {
|
|
54
|
+
const all = listSkills();
|
|
55
|
+
const names = all.map((s) => s.name).join(', ');
|
|
56
|
+
return `Error: name a skill to activate. Installed: ${names || '(none)'}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const out = [];
|
|
60
|
+
const missing = [];
|
|
61
|
+
for (const name of wanted) {
|
|
62
|
+
const skill = readSkill(name);
|
|
63
|
+
if (!skill) { missing.push(name); continue; }
|
|
64
|
+
// The header names the skill so a transcript with two of them stays readable; the body is verbatim.
|
|
65
|
+
out.push(`[skill: ${skill.name}]\n\n${skill.body}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!out.length) {
|
|
69
|
+
const all = listSkills();
|
|
70
|
+
return `Error: no skill named ${missing.join(', ')}. Installed: ${all.map((s) => s.name).join(', ') || '(none)'}`;
|
|
71
|
+
}
|
|
72
|
+
// A partial miss is worth saying: silently loading one of two would leave the job half-instructed.
|
|
73
|
+
if (missing.length) out.push(`(not installed: ${missing.join(', ')})`);
|
|
74
|
+
return out.join('\n\n---\n\n');
|
|
75
|
+
},
|
|
76
|
+
};
|
package/src/tools/edit.js
CHANGED
|
@@ -429,7 +429,12 @@ function buildEditDiff({ fileLines, oldText, newText, startLine }) {
|
|
|
429
429
|
//
|
|
430
430
|
// `net` is the number of lines the edit added (negative when it shrank the file), so every line below
|
|
431
431
|
// the replaced block moves by exactly that much.
|
|
432
|
-
|
|
432
|
+
// AN EMPTY REPLACEMENT IS ZERO LINES, NOT ONE. `''.split('\n')` is `['']`, so a deletion counted itself as
|
|
433
|
+
// a one-line replacement and `net` came out 0 instead of -1 — the AFTER context was then numbered as if
|
|
434
|
+
// nothing had been removed, and the diff carried a phantom `+` row holding an empty string, which reads as an
|
|
435
|
+
// insertion where the file in fact lost a line.
|
|
436
|
+
const newLines = normalizeText(newText) === '' ? [] : normalizeText(newText).split('\n');
|
|
437
|
+
const net = newLines.length - replacedCount;
|
|
433
438
|
for (let n = lastReplaced + 1; n <= lastReplaced + DIFF_CONTEXT; n++) {
|
|
434
439
|
if (n >= 1 && n <= lines.length) after.push({ n: n + net, text: lines[n - 1] });
|
|
435
440
|
}
|
package/src/tools/fetch-url.js
CHANGED
|
@@ -76,7 +76,7 @@ const RETRY_AFTER_MS = 1500;
|
|
|
76
76
|
* `node:https` instead. It is the SAME request — same headers, same redirects, same body cap — just built
|
|
77
77
|
* by hand so `rejectUnauthorized: false` can be passed.
|
|
78
78
|
*/
|
|
79
|
-
async function fetchWithRetry(url, signal, insecure = false) {
|
|
79
|
+
export async function fetchWithRetry(url, signal, insecure = false) {
|
|
80
80
|
if (insecure) return httpsRequest(url, signal, true);
|
|
81
81
|
const first = await fetch(url, { signal, redirect: 'follow', headers: HEADERS });
|
|
82
82
|
if (first.status !== 429) return first;
|
|
@@ -93,7 +93,7 @@ async function fetchWithRetry(url, signal, insecure = false) {
|
|
|
93
93
|
* buffered up to MAX_BYTES and handed back as an object with `.ok`, `.status`, `.headers.get()` and
|
|
94
94
|
* `.arrayBuffer()`, so the rest of `execute` does not know which transport ran.
|
|
95
95
|
*/
|
|
96
|
-
function httpsRequest(url, signal, insecure) {
|
|
96
|
+
export function httpsRequest(url, signal, insecure) {
|
|
97
97
|
return new Promise((resolve, reject) => {
|
|
98
98
|
let u;
|
|
99
99
|
try { u = new URL(url); } catch (e) { reject(e); return; }
|
package/src/tools/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { spec as notebookSpec } from './notebook.js';
|
|
|
23
23
|
import { spec as diagnosticsSpec } from './diagnostics.js';
|
|
24
24
|
import * as memoryMod from './memory.js';
|
|
25
25
|
import * as goalMod from './goal.js';
|
|
26
|
+
import * as activateSkillMod from './activate-skill.js';
|
|
26
27
|
import { pluginTools, pluginToolGeneration } from '../plugin.js';
|
|
27
28
|
|
|
28
29
|
|
|
@@ -57,6 +58,7 @@ const builtinTools = [
|
|
|
57
58
|
diagnosticsSpec,
|
|
58
59
|
memoryMod.spec,
|
|
59
60
|
goalMod.spec,
|
|
61
|
+
activateSkillMod.spec,
|
|
60
62
|
...TEAM_TOOL_SPECS,
|
|
61
63
|
];
|
|
62
64
|
|
package/src/tui.js
CHANGED
|
@@ -103,7 +103,7 @@ import { renderTaskOutputViewer, handleViewerKey, makeViewerState } from './task
|
|
|
103
103
|
import { renderSwarmProgress } from './swarm-progress.js';
|
|
104
104
|
import { sortedTasks, getTask, settleTask, STATUS_LABEL, backgroundTaskCard } from './agent-task.js';
|
|
105
105
|
import { localVersion } from './version.js';
|
|
106
|
-
import { listSkills, readSkill, importSkill, deleteSkill, skillPrompt, skillCompletions, normalizeSkillName, SKILL_PREFIX, skillsDir } from './skills.js';
|
|
106
|
+
import { listSkills, readSkill, importSkill, installSkillFromGitHub, deleteSkill, skillPrompt, skillCompletions, normalizeSkillName, SKILL_PREFIX, skillsDir } from './skills.js';
|
|
107
107
|
import { loadHooks, describeHooks, runShellHooks, HOOK_EVENTS } from './hooks.js';
|
|
108
108
|
import { beginTurn, rewind as rewindFiles, describeRewind, listCheckpoints, restoreCheckpoint, describeCheckpoints, checkpointDelta } from './file-history.js';
|
|
109
109
|
import { trimToolResults } from './tool-result.js';
|
|
@@ -258,7 +258,7 @@ export const COMMANDS = [
|
|
|
258
258
|
{ name: 'reload-config', desc: 'Reload config.toml settings only', priority: 60 },
|
|
259
259
|
{ name: 'plugins', desc: 'Open the plugin manager (installed + available in the repo)', priority: 60, argumentHint: '[install <name> | remove <name> | reload]' },
|
|
260
260
|
{ name: 'skills', desc: 'Open the skill manager (installed + available in the repo)', priority: 60, argumentHint: '[install <name> | remove <name>]' },
|
|
261
|
-
{ name: 'import-skill', desc: '
|
|
261
|
+
{ name: 'import-skill', desc: 'Add a skill from a Markdown file, or install every skill in a GitHub repository (owner/repo)', cooldown: false },
|
|
262
262
|
{ name: 'hooks', desc: 'Show configured shell hooks, or test one', priority: 60, argumentHint: '[list | test <event>]' },
|
|
263
263
|
{ name: 'git', desc: 'Show repository status (branch, changes, remote)', priority: 62 },
|
|
264
264
|
{ name: 'commit', desc: 'Stage and commit with a message (or let the agent draft one)', priority: 62, argumentHint: '[message]' },
|
|
@@ -1524,7 +1524,13 @@ const DIFF_CONTEXT_LINES = 5;
|
|
|
1524
1524
|
*/
|
|
1525
1525
|
export function editDiffRows(diff) {
|
|
1526
1526
|
if (!diff || typeof diff.old !== 'string' || typeof diff.new !== 'string') return [];
|
|
1527
|
-
|
|
1527
|
+
// An empty string is ZERO lines: `''.split('\n')` is `['']`, which would draw a phantom blank row.
|
|
1528
|
+
// An empty side is ZERO lines and a trailing newline is not a line: `''.split('\n')` is `['']`,
|
|
1529
|
+
// which drew a phantom blank row, and `'a\n'` split to `['a', '']`, which drew one past the end.
|
|
1530
|
+
const split = (s) => {
|
|
1531
|
+
const t = String(s).replace(/\r\n/g, '\n').replace(/\n$/, '');
|
|
1532
|
+
return t === '' ? [] : t.split('\n');
|
|
1533
|
+
};
|
|
1528
1534
|
const ctx = diff.context || {};
|
|
1529
1535
|
const before = Array.isArray(ctx.before) ? ctx.before : [];
|
|
1530
1536
|
const after = Array.isArray(ctx.after) ? ctx.after : [];
|
|
@@ -2900,7 +2906,8 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
|
|
|
2900
2906
|
? col(` (Ctrl-O to ${expanded ? 'hide' : 'show'} compaction summary)`, C.gray) : '';
|
|
2901
2907
|
head = bullet + col('Compaction complete', C.green + C.bold) + detail + hint;
|
|
2902
2908
|
} else if (canceled) {
|
|
2903
|
-
head = bullet + col('Compaction cancelled', C.yellow + C.bold)
|
|
2909
|
+
head = bullet + col('Compaction cancelled', C.yellow + C.bold)
|
|
2910
|
+
+ (msg.reason ? col(' (' + msg.reason + ')', C.gray) : '');
|
|
2904
2911
|
} else {
|
|
2905
2912
|
head = bullet + col('Compacting context…', C.cyan + C.bold);
|
|
2906
2913
|
}
|
|
@@ -6557,25 +6564,23 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
6557
6564
|
// earlier question with the mouse; without these the labels were plain text and a click did nothing.
|
|
6558
6565
|
const tabHits = [];
|
|
6559
6566
|
{
|
|
6560
|
-
//
|
|
6561
|
-
//
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
tabs += ' ';
|
|
6576
|
-
}
|
|
6577
|
-
box.push(boxRow(fitAnsi(tabs, innerW - 2)));
|
|
6567
|
+
// ALWAYS DRAWN. The trailing Other tab means even one question has two destinations, and
|
|
6568
|
+
// hiding the strip hid that the note existed at all.
|
|
6569
|
+
let tabs = '';
|
|
6570
|
+
const tabName = (i) => {
|
|
6571
|
+
const it = qs.items[i];
|
|
6572
|
+
return String((it && it.header) || `Q${i + 1}`).trim() || `Q${i + 1}`;
|
|
6573
|
+
};
|
|
6574
|
+
for (let i = 0; i <= qs.items.length; i++) {
|
|
6575
|
+
const name = i === qs.items.length ? OTHER_LABEL : tabName(i);
|
|
6576
|
+
const on = i === qs.index;
|
|
6577
|
+
const colStart = visualCol(tabs);
|
|
6578
|
+
const label = ' ' + name + ' ';
|
|
6579
|
+
tabs += on ? col(label, C.selBg + C.cyan + C.bold) : col(label, C.gray);
|
|
6580
|
+
tabHits.push({ index: i, colStart, colEnd: colStart + visualCol(label) - 1 });
|
|
6581
|
+
tabs += ' ';
|
|
6578
6582
|
}
|
|
6583
|
+
box.push(boxRow(fitAnsi(tabs, innerW - 2)));
|
|
6579
6584
|
}
|
|
6580
6585
|
const itemRows = [];
|
|
6581
6586
|
let caretInBox = -1;
|
|
@@ -6611,15 +6616,51 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
6611
6616
|
// THE LIVE BUFFER, not the committed value. Typing writes `editingText`; `otherText` only holds what was
|
|
6612
6617
|
// committed when the field was left, so rendering it made the field look empty while the user typed into it.
|
|
6613
6618
|
const typed = qs.editing === 'other' ? String(qs.editingText || '') : (qs.otherText || '');
|
|
6614
|
-
|
|
6615
|
-
//
|
|
6616
|
-
const
|
|
6617
|
-
|
|
6618
|
-
const
|
|
6619
|
-
const
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6619
|
+
const caretAt = Math.max(0, Math.min(typed.length, qs.editingCaret == null ? typed.length : qs.editingCaret));
|
|
6620
|
+
// The marker plus the padding on each side is what the text cannot use.
|
|
6621
|
+
const bodyW = Math.max(1, innerW - 2 - visualCol('❯ '));
|
|
6622
|
+
// A PLACEHOLDER SITS INSIDE THE FIELD, grey, and vanishes on the first keystroke.
|
|
6623
|
+
const empty = typed === '';
|
|
6624
|
+
const shown = empty ? 'type your answer, then Enter' : typed;
|
|
6625
|
+
const paint = empty ? C.gray : C.white;
|
|
6626
|
+
// WRAP, so a long answer stays visible instead of being clipped at the box edge.
|
|
6627
|
+
// WRAP BY PARAGRAPH, then by width. `wrapWithOffsets` cuts on width only, so an explicit newline has to be
|
|
6628
|
+
// split out first or it is drawn as the two characters `\` and `n`.
|
|
6629
|
+
const segs = [];
|
|
6630
|
+
{
|
|
6631
|
+
let base = 0;
|
|
6632
|
+
const paras = shown.split('\n');
|
|
6633
|
+
for (let pi = 0; pi < paras.length; pi++) {
|
|
6634
|
+
const para = paras[pi];
|
|
6635
|
+
for (const w of wrapWithOffsets(para, bodyW)) segs.push({ text: w.text, start: base + w.start });
|
|
6636
|
+
base += para.length + 1; // + the newline we split on
|
|
6637
|
+
}
|
|
6638
|
+
// A trailing empty line matters while typing: it is where the next character will go. Without it a full
|
|
6639
|
+
// row leaves the caret with nowhere to sit.
|
|
6640
|
+
if (!empty && caretAt === typed.length && !/\n$/.test(typed)) segs.push({ text: '', start: typed.length });
|
|
6641
|
+
}
|
|
6642
|
+
// The caret belongs to the segment that CONTAINS it; the LAST match wins on a boundary so the cursor sits at
|
|
6643
|
+
// the end of the text rather than jumping to the next row.
|
|
6644
|
+
let caretRow = Math.max(0, segs.length - 1);
|
|
6645
|
+
let caretCol = 0;
|
|
6646
|
+
for (let si = 0; si < segs.length; si++) {
|
|
6647
|
+
const seg = segs[si];
|
|
6648
|
+
const segEnd = seg.start + seg.text.length;
|
|
6649
|
+
if (caretAt >= seg.start && caretAt <= segEnd) {
|
|
6650
|
+
caretRow = si;
|
|
6651
|
+
caretCol = visualCol(seg.text.slice(0, caretAt - seg.start));
|
|
6652
|
+
}
|
|
6653
|
+
// The first row carries the prompt glyph; the rest are indented to match it.
|
|
6654
|
+
const marker = si === 0 ? col('❯ ', C.cyan) : ' ';
|
|
6655
|
+
// Padded to the full body width: a short row would leave the right wall short and the next row would appear
|
|
6656
|
+
// to run into it.
|
|
6657
|
+
const body = col(seg.text, paint) + ' '.repeat(Math.max(0, bodyW - visualCol(seg.text)));
|
|
6658
|
+
box.push(boxRow(col(marker + body, '')));
|
|
6659
|
+
}
|
|
6660
|
+
// How many rows the field took, so the caret row can be located in the box.
|
|
6661
|
+
qs.freeTextRows = segs.length;
|
|
6662
|
+
caretInBox = box.length - segs.length + Math.min(caretRow, segs.length - 1);
|
|
6663
|
+
caretColInBox = 2 + caretCol;
|
|
6623
6664
|
} else {
|
|
6624
6665
|
const opts = cur.options.map((o) => ({ label: o.label, description: o.description, kind: 'option' }));
|
|
6625
6666
|
opts.push({ label: OTHER_LABEL, description: '', kind: 'other' });
|
|
@@ -6642,8 +6683,15 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
6642
6683
|
else marker = isSel ? '> ' : ' ';
|
|
6643
6684
|
// The current row is marked by `> ` (single) / the filled square (multi); no
|
|
6644
6685
|
// background highlight.
|
|
6645
|
-
|
|
6646
|
-
|
|
6686
|
+
// A CHOSEN ROW IS BOLD; THE HOVERED ROW IS TINTED AND PLAIN.
|
|
6687
|
+
//
|
|
6688
|
+
// They used to share `C.cyan`, so there was no way to tell which rows were ticked and which one the cursor
|
|
6689
|
+
// happened to be on — the one distinction the screen exists to make. Weight carries the choice, the
|
|
6690
|
+
// background carries the cursor, and the marker carries the cursor when colour is unavailable.
|
|
6691
|
+
const markerPaint = isSel ? (chosen ? (C.cyan + C.bold) : C.cyan) : (chosen ? C.cyan : C.gray);
|
|
6692
|
+
const labelPaint = chosen
|
|
6693
|
+
? (C.cyan + C.bold)
|
|
6694
|
+
: (isSel ? (C.selBg + C.cyan) : C.white);
|
|
6647
6695
|
const typed = (o.kind === 'other' && qs.otherText) ? `: ${qs.otherText}` : '';
|
|
6648
6696
|
const head = col(marker, markerPaint) + col(String(o.label) + typed, labelPaint);
|
|
6649
6697
|
const desc = o.description ? col(' ' + o.description, C.gray) : '';
|
|
@@ -7043,9 +7091,9 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
7043
7091
|
const textPart = styledRow.slice(contWidth);
|
|
7044
7092
|
const textStart = rowMeta.start != null ? rowMeta.start : 0;
|
|
7045
7093
|
const highlighted = (sel && sel.anchor !== sel.head)
|
|
7046
|
-
? highlightSelection(
|
|
7047
|
-
:
|
|
7048
|
-
rowAnsi = ' ' + C.white + fitAnsi(highlighted, textW) + ' ';
|
|
7094
|
+
? highlightSelection(textPart, textStart, sel.anchor, sel.head)
|
|
7095
|
+
: textPart;
|
|
7096
|
+
rowAnsi = ' ' + indent + C.white + fitAnsi(highlighted, Math.max(1, textW - contWidth)) + ' ';
|
|
7049
7097
|
}
|
|
7050
7098
|
lines.push(
|
|
7051
7099
|
CPAD + col('│', shellBorder) + rowAnsi + C.reset + col('│', shellBorder)
|
|
@@ -10497,7 +10545,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
10497
10545
|
...panel,
|
|
10498
10546
|
'Skills',
|
|
10499
10547
|
` /${SKILL_PREFIX}<name> run an installed skill (Tab completes the name)`,
|
|
10500
|
-
|
|
10548
|
+
' /import-skill <file.md | owner/repo> add a skill from a file, or install a repository of them',
|
|
10501
10549
|
' /skills open the skill manager (Tab switches tabs)',
|
|
10502
10550
|
' /skills install <name> download one from the repo, without the panel',
|
|
10503
10551
|
' /skills remove <name> delete the local copy',
|
|
@@ -12471,12 +12519,30 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
12471
12519
|
return;
|
|
12472
12520
|
}
|
|
12473
12521
|
case 'import-skill': {
|
|
12474
|
-
// Usage: /import-skill <file.md> [name]
|
|
12522
|
+
// Usage: /import-skill <file.md | owner/repo[/sub]> [name]
|
|
12475
12523
|
const parts = raw.split(/\s+/).filter(Boolean);
|
|
12476
|
-
const
|
|
12524
|
+
const source = parts[0];
|
|
12477
12525
|
const nameArg = parts[1];
|
|
12478
|
-
if (!
|
|
12479
|
-
|
|
12526
|
+
if (!source) { appErr('Usage: /import-skill <file.md | owner/repo[/subdir]> [name]'); return; }
|
|
12527
|
+
|
|
12528
|
+
// A repository spec has no extension and carries a slash — a local Markdown path always ends in .md, and a
|
|
12529
|
+
// path that does not is not something this command ever accepted.
|
|
12530
|
+
const looksLikeRepo = !/\.md$/i.test(source) && /^[\w.-]+\/[\w.-]+/.test(source);
|
|
12531
|
+
if (looksLikeRepo) {
|
|
12532
|
+
say(`Installing skills from ${source}…`);
|
|
12533
|
+
renderFrame();
|
|
12534
|
+
void installSkillFromGitHub(source).then((res) => {
|
|
12535
|
+
if (!res.ok) { appErr(`Install failed: ${res.error}`); renderFrame(); return; }
|
|
12536
|
+
const names = res.installed.map((s) => s.name);
|
|
12537
|
+
app(`Installed ${names.length} skill(s): ${names.join(', ')}`);
|
|
12538
|
+
if (res.skipped.length) notice(`Skipped: ${res.skipped.join(', ')}`, 'error');
|
|
12539
|
+
say(`Activate one with /skill:<name>, or type /skills to list them.`);
|
|
12540
|
+
renderFrame();
|
|
12541
|
+
}).catch((e) => { appErr(`Install failed: ${(e && e.message) || e}`); renderFrame(); });
|
|
12542
|
+
return;
|
|
12543
|
+
}
|
|
12544
|
+
|
|
12545
|
+
const res = importSkill(source, nameArg);
|
|
12480
12546
|
if (!res.ok) { appErr(res.error); return; }
|
|
12481
12547
|
app(`${res.replaced ? 'Skill overwritten' : 'Skill imported'}: ${res.name} → ${res.path}`);
|
|
12482
12548
|
say(`Activate it with /${SKILL_PREFIX}${res.name}`);
|
|
@@ -12890,12 +12956,15 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
12890
12956
|
// and the model saw it as a detached system note rather than as their words.
|
|
12891
12957
|
const extra = String(raw || '').trim();
|
|
12892
12958
|
const prompt = skillPrompt(sk) + (extra ? `\n\n${extra}` : '');
|
|
12893
|
-
//
|
|
12894
|
-
//
|
|
12895
|
-
//
|
|
12896
|
-
//
|
|
12959
|
+
// THE CARD says which skill ran; the USER MESSAGE is what the person typed, if anything.
|
|
12960
|
+
//
|
|
12961
|
+
// The args used to travel twice: once as `bubbleText`, which drew a bubble identical to the composer below the
|
|
12962
|
+
// card, and once appended to the hidden body. Passing them as the prompt is what makes them the user's words —
|
|
12963
|
+
// and when there are none, nothing is drawn but the card.
|
|
12897
12964
|
addChat({ role: 'skill', text: name, args: extra, _conv: true });
|
|
12898
|
-
|
|
12965
|
+
// `submit` drops an empty prompt, so an activation with no args needs something to send. The body itself
|
|
12966
|
+
// is what does the work; this line is only what makes the turn happen.
|
|
12967
|
+
await sendPrompt(extra || `Run the ${name} skill.`, { skillBody: prompt });
|
|
12899
12968
|
return;
|
|
12900
12969
|
}
|
|
12901
12970
|
// Check if this is a plugin-registered command.
|
|
@@ -13809,6 +13878,29 @@ async function runTurn(deps, text, opts = {}, viewArg) {
|
|
|
13809
13878
|
if (memory) {
|
|
13810
13879
|
sysText += '\n\n' + memory;
|
|
13811
13880
|
}
|
|
13881
|
+
|
|
13882
|
+
// THE SKILLS ON DISK, BY NAME AND DESCRIPTION.
|
|
13883
|
+
//
|
|
13884
|
+
// Without this the model has no way to know a skill exists: the directory is invisible to it, so a skill that
|
|
13885
|
+
// fits the task went unused unless the user remembered the name. Names and descriptions only — the bodies are
|
|
13886
|
+
// read from disk when activated, and putting every body in every request would defeat the point of a skill.
|
|
13887
|
+
//
|
|
13888
|
+
// Read per turn, like the memory notes above, so installing one takes effect on the next message.
|
|
13889
|
+
const availableSkills = listSkills().filter((s) => s.name);
|
|
13890
|
+
if (availableSkills.length) {
|
|
13891
|
+
const listed = availableSkills
|
|
13892
|
+
.map((s) => `- ${s.name}${s.description ? `: ${s.description}` : ''}`)
|
|
13893
|
+
.join('\n');
|
|
13894
|
+
sysText += '\n\n' + [
|
|
13895
|
+
'# Skills',
|
|
13896
|
+
'',
|
|
13897
|
+
'Reusable procedures installed for this machine. Each is invoked by name and then supplies its own',
|
|
13898
|
+
'instructions. Use the ActivateSkill tool when one fits the work in front of you; the user can also call',
|
|
13899
|
+
'them directly with /skill:<name>.',
|
|
13900
|
+
'',
|
|
13901
|
+
listed,
|
|
13902
|
+
].join('\n');
|
|
13903
|
+
}
|
|
13812
13904
|
// OUTPUT STYLE (/output-style): a form-of-reply instruction read from a file,
|
|
13813
13905
|
// re-read per turn like the notes above so editing the file takes effect on the
|
|
13814
13906
|
// next request. Placed AFTER the preferences and memory but BEFORE AGENTS.md:
|
|
@@ -17333,7 +17425,7 @@ export async function startTUI(opts) {
|
|
|
17333
17425
|
// the user left them.
|
|
17334
17426
|
if (qs.index < total) {
|
|
17335
17427
|
if (!qs.remember) qs.remember = new Map();
|
|
17336
|
-
|
|
17428
|
+
qs.remember.set(qs.index, { sel: qs.sel, picked: new Set(qs.picked), otherText: qs.otherText || '' });
|
|
17337
17429
|
}
|
|
17338
17430
|
if (qs.index < total - 1) {
|
|
17339
17431
|
qs.index++;
|
|
@@ -17345,7 +17437,10 @@ export async function startTUI(opts) {
|
|
|
17345
17437
|
const q = qs.items[qs.index];
|
|
17346
17438
|
if (q && q.freeText) {
|
|
17347
17439
|
qs.editing = 'other';
|
|
17348
|
-
|
|
17440
|
+
// THE RECORDED TEXT for THIS question: `otherText` is shared by every question, so it holds whichever
|
|
17441
|
+
// answer was edited most recently — or nothing, once submitting cleared it.
|
|
17442
|
+
qs.editingText = (back && back.otherText) || '';
|
|
17443
|
+
qs.otherText = qs.editingText;
|
|
17349
17444
|
qs.editingCaret = qs.editingText.length;
|
|
17350
17445
|
}
|
|
17351
17446
|
return;
|
|
@@ -18580,6 +18675,15 @@ export async function startTUI(opts) {
|
|
|
18580
18675
|
const back = qs.index < total0 && qs.remember ? qs.remember.get(qs.index) : null;
|
|
18581
18676
|
qs.sel = back ? back.sel : 0;
|
|
18582
18677
|
qs.picked = back ? new Set(back.picked) : new Set();
|
|
18678
|
+
// A FREE-TEXT QUESTION REOPENS ITS FIELD, with this question's text: `editing` is what makes the row
|
|
18679
|
+
// exist at all, so without this the question came back with nothing to type into.
|
|
18680
|
+
const qBack = qs.items[qs.index];
|
|
18681
|
+
if (qBack && qBack.freeText) {
|
|
18682
|
+
qs.editing = 'other';
|
|
18683
|
+
qs.editingText = (back && back.otherText) || '';
|
|
18684
|
+
qs.otherText = qs.editingText;
|
|
18685
|
+
qs.editingCaret = qs.editingText.length;
|
|
18686
|
+
}
|
|
18583
18687
|
if (qs.index === total) {
|
|
18584
18688
|
// Landing on the Other tab opens its text field directly.
|
|
18585
18689
|
qs.editing = 'supplement';
|