neoctl 0.2.14 → 0.2.15

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.
@@ -52,17 +52,19 @@ interface UiLine {
52
52
  title?: string;
53
53
  bodyTitle?: string;
54
54
  titleStatus?: "success" | "failure";
55
+ toolKind?: string;
55
56
  format?: "markdown" | "ansi" | "plain" | "diff";
56
57
  previewStyle?: "summary";
57
58
  summaryMaxLines?: number;
58
59
  live?: boolean;
59
60
  collapsible?: boolean;
60
61
  image?: UiLineImage;
62
+ artifact?: unknown;
61
63
  }
62
64
  interface UiActiveTool {
63
65
  id: string;
64
66
  name: string;
65
- input: unknown;
67
+ kind: string;
66
68
  startedAt: number;
67
69
  }
68
70
  interface UiStatus {
@@ -96,6 +98,10 @@ export interface CreateWebRuntimeOptions {
96
98
  cwd?: string;
97
99
  /** Additional tools to register in the web runtime. */
98
100
  externalTools?: readonly Tool[];
101
+ /** Additional shared skill roots. Defaults can also be provided with NEO_SKILL_ROOTS. */
102
+ skillRoots?: readonly string[];
103
+ /** Writable root for skill_create/skill_update. Defaults to NEO_SKILL_CREATE_ROOT or ~/.neoctl/skills. */
104
+ skillCreateRoot?: string;
99
105
  }
100
106
  export interface WebRuntimeScope {
101
107
  /** Browser-tab or client-instance identifier. Omit for the legacy singleton runtime. */
@@ -271,6 +277,7 @@ export declare class WebRepl {
271
277
  private handleCommandOrPrompt;
272
278
  private runCompaction;
273
279
  private send;
280
+ private sendSerialized;
274
281
  private broadcastSync;
275
282
  }
276
283
  export {};
package/dist/web/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import http from "node:http";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { existsSync, readFileSync } from "node:fs";
5
+ import { createReadStream, existsSync, readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { createRequire } from "node:module";
8
8
  import { QueryEngine } from "../core/query-engine.js";
@@ -28,9 +28,14 @@ import { createTaskTools } from "../tasks/task-tools.js";
28
28
  import { TaskStore } from "../tasks/task-store.js";
29
29
  import { parseReplCommand, helpText, replCommandDefinitions } from "../repl/commands.js";
30
30
  import { writeSessionMarkdownExport } from "../session/session-export.js";
31
+ import { DefaultContextManager } from "../context/context-manager.js";
32
+ import { buildEffectiveSystemPrompt } from "../context/prompts.js";
31
33
  import { WEB_HTML } from "./html.js";
32
34
  import { openDirectory } from "../open-directory.js";
33
- import { resolveImageBlockDataSync } from "../core/image-storage.js";
35
+ import { getNeoctlHome } from "../paths.js";
36
+ import { FileSystemSkillCatalog } from "../skills/skill-filesystem.js";
37
+ import { createSkillTool } from "../skills/skill-tool.js";
38
+ import { createSkillManagementTools } from "../skills/skill-management-tools.js";
34
39
  const require = createRequire(import.meta.url);
35
40
  const markedPackageDir = path.dirname(require.resolve("marked/package.json"));
36
41
  const highlightPackageDir = path.dirname(require.resolve("@highlightjs/cdn-assets/package.json"));
@@ -109,6 +114,85 @@ function parseWebArgs(argv) {
109
114
  port = 3000;
110
115
  return { host, port: Math.round(port) };
111
116
  }
117
+ class SkillCatalogContextManager {
118
+ catalog;
119
+ base;
120
+ constructor(catalog, base = new DefaultContextManager()) {
121
+ this.catalog = catalog;
122
+ this.base = base;
123
+ }
124
+ async build(input) {
125
+ const runtimeContext = await this.base.build(input);
126
+ const skillSection = await buildSkillCatalogPromptSection(this.catalog);
127
+ if (!skillSection)
128
+ return runtimeContext;
129
+ const promptSections = [...runtimeContext.promptSections, skillSection];
130
+ return {
131
+ ...runtimeContext,
132
+ promptSections,
133
+ systemPrompt: buildEffectiveSystemPrompt(promptSections, input),
134
+ };
135
+ }
136
+ }
137
+ async function buildSkillCatalogPromptSection(catalog) {
138
+ const skills = await catalog.list();
139
+ if (skills.length === 0)
140
+ return undefined;
141
+ const visible = skills.slice(0, 80);
142
+ const lines = visible.map((skill) => {
143
+ const tags = skill.tags?.length ? `; tags=${skill.tags.join(",")}` : "";
144
+ const tools = skill.allowedTools?.length ? `; allowedTools=${skill.allowedTools.join(",")}` : "";
145
+ return `- ${skill.name}: ${skill.description} (execution=${skill.execution}${tags}${tools})`;
146
+ });
147
+ if (skills.length > visible.length)
148
+ lines.push(`- ... ${skills.length - visible.length} more skills available; use skill_list for the full catalog.`);
149
+ return {
150
+ name: "Available Skills",
151
+ cacheStable: false,
152
+ content: [
153
+ "Reusable skills are available through the `skill` tool and the /skill UI command.",
154
+ "When the user's task matches a skill name, description, tags, or domain capability, proactively call the `skill` tool before doing the work directly.",
155
+ "Do not wait for the user to explicitly say 'use skill'. Use skill_list/skill_read if you need to inspect details.",
156
+ "Available skill catalog:",
157
+ ...lines,
158
+ ].join("\n"),
159
+ };
160
+ }
161
+ function resolveSkillCatalogRoots(cwd, extraRoots = [], createRoot) {
162
+ const userRoot = path.resolve(process.env.NEO_SKILL_CREATE_ROOT || createRoot || path.join(getNeoctlHome(), "skills"));
163
+ const configuredRoots = splitPathList(process.env.NEO_SKILL_ROOTS);
164
+ const workspaceRoot = path.resolve(cwd, ".neo", "skills");
165
+ const roots = uniquePaths([
166
+ userRoot,
167
+ ...extraRoots,
168
+ ...configuredRoots,
169
+ workspaceRoot,
170
+ ]).map((root) => ({
171
+ root,
172
+ kind: path.resolve(root) === userRoot ? "user" : "workspace",
173
+ }));
174
+ return { roots, createRoot: userRoot };
175
+ }
176
+ function splitPathList(value) {
177
+ return String(value || "")
178
+ .split(path.delimiter)
179
+ .map((item) => item.trim())
180
+ .filter(Boolean)
181
+ .map((item) => path.resolve(item));
182
+ }
183
+ function uniquePaths(values) {
184
+ const seen = new Set();
185
+ const result = [];
186
+ for (const value of values) {
187
+ const resolved = path.resolve(value);
188
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
189
+ if (seen.has(key))
190
+ continue;
191
+ seen.add(key);
192
+ result.push(resolved);
193
+ }
194
+ return result;
195
+ }
112
196
  export async function createWebRuntime(options = {}) {
113
197
  const envLoad = loadDefaultDotEnvFiles({ override: true });
114
198
  const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
@@ -117,6 +201,11 @@ export async function createWebRuntime(options = {}) {
117
201
  const modelGateway = new LoggingModelGateway(createModelGatewayFromProcessEnv(process.env), communicationLogger);
118
202
  const taskStore = new TaskStore();
119
203
  const tools = new ToolRegistry();
204
+ const { roots: skillRoots, createRoot: skillCreateRoot } = resolveSkillCatalogRoots(cwd, options.skillRoots, options.skillCreateRoot);
205
+ const skills = new FileSystemSkillCatalog({
206
+ roots: skillRoots,
207
+ createRoot: skillCreateRoot,
208
+ });
120
209
  tools.register(editTool);
121
210
  tools.register(writeTool);
122
211
  tools.register(createExecTool({ taskStore }));
@@ -129,6 +218,9 @@ export async function createWebRuntime(options = {}) {
129
218
  if (modelConfig?.provider === "openai")
130
219
  tools.register(createOpenAIImageGenerationTool());
131
220
  tools.register(planTool);
221
+ tools.register(createSkillTool(skills));
222
+ for (const tool of createSkillManagementTools(skills, { requireApproval: true, allowDelete: false }))
223
+ tools.register(tool);
132
224
  for (const tool of options.externalTools ?? [])
133
225
  tools.register(tool);
134
226
  const agentRuntime = { modelGateway, tools, taskStore };
@@ -155,6 +247,8 @@ export async function createWebRuntime(options = {}) {
155
247
  modelGateway,
156
248
  tools,
157
249
  appPromptStore,
250
+ contextManager: new SkillCatalogContextManager(skills),
251
+ skills: (await skills.list()).map((skill) => skill.name),
158
252
  taskNotificationSource: createTaskNotificationSource(taskStore),
159
253
  commands: replCommandDefinitions.map((command) => command.usage),
160
254
  session: {
@@ -508,7 +602,7 @@ export class WebRepl {
508
602
  this.broadcastSync();
509
603
  }
510
604
  setStatus(next) {
511
- this.status = next;
605
+ this.status = next.phase === "running_tools" ? next : { ...next, currentTool: undefined };
512
606
  this.broadcastSync();
513
607
  }
514
608
  finalizeForegroundView() {
@@ -885,10 +979,14 @@ export class WebRepl {
885
979
  res.write(`event: ${event}\n`);
886
980
  res.write(`data: ${JSON.stringify(data)}\n\n`);
887
981
  }
982
+ sendSerialized(res, event, data) {
983
+ res.write(`event: ${event}\n`);
984
+ res.write(`data: ${data}\n\n`);
985
+ }
888
986
  broadcastSync() {
889
- const payload = this.snapshot(false);
987
+ const payload = JSON.stringify(this.snapshot(false));
890
988
  for (const res of this.subscribers)
891
- this.send(res, "sync", payload);
989
+ this.sendSerialized(res, "sync", payload);
892
990
  }
893
991
  }
894
992
  function reqKeepAlive(res) {
@@ -907,6 +1005,8 @@ async function route(req, res, router) {
907
1005
  return sendFile(res, highlightAssetPath, "text/javascript; charset=utf-8");
908
1006
  if (req.method === "GET" && url.pathname === "/vendor/highlight-theme.css")
909
1007
  return sendFile(res, highlightThemeAssetPath, "text/css; charset=utf-8");
1008
+ if (req.method === "GET" && url.pathname === "/api/images")
1009
+ return sendImage(res, url.searchParams.get("path"), url.searchParams.get("mime"));
910
1010
  const scope = webRuntimeScopeFromUrl(url);
911
1011
  const repl = await router.get(scope);
912
1012
  if (req.method === "GET" && url.pathname === "/events")
@@ -970,6 +1070,23 @@ async function sendFile(res, filepath, contentType) {
970
1070
  res.writeHead(200, { "Content-Type": contentType, "Cache-Control": "public, max-age=3600" });
971
1071
  res.end(body);
972
1072
  }
1073
+ async function sendImage(res, encodedPath, mimeType) {
1074
+ const filepath = decodeImagePath(encodedPath);
1075
+ if (!filepath)
1076
+ return sendJson(res, { error: "missing image path" }, 400);
1077
+ const contentType = safeImageContentType(mimeType);
1078
+ const binaryPath = filepath.endsWith(".base64.txt") ? filepath.slice(0, -".base64.txt".length) : filepath;
1079
+ if (existsSync(binaryPath)) {
1080
+ res.writeHead(200, { "Content-Type": contentType, "Cache-Control": "no-store" });
1081
+ createReadStream(binaryPath).pipe(res);
1082
+ return;
1083
+ }
1084
+ if (!existsSync(filepath))
1085
+ return sendJson(res, { error: "image not found" }, 404);
1086
+ const base64 = (await fs.readFile(filepath, "utf8")).trim();
1087
+ res.writeHead(200, { "Content-Type": contentType, "Cache-Control": "no-store" });
1088
+ res.end(Buffer.from(stripDataUrlPrefix(base64), "base64"));
1089
+ }
973
1090
  function sendJson(res, value, status = 200) {
974
1091
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
975
1092
  res.end(JSON.stringify(value));
@@ -1074,7 +1191,7 @@ function reduceStatus(status, event) {
1074
1191
  if (event.type === "state")
1075
1192
  return { ...status, phase: event.phase, detail: event.detail, currentTool: event.phase === "preparing" || event.phase === "calling_model" || event.phase === "ready" ? undefined : status.currentTool, usage: event.phase === "preparing" ? undefined : status.usage, streamedOutputTokens: event.phase === "preparing" ? 0 : status.streamedOutputTokens, inputTokenUpdatedAt: event.phase === "preparing" ? undefined : status.inputTokenUpdatedAt, outputTokenUpdatedAt: event.phase === "preparing" ? undefined : status.outputTokenUpdatedAt, retryCooldownUntil: event.phase === "preparing" ? undefined : status.retryCooldownUntil, activityTick: status.activityTick + 1 };
1076
1193
  if (event.type === "tool.started")
1077
- return { ...status, phase: "running_tools", detail: event.toolUse.name, currentTool: { id: event.toolUse.id, name: event.toolUse.name, input: event.toolUse.input, startedAt: Date.now() }, activityTick: status.activityTick + 1 };
1194
+ return { ...status, phase: "running_tools", detail: event.toolUse.name, currentTool: { id: event.toolUse.id, name: event.toolUse.name, kind: toolKindForToolUse(event.toolUse.name, event.toolUse.input), startedAt: Date.now() }, activityTick: status.activityTick + 1 };
1078
1195
  if (event.type === "tool.finished")
1079
1196
  return { ...status, currentTool: status.currentTool?.id === event.toolUse.id ? undefined : status.currentTool, activityTick: status.activityTick + 1 };
1080
1197
  if (event.type === "context.metrics")
@@ -1095,6 +1212,21 @@ function reduceStatus(status, event) {
1095
1212
  return { ...status, activityTick: status.activityTick + 1 };
1096
1213
  return status;
1097
1214
  }
1215
+ function toolKindForToolUse(toolName, input) {
1216
+ if (toolName === "image2")
1217
+ return isRecord(input) && input.mode === "edit" ? "修图" : "作图";
1218
+ if (toolName === "edit" || toolName === "write" || toolName.includes("artifact_editor") || toolName.includes("apply_patch"))
1219
+ return "编辑";
1220
+ if (toolName === "exec" || toolName.includes("shell") || toolName.includes("command"))
1221
+ return "执行";
1222
+ if (toolName.includes("download"))
1223
+ return "下载";
1224
+ if (toolName === "read" || toolName === "list" || toolName === "grep" || toolName === "search" || toolName.includes("query") || toolName.includes("load"))
1225
+ return "查询";
1226
+ if (toolName === "plan")
1227
+ return "计划";
1228
+ return "工具";
1229
+ }
1098
1230
  async function handleExportCommand(outputPath, runtime) {
1099
1231
  const snapshot = runtime.engine.snapshot();
1100
1232
  if (!snapshot.session)
@@ -1495,20 +1627,43 @@ function imageLineForBlock(role, block) {
1495
1627
  kind,
1496
1628
  text: block.label ?? `[image ${block.mimeType}]`,
1497
1629
  image: {
1498
- src: imageBlockToDataUrl(block),
1630
+ src: imageBlockToSrc(block),
1499
1631
  label: block.label,
1500
1632
  mimeType: block.mimeType,
1501
1633
  },
1502
1634
  };
1503
1635
  }
1504
- function imageBlockToDataUrl(block) {
1505
- const data = resolveImageBlockDataSync(block);
1636
+ function imageBlockToSrc(block) {
1637
+ if (block.storage?.path) {
1638
+ return `/api/images?path=${encodeImagePath(block.storage.path)}&mime=${encodeURIComponent(block.mimeType)}`;
1639
+ }
1640
+ const data = block.data.trim();
1506
1641
  if (!data)
1507
1642
  return "";
1508
1643
  if (data.startsWith("data:"))
1509
1644
  return data;
1510
1645
  return `data:${block.mimeType};base64,${data}`;
1511
1646
  }
1647
+ function encodeImagePath(filepath) {
1648
+ return Buffer.from(filepath, "utf8").toString("base64url");
1649
+ }
1650
+ function decodeImagePath(value) {
1651
+ if (!value)
1652
+ return undefined;
1653
+ try {
1654
+ return Buffer.from(value, "base64url").toString("utf8");
1655
+ }
1656
+ catch {
1657
+ return undefined;
1658
+ }
1659
+ }
1660
+ function safeImageContentType(value) {
1661
+ return value && /^image\/[a-z0-9.+-]+$/i.test(value) ? value : "application/octet-stream";
1662
+ }
1663
+ function stripDataUrlPrefix(value) {
1664
+ const match = /^data:[^;]+;base64,(.*)$/is.exec(value);
1665
+ return match ? match[1] : value;
1666
+ }
1512
1667
  function assistantText(message) {
1513
1668
  const text = message.blocks.filter((block) => block.type === "text").map((block) => block.text).join("");
1514
1669
  return text.length > 0 ? text : undefined;
@@ -1561,14 +1716,21 @@ function thinkingLine(text, live = false) {
1561
1716
  return { kind: "thinking", title: titleForKind("thinking"), text, previewStyle: "summary", summaryMaxLines: THINKING_SUMMARY_MAX_LINES, live };
1562
1717
  }
1563
1718
  function formatToolUse(toolUse) {
1719
+ const toolKind = toolKindForToolUse(toolUse.name, toolUse.input);
1564
1720
  if (toolUse.name === "plan" && isPlanToolPayload(toolUse.input))
1565
- return { kind: "tool", title: toolTitle(toolUse.name, "running"), bodyTitle: planToolBodyTitle(toolUse.input), text: formatPlanToolPayload(toolUse.input), collapsible: true };
1721
+ return { kind: "tool", title: toolTitle(toolUse.name, "running"), bodyTitle: planToolBodyTitle(toolUse.input), toolKind, text: formatPlanToolPayload(toolUse.input), collapsible: true };
1566
1722
  const description = toolUse.name === "exec" ? execDescriptionFromInput(toolUse.input) : undefined;
1567
- return { kind: "tool", title: toolTitle(toolUse.name, "running"), bodyTitle: description, text: formatReplData(toolUse.input, 1200), previewStyle: "summary", collapsible: true };
1723
+ return { kind: "tool", title: toolTitle(toolUse.name, "running"), bodyTitle: description, toolKind, text: formatReplData(toolUse.input, 1200), previewStyle: "summary", collapsible: true };
1568
1724
  }
1569
1725
  function formatToolResultLine(toolName, output, ok) {
1570
1726
  const formatted = formatToolResult(toolName, output, ok);
1571
- return { kind: ok ? "tool" : "error", title: toolTitle(toolName, "finished"), bodyTitle: formatted.bodyTitle, titleStatus: ok ? "success" : "failure", text: formatted.text, format: formatted.format, live: false, previewStyle: formatted.full ? undefined : "summary", summaryMaxLines: formatted.summaryMaxLines, collapsible: true };
1727
+ return { kind: ok ? "tool" : "error", title: toolTitle(toolName, "finished"), bodyTitle: formatted.bodyTitle, titleStatus: ok ? "success" : "failure", toolKind: toolKindForToolUse(toolName, undefined), text: formatted.text, format: formatted.format, live: false, previewStyle: formatted.full ? undefined : "summary", summaryMaxLines: formatted.summaryMaxLines, collapsible: true, artifact: xhsArtifactFromToolOutput(toolName, output) };
1728
+ }
1729
+ function xhsArtifactFromToolOutput(toolName, output) {
1730
+ if (toolName !== "open_xhs_artifact_editor" || !isRecord(output))
1731
+ return undefined;
1732
+ const artifact = output.artifact;
1733
+ return isRecord(artifact) && typeof artifact.id === "string" ? artifact : undefined;
1572
1734
  }
1573
1735
  function toolTitle(toolName, _phase) {
1574
1736
  return toolName;