skyloom 1.15.4 → 1.16.0

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.
Files changed (47) hide show
  1. package/dist/cli/command_args.d.ts +74 -0
  2. package/dist/cli/command_args.d.ts.map +1 -0
  3. package/dist/cli/command_args.js +129 -0
  4. package/dist/cli/command_args.js.map +1 -0
  5. package/dist/cli/loom.d.ts +20 -0
  6. package/dist/cli/loom.d.ts.map +1 -1
  7. package/dist/cli/loom.js +202 -24
  8. package/dist/cli/loom.js.map +1 -1
  9. package/dist/cli/loom_chat.d.ts.map +1 -1
  10. package/dist/cli/loom_chat.js +39 -0
  11. package/dist/cli/loom_chat.js.map +1 -1
  12. package/dist/core/agent.js +2 -2
  13. package/dist/core/agent.js.map +1 -1
  14. package/dist/core/security.d.ts.map +1 -1
  15. package/dist/core/security.js +1 -0
  16. package/dist/core/security.js.map +1 -1
  17. package/dist/core/tool_router.d.ts.map +1 -1
  18. package/dist/core/tool_router.js +11 -3
  19. package/dist/core/tool_router.js.map +1 -1
  20. package/dist/tools/builtin.d.ts.map +1 -1
  21. package/dist/tools/builtin.js +38 -192
  22. package/dist/tools/builtin.js.map +1 -1
  23. package/dist/tools/websearch.d.ts +92 -0
  24. package/dist/tools/websearch.d.ts.map +1 -0
  25. package/dist/tools/websearch.js +343 -0
  26. package/dist/tools/websearch.js.map +1 -0
  27. package/dist/web/server.js +2 -9
  28. package/dist/web/server.js.map +1 -1
  29. package/dist/web/ui.d.ts.map +1 -1
  30. package/dist/web/ui.js +3 -2
  31. package/dist/web/ui.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cli/command_args.ts +159 -0
  34. package/src/cli/loom.ts +155 -17
  35. package/src/cli/loom_chat.ts +33 -0
  36. package/src/core/agent.ts +2 -2
  37. package/src/core/security.ts +1 -0
  38. package/src/core/tool_router.ts +11 -3
  39. package/src/tools/builtin.ts +38 -190
  40. package/src/tools/websearch.ts +368 -0
  41. package/src/web/server.ts +2 -10
  42. package/src/web/ui.ts +3 -2
  43. package/tests/command_args.test.ts +115 -0
  44. package/tests/loom.test.ts +74 -0
  45. package/tests/tool_router.test.ts +15 -0
  46. package/tests/web.test.ts +7 -5
  47. package/tests/websearch.test.ts +190 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * 斜杠命令向导 · Cascading argument wizard for slash commands.
3
+ *
4
+ * After a slash command that takes structured arguments is chosen in the loom
5
+ * palette, the TUI walks the user through its arguments one level at a time:
6
+ * pick a provider, then paste a key; pick a model; pick a session. Each level is
7
+ * navigable with ↑/↓ and filterable by typing — the same affordance as the
8
+ * command palette itself, extended to arguments.
9
+ *
10
+ * This module is the pure brain of that flow (no I/O, no terminal) so it is
11
+ * fully unit-testable: given a command, the values chosen so far, and a snapshot
12
+ * of runtime context, it returns the next step — or null when the command is
13
+ * complete and ready to submit.
14
+ */
15
+
16
+ export interface ArgChoice {
17
+ /** The value contributed to the final command line. */
18
+ value: string;
19
+ /** Display label in the list. */
20
+ label: string;
21
+ /** Optional dim hint shown after the label. */
22
+ hint?: string;
23
+ /** Optional group heading (e.g. provider name) for sectioned lists. */
24
+ group?: string;
25
+ }
26
+
27
+ export interface WizardStep {
28
+ kind: 'choice' | 'freeform';
29
+ /** Heading shown above the list / prompt. */
30
+ title: string;
31
+ /** Choices for a 'choice' step (already ordered). */
32
+ choices: ArgChoice[];
33
+ /** A 'choice' step may also accept a typed value not in the list. */
34
+ allowFreeform: boolean;
35
+ /** Placeholder for a 'freeform' step (or a free-typed choice). */
36
+ placeholder?: string;
37
+ /** Mask typed input (API keys). */
38
+ secret?: boolean;
39
+ }
40
+
41
+ export interface WizardProvider { id: string; label: string; configured: boolean; envVar?: string }
42
+ export interface WizardModel { id: string; provider: string; label: string; hint?: string }
43
+ export interface WizardSession { id: string; label: string }
44
+
45
+ export interface WizardContext {
46
+ providers: WizardProvider[];
47
+ models: WizardModel[];
48
+ sessions: WizardSession[];
49
+ }
50
+
51
+ /** Commands that drive a guided wizard (base name without the leading slash). */
52
+ const WIZARD_COMMANDS = new Set(['model', 'apikey', 'connect', 'resume']);
53
+
54
+ /** Does this base command (with or without leading slash) have a wizard? */
55
+ export function hasWizard(command: string): boolean {
56
+ return WIZARD_COMMANDS.has(command.replace(/^\//, '').trim().toLowerCase());
57
+ }
58
+
59
+ function providerChoices(ctx: WizardContext): ArgChoice[] {
60
+ return ctx.providers.map((p) => ({
61
+ value: p.id,
62
+ label: p.label,
63
+ hint: p.configured ? '✓ 已配置' : (p.envVar ? `需 ${p.envVar}` : '未配置'),
64
+ }));
65
+ }
66
+
67
+ function modelChoices(ctx: WizardContext): ArgChoice[] {
68
+ return ctx.models.map((m) => ({
69
+ value: m.id,
70
+ label: m.id,
71
+ hint: m.hint,
72
+ group: m.provider,
73
+ }));
74
+ }
75
+
76
+ /**
77
+ * The next step for `command` given the values already chosen, or null when the
78
+ * command is complete (ready to submit via {@link buildCommandLine}).
79
+ */
80
+ export function nextWizardStep(command: string, prior: string[], ctx: WizardContext): WizardStep | null {
81
+ const cmd = command.replace(/^\//, '').trim().toLowerCase();
82
+
83
+ switch (cmd) {
84
+ case 'model': {
85
+ if (prior.length >= 1) return null;
86
+ const choices: ArgChoice[] = [
87
+ { value: 'reset', label: '↺ reset', hint: '回到统一默认模型' },
88
+ ...modelChoices(ctx),
89
+ ];
90
+ return { kind: 'choice', title: '选择模型(输入可筛选)', choices, allowFreeform: true, placeholder: '模型 id' };
91
+ }
92
+
93
+ case 'connect': {
94
+ if (prior.length >= 1) return null;
95
+ return { kind: 'choice', title: '选择 Provider', choices: providerChoices(ctx), allowFreeform: true, placeholder: 'provider' };
96
+ }
97
+
98
+ case 'apikey': {
99
+ // step 0: provider · step 1: the key
100
+ if (prior.length === 0) {
101
+ return { kind: 'choice', title: '为哪个 Provider 配置 API Key', choices: providerChoices(ctx), allowFreeform: true, placeholder: 'provider' };
102
+ }
103
+ if (prior.length === 1) {
104
+ return { kind: 'freeform', title: `粘贴 ${prior[0]} 的 API Key`, choices: [], allowFreeform: true, placeholder: 'sk-…(回车保存)', secret: true };
105
+ }
106
+ return null;
107
+ }
108
+
109
+ case 'resume': {
110
+ if (prior.length >= 1) return null;
111
+ const choices: ArgChoice[] = ctx.sessions.map((s, i) => ({ value: String(i + 1), label: `${i + 1}. ${s.label}`, hint: s.id.slice(0, 8) }));
112
+ return { kind: 'choice', title: choices.length ? '选择要恢复的会话' : '暂无历史会话', choices, allowFreeform: true, placeholder: '序号或 id' };
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /** Assemble the final command line from the base command + chosen values. */
119
+ export function buildCommandLine(command: string, values: string[]): string {
120
+ const cmd = command.replace(/^\//, '').trim().toLowerCase();
121
+ const v = values.filter((x) => x !== undefined && x !== null);
122
+ switch (cmd) {
123
+ case 'apikey':
124
+ // /apikey set <provider> <key>
125
+ return `/apikey set ${v.join(' ')}`.trim();
126
+ case 'model':
127
+ return `/model ${v.join(' ')}`.trim();
128
+ case 'connect':
129
+ return `/connect ${v.join(' ')}`.trim();
130
+ case 'resume':
131
+ return `/resume ${v.join(' ')}`.trim();
132
+ default:
133
+ return `/${cmd} ${v.join(' ')}`.trim();
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Filter + rank choices by a typed query (case-insensitive substring on value,
139
+ * label, and group). Empty query returns the list unchanged. Exact value/label
140
+ * prefix matches sort first so the obvious pick lands at the top.
141
+ */
142
+ export function filterChoices(choices: ArgChoice[], typed: string): ArgChoice[] {
143
+ const q = typed.trim().toLowerCase();
144
+ if (!q) return choices;
145
+ const scored: Array<{ c: ArgChoice; rank: number }> = [];
146
+ for (const c of choices) {
147
+ const value = c.value.toLowerCase();
148
+ const label = c.label.toLowerCase();
149
+ const group = (c.group || '').toLowerCase();
150
+ let rank = -1;
151
+ if (value === q || label === q) rank = 0;
152
+ else if (value.startsWith(q) || label.startsWith(q)) rank = 1;
153
+ else if (value.includes(q) || label.includes(q)) rank = 2;
154
+ else if (group.includes(q)) rank = 3;
155
+ if (rank >= 0) scored.push({ c, rank });
156
+ }
157
+ scored.sort((a, b) => a.rank - b.rank); // stable within equal ranks
158
+ return scored.map((s) => s.c);
159
+ }
package/src/cli/loom.ts CHANGED
@@ -30,6 +30,7 @@ import * as readline from "readline";
30
30
  import chalk from "chalk";
31
31
  import { agentTheme, AGENT_ORDER, PALETTE } from "../core/theme";
32
32
  import { charWidth, visualWidth, SLASH_COMMANDS } from "./tui";
33
+ import { hasWizard, buildCommandLine, filterChoices, type WizardStep, type ArgChoice } from "./command_args";
33
34
 
34
35
  /* ════════════════════════════════════════
35
36
  ANSI-aware string helpers (pure, tested)
@@ -409,6 +410,14 @@ export class LoomUI {
409
410
  modeBadge = "";
410
411
  /** User-defined slash commands shown in the palette ([name, description]). */
411
412
  extraCommands: [string, string][] = [];
413
+ /**
414
+ * Cascading argument wizard, active after a structured slash command is
415
+ * chosen (e.g. /apikey → pick provider → paste key). Resolved step-by-step
416
+ * via the wizardStep callback the chat loop wires up.
417
+ */
418
+ private wizard: { command: string; values: string[]; step: WizardStep; typed: string; idx: number } | null = null;
419
+ /** Next-step resolver for the argument wizard (set by the chat loop with runtime context). */
420
+ wizardStep: ((command: string, prior: string[]) => WizardStep | null) | null = null;
412
421
  private keypressHandler: ((str: string, key: any) => void) | null = null;
413
422
  private resizeHandler: (() => void) | null = null;
414
423
 
@@ -622,6 +631,9 @@ export class LoomUI {
622
631
  const name = key?.name;
623
632
  if (key?.ctrl && name === "c") { this.handleSigint(); return; }
624
633
 
634
+ // The argument wizard owns all keys while it is open.
635
+ if (this.wizard && !this.busy) { this.handleWizardKey(str, key); return; }
636
+
625
637
  if (name === "pageup") { this.scrollOff += Math.max(1, this.bodyH() - 2); this.clampScroll(); this.paint(); return; }
626
638
  if (name === "pagedown") { this.scrollOff -= Math.max(1, this.bodyH() - 2); this.clampScroll(); this.paint(); return; }
627
639
 
@@ -630,15 +642,14 @@ export class LoomUI {
630
642
  let text = this.inputGlyphs.join("").trim();
631
643
 
632
644
  // Palette open: Enter runs the ↑↓-highlighted command (Claude Code
633
- // style). Commands that take arguments fill the input instead so the
634
- // user can type them.
645
+ // style). A command with a guided argument wizard opens the wizard;
646
+ // otherwise an argument-taking command fills the input to wait for input.
635
647
  const matches = this.paletteMatches();
636
648
  if (matches.length > 0 && text.startsWith("/")) {
637
649
  const [cmd] = matches[Math.max(0, Math.min(this.paletteIdx, matches.length - 1))];
650
+ if (this.startWizard(cmd.trim())) return;
638
651
  if (cmd.endsWith(" ")) {
639
- // argument-taking command: fill the input and wait for arguments
640
- // (the palette closes once the line contains a space; a second
641
- // Enter then submits as typed)
652
+ // argument-taking command without a wizard: fill the input and wait.
642
653
  this.inputGlyphs = [...cmd];
643
654
  this.cursor = this.inputGlyphs.length;
644
655
  this.paletteIdx = 0;
@@ -648,13 +659,7 @@ export class LoomUI {
648
659
  text = cmd.trimEnd();
649
660
  }
650
661
 
651
- this.inputGlyphs = []; this.cursor = 0; this.histIdx = -1; this.paletteIdx = 0;
652
- this.scrollOff = 0; // submitting a turn snaps back to the tail to watch the reply
653
- if (text) { this.history.unshift(text); if (this.history.length > 200) this.history.pop(); }
654
- const r = this.pendingResolve;
655
- this.pendingResolve = null;
656
- this.paint();
657
- if (r) r(text);
662
+ this.submitText(text);
658
663
  return;
659
664
  }
660
665
 
@@ -683,7 +688,10 @@ export class LoomUI {
683
688
  if (paletteOpen) {
684
689
  const m = this.paletteMatches();
685
690
  const pick = m[Math.min(this.paletteIdx, m.length - 1)];
686
- if (pick) { this.inputGlyphs = [...pick[0].trimEnd()]; this.cursor = this.inputGlyphs.length; }
691
+ if (pick) {
692
+ if (this.startWizard(pick[0].trim())) return;
693
+ this.inputGlyphs = [...pick[0].trimEnd()]; this.cursor = this.inputGlyphs.length;
694
+ }
687
695
  }
688
696
  this.paint(); return;
689
697
  }
@@ -744,6 +752,91 @@ export class LoomUI {
744
752
 
745
753
  private flashHint = "";
746
754
 
755
+ /* ── argument wizard ── */
756
+
757
+ /** Submit a turn: clear input, record history, resolve the pending read. */
758
+ private submitText(text: string) {
759
+ this.wizard = null;
760
+ this.inputGlyphs = []; this.cursor = 0; this.histIdx = -1; this.paletteIdx = 0;
761
+ this.scrollOff = 0; // submitting a turn snaps back to the tail to watch the reply
762
+ if (text) { this.history.unshift(text); if (this.history.length > 200) this.history.pop(); }
763
+ const r = this.pendingResolve;
764
+ this.pendingResolve = null;
765
+ this.paint();
766
+ if (r) r(text);
767
+ }
768
+
769
+ /** Choices for the current wizard step, filtered by what the user has typed. */
770
+ private wizardFiltered(): ArgChoice[] {
771
+ if (!this.wizard || this.wizard.step.kind !== "choice") return [];
772
+ return filterChoices(this.wizard.step.choices, this.wizard.typed);
773
+ }
774
+
775
+ /** Open the wizard for `command` (e.g. "/model"). Returns false if it has none. */
776
+ private startWizard(command: string): boolean {
777
+ if (!this.wizardStep || !hasWizard(command)) return false;
778
+ const step = this.wizardStep(command, []);
779
+ if (!step) return false;
780
+ this.wizard = { command, values: [], step, typed: "", idx: 0 };
781
+ this.inputGlyphs = []; this.cursor = 0; this.paletteIdx = 0;
782
+ this.paint();
783
+ return true;
784
+ }
785
+
786
+ /** Accept a value for the current step; advance to the next or submit. */
787
+ private wizardCommit(value: string) {
788
+ const w = this.wizard!;
789
+ const values = [...w.values, value];
790
+ const next = this.wizardStep ? this.wizardStep(w.command, values) : null;
791
+ if (next) {
792
+ this.wizard = { command: w.command, values, step: next, typed: "", idx: 0 };
793
+ this.paint();
794
+ } else {
795
+ this.submitText(buildCommandLine(w.command, values));
796
+ }
797
+ }
798
+
799
+ /** Step back one level (or close the wizard when at the first level). */
800
+ private wizardBack() {
801
+ const w = this.wizard!;
802
+ if (w.values.length === 0 || !this.wizardStep) { this.wizard = null; this.paint(); return; }
803
+ const values = w.values.slice(0, -1);
804
+ const step = this.wizardStep(w.command, values);
805
+ if (!step) { this.wizard = null; this.paint(); return; }
806
+ this.wizard = { command: w.command, values, step, typed: "", idx: 0 };
807
+ this.paint();
808
+ }
809
+
810
+ private handleWizardKey(str: string, key: any) {
811
+ const w = this.wizard!;
812
+ const name = key?.name;
813
+ if (name === "escape") { this.wizard = null; this.paint(); return; }
814
+ if (name === "up") { if (w.step.kind === "choice") w.idx = Math.max(0, w.idx - 1); this.paint(); return; }
815
+ if (name === "down") {
816
+ if (w.step.kind === "choice") w.idx = Math.min(Math.max(0, this.wizardFiltered().length - 1), w.idx + 1);
817
+ this.paint(); return;
818
+ }
819
+ if (name === "return" || name === "tab") {
820
+ if (w.step.kind === "choice") {
821
+ const filtered = this.wizardFiltered();
822
+ const pick = filtered[Math.min(w.idx, filtered.length - 1)];
823
+ if (pick) { this.wizardCommit(pick.value); return; }
824
+ if (w.step.allowFreeform && w.typed.trim()) { this.wizardCommit(w.typed.trim()); return; }
825
+ return;
826
+ }
827
+ if (w.typed.trim()) this.wizardCommit(w.typed.trim());
828
+ return;
829
+ }
830
+ if (name === "backspace") {
831
+ if (w.typed.length > 0) { w.typed = w.typed.slice(0, -1); w.idx = 0; this.paint(); return; }
832
+ this.wizardBack(); return;
833
+ }
834
+ if (str && !key?.ctrl && !key?.meta) {
835
+ const glyphs = [...str].filter((c) => c >= " " || charWidth(c.codePointAt(0)!) > 0);
836
+ if (glyphs.length) { w.typed += glyphs.join(""); w.idx = 0; this.paint(); }
837
+ }
838
+ }
839
+
747
840
  private paletteMatches(): [string, string][] {
748
841
  const l = this.inputGlyphs.join("");
749
842
  if (!l.startsWith("/") || l.includes(" ")) return [];
@@ -936,6 +1029,13 @@ export class LoomUI {
936
1029
  if (this.modal) {
937
1030
  content = " " + chalk.yellow("⚠ ") + cutVisual(this.modal.text, innerW - 14) + chalk.bold(" 允许? ") + chalk.dim("[y/N]");
938
1031
  cursorPos = { row: rows - 2, col: Math.min(innerW, visualWidth(content) + 1) };
1032
+ } else if (this.wizard) {
1033
+ const w = this.wizard;
1034
+ const crumb = [w.command.replace(/^\//, ""), ...w.values].join(" ");
1035
+ const shownTyped = w.step.secret ? "•".repeat([...w.typed].length) : w.typed;
1036
+ const head = chalk.hex(t.hex)(` ${t.symbol} `) + chalk.hex(PALETTE.inkLight)(crumb + " ▸ ");
1037
+ content = head + cutVisual(shownTyped, innerW - visualWidth(head) - 2);
1038
+ cursorPos = { row: rows - 2, col: Math.min(innerW, visualWidth(content) + 1) };
939
1039
  } else {
940
1040
  const promptStr = chalk.hex(t.hex)(` ${t.symbol} `) + chalk.hex(PALETTE.inkLight)("❯ ");
941
1041
  const promptW = visualWidth(promptStr);
@@ -966,17 +1066,55 @@ export class LoomUI {
966
1066
  const paletteUp = this.paletteMatches().length > 0 && this.inputGlyphs[0] === "/";
967
1067
  const hint = this.busy
968
1068
  ? " Ctrl-C 中断本轮 "
969
- : paletteUp
970
- ? " ↑↓ 选命令 · Enter 执行 · Tab 补全 · Esc 收起 "
971
- : " / 命令 · 滚轮/PgUp 回看 · Shift+Tab 切模式 · Ctrl-C 退出 ";
1069
+ : this.wizard
1070
+ ? (this.wizard.step.kind === "choice"
1071
+ ? " ↑↓ 选择 · Enter 确认 · 输入筛选 · ⌫ 返回 · Esc 取消 "
1072
+ : " 输入后 Enter 确认 · ⌫ 返回上一步 · Esc 取消 ")
1073
+ : paletteUp
1074
+ ? " ↑↓ 选命令 · Enter 执行 · Tab 补全 · Esc 收起 "
1075
+ : " / 命令 · 滚轮/PgUp 回看 · Shift+Tab 切模式 · Ctrl-C 退出 ";
972
1076
  // └─ hint ───…┘ → 2 + w(hint) + fill + 1 = cols
973
1077
  const fill = innerW - visualWidth(hint) - 1;
974
1078
  frame.push(B("└─") + chalk.dim(hint) + B("─".repeat(Math.max(0, fill)) + "┘"));
975
1079
  }
976
1080
 
1081
+ // ── argument wizard: overlay the title + selectable choices ──
1082
+ if (this.wizard && !this.modal) {
1083
+ const w = this.wizard;
1084
+ const overlayRow = (row: number, s: string) => {
1085
+ if (row < 1 + SKY_H || row >= 1 + SKY_H + bodyH) return;
1086
+ frame[row] = B("│") + padAnsi(rail[row - 1 - SKY_H] ?? "", RAIL_W) + B("│") + " " + padAnsi(s, this.viewW()) + B("│");
1087
+ };
1088
+ const lines: string[] = [];
1089
+ lines.push(chalk.dim(" " + cutVisual(w.step.title, this.viewW() - 4)));
1090
+ if (w.step.kind === "choice") {
1091
+ const filtered = this.wizardFiltered();
1092
+ const bodyRows = Math.min(7, bodyH - 1);
1093
+ if (!filtered.length) {
1094
+ lines.push(" " + chalk.dim(w.step.allowFreeform ? `直接输入,回车确认` : `无匹配项`));
1095
+ } else {
1096
+ w.idx = Math.max(0, Math.min(w.idx, filtered.length - 1));
1097
+ const start = Math.max(0, Math.min(w.idx - bodyRows + 1, filtered.length - bodyRows));
1098
+ filtered.slice(start, start + bodyRows).forEach((c, i) => {
1099
+ const sel = start + i === w.idx;
1100
+ const mark = sel ? chalk.hex(t.hex)(" ▸ ") : " ";
1101
+ const group = c.group ? chalk.dim(`${c.group}/`) : "";
1102
+ const label = sel ? chalk.bold.hex(t.hex)(c.label) : chalk.hex(PALETTE.inkLight)(c.label);
1103
+ const hint = c.hint ? chalk.dim(" " + c.hint) : "";
1104
+ const counter = sel && filtered.length > bodyRows ? chalk.dim(` ${w.idx + 1}/${filtered.length}`) : "";
1105
+ lines.push(mark + group + cutVisual(label + hint, this.viewW() - 8) + counter);
1106
+ });
1107
+ }
1108
+ } else {
1109
+ lines.push(" " + chalk.dim(w.step.placeholder || "输入后回车确认"));
1110
+ }
1111
+ const baseRow = 1 + SKY_H + bodyH - lines.length;
1112
+ lines.forEach((s, i) => overlayRow(baseRow + i, s));
1113
+ }
1114
+
977
1115
  // ── slash palette: overlay onto the rows just above the divider ──
978
1116
  const matches = this.paletteMatches();
979
- if (matches.length > 0 && this.inputGlyphs[0] === "/" && !this.modal) {
1117
+ if (!this.wizard && matches.length > 0 && this.inputGlyphs[0] === "/" && !this.modal) {
980
1118
  const maxShow = Math.min(8, bodyH - 1);
981
1119
  this.paletteIdx = Math.max(0, Math.min(this.paletteIdx, matches.length - 1));
982
1120
  // scroll window that keeps the ↑↓ selection visible
@@ -320,6 +320,39 @@ export async function loomChat(ctx: any, startAgent: any, deps: LoomChatDeps): P
320
320
  let customCommands = loadCustomCommands();
321
321
  ui.extraCommands = customCommands.map((c) => ["/" + c.name, c.description] as [string, string]);
322
322
 
323
+ // Pre-load the session list so the /resume wizard has choices from the start.
324
+ try { lastSessions = await agent.memory.listSessions(); } catch { /* best-effort */ }
325
+
326
+ // Guided argument wizard for structured commands (/model · /apikey · /connect · /resume):
327
+ // pick a provider/model/session from a ↑↓ list, paste a key — no syntax to memorize.
328
+ ui.wizardStep = (command, prior) => {
329
+ try {
330
+ const { nextWizardStep } = require("./command_args");
331
+ const { listProviders, modelsFor, providerLabel, allModels } = require("../core/catalog");
332
+ const { loadConfig } = require("../core/config");
333
+ const cfg = loadConfig();
334
+ const configured = (p: string): boolean => {
335
+ const meta = PROVIDER_META[p];
336
+ if (meta?.envVar && process.env[meta.envVar]) return true;
337
+ if (cfg?.api_keys?.[p]) return true;
338
+ const models = modelsFor(p);
339
+ return models.length > 0 && models.every((m: any) => m.local); // local providers need no key
340
+ };
341
+ const providers = listProviders().map((p: string) => ({
342
+ id: p, label: providerLabel(p), configured: configured(p), envVar: PROVIDER_META[p]?.envVar,
343
+ }));
344
+ const models = allModels().map((m: any) => ({
345
+ id: m.id, provider: m.provider, label: m.id,
346
+ hint: m.local ? "本地/免费" : (m.costIn != null ? `$${m.costIn}/$${m.costOut}` : undefined),
347
+ }));
348
+ const sessions = lastSessions.map((s: any) => ({
349
+ id: String(s.id),
350
+ label: (s.preview || "(空)").replace(/\s+/g, " ").slice(0, 40),
351
+ }));
352
+ return nextWizardStep(command, prior, { providers, models, sessions });
353
+ } catch { return null; }
354
+ };
355
+
323
356
  try {
324
357
  while (true) {
325
358
  const inp = await ui.readInput();
package/src/core/agent.ts CHANGED
@@ -199,10 +199,10 @@ export class BaseAgent {
199
199
  const lang = (this.config as any).llm?.language || 'zh';
200
200
  if (lang === 'en') {
201
201
  return prompt +
202
- `\n\n## Thinking Protocol\nBefore acting, briefly weigh: (1) **What** is the actual need? (2) **How** sure am I? If <80%, flag with [uncertain] and ask.\nIf stuck, admit it — propose a partial answer or ask the user. Never fabricate.\n\n## Behavior\n- Act, don't narrate. No "I will..." before tool calls.\n- Stay in scope. Do what's asked, then stop.\n- Batch independent tool calls in one response.\n- For tasks with 3+ steps, plan with todo_write first and update item status as you go.\n- Verify writes: read back, report verified state.\n- Call list_skills when the task needs specialized capabilities.`;
202
+ `\n\n## Thinking Protocol\nBefore acting, briefly weigh: (1) **What** is the actual need? (2) **How** sure am I? If <80%, flag with [uncertain] and ask.\nIf stuck, admit it — propose a partial answer or ask the user. Never fabricate.\n\n## Behavior\n- Act, don't narrate. No "I will..." before tool calls.\n- Stay in scope. Do what's asked, then stop.\n- Batch independent tool calls in one response.\n- For tasks with 3+ steps, plan with todo_write first and update item status as you go.\n- Verify writes: read back, report verified state.\n- For anything current or real-time (today's news/hot topics, recent events, latest versions, prices, weather), call web_search FIRST, then read_url for detail. Never answer from memory or claim you can't go online.\n- Call list_skills when the task needs specialized capabilities.`;
203
203
  }
204
204
  return prompt +
205
- `\n\n## 思考协议\n行动前快速判断:(1) 用户真实需求是什么?(2) 我有多大把握?低于80%标注 [不确定] 并主动询问。\n卡住时承认,给出部分答案或请求用户指导。绝不编造。\n\n## 行为守则\n- 直接行动,不预告。不说「我将要...」,直接调用工具\n- 不擅自扩大范围。用户要什么做什么,核心完成即止\n- 独立的工具调用一次发出,并行执行\n- 3 步以上的任务先用 todo_write 列任务清单,开工/完成时逐项更新状态\n- 写入后回读验证,汇报已验证状态而非仅尝试\n- 任务涉及专业能力时(PPT/Excel/PDF/网页设计/代码审查等),先调 list_skills 查看可用技能,再用 use_skill 激活`;
205
+ `\n\n## 思考协议\n行动前快速判断:(1) 用户真实需求是什么?(2) 我有多大把握?低于80%标注 [不确定] 并主动询问。\n卡住时承认,给出部分答案或请求用户指导。绝不编造。\n\n## 行为守则\n- 直接行动,不预告。不说「我将要...」,直接调用工具\n- 不擅自扩大范围。用户要什么做什么,核心完成即止\n- 独立的工具调用一次发出,并行执行\n- 3 步以上的任务先用 todo_write 列任务清单,开工/完成时逐项更新状态\n- 写入后回读验证,汇报已验证状态而非仅尝试\n- 凡涉及最新/实时信息(今日新闻热点、近期事件、最新版本、价格、天气等)一律先调 web_search 联网核实,再用 read_url 读全文;绝不凭记忆作答,也不要声称无法联网\n- 任务涉及专业能力时(PPT/Excel/PDF/网页设计/代码审查等),先调 list_skills 查看可用技能,再用 use_skill 激活`;
206
206
  }
207
207
 
208
208
  protected injectProgrammingWisdom(prompt: string): string {
@@ -89,6 +89,7 @@ const TOOL_DANGER_MAP: Record<string, DangerLevel> = {
89
89
  git_branch: DangerLevel.LOW,
90
90
  http_get: DangerLevel.LOW,
91
91
  fetch_page: DangerLevel.LOW,
92
+ read_url: DangerLevel.LOW,
92
93
  web_search: DangerLevel.LOW,
93
94
  remember_fact: DangerLevel.LOW,
94
95
  use_skill: DangerLevel.LOW,
@@ -101,9 +101,17 @@ function scoreTool(tool: ToolDefinition, queryTokens: Set<string>, queryLc: stri
101
101
  ['git', 'commit', 'diff', 'branch', '提交', '分支', '差异'].some(k => queryLc.includes(k))) {
102
102
  score += 4;
103
103
  }
104
- if (['web_search', 'fetch_page', 'http_get'].includes(name) &&
105
- ['web', 'url', 'http', 'research', '搜索', '网页', '联网', '资料'].some(k => queryLc.includes(k))) {
106
- score += 4;
104
+ if (['web_search', 'read_url', 'fetch_page', 'http_get'].includes(name) &&
105
+ [
106
+ // explicit web/search intent
107
+ 'web', 'url', 'http', 'research', '搜索', '搜', '网页', '联网', '上网', '资料', '查询', '查一下', '查查',
108
+ // time-sensitive / current-events intent — the reason "今日热点新闻" used to
109
+ // miss web_search entirely (it scored 0 and never made the tool shortlist)
110
+ 'news', 'today', 'latest', 'current', 'recent', 'now', 'breaking', 'trending', 'weather', 'price', 'stock',
111
+ '新闻', '今日', '今天', '最新', '近期', '实时', '热点', '热搜', '头条', '动态', '行情', '股价', '汇率', '天气', '比分', '发布',
112
+ '2024', '2025', '2026',
113
+ ].some(k => queryLc.includes(k))) {
114
+ score += 5;
107
115
  }
108
116
  if (['list_skills', 'use_skill'].includes(name) &&
109
117
  ['skill', '能力', '技能', 'ppt', 'pdf', 'excel', 'xlsx', 'docx'].some(k => queryLc.includes(k))) {