u1s1-cli 1.8.0 → 1.9.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.
@@ -70,7 +70,7 @@ export declare function writeAttributionExtension(): void;
70
70
  * - 每轮回复结束追加一条汇总行,如 ⚙ 9 tools · bash×4 · read×3 · ✗ 1
71
71
  * - 思考块折叠标签设为空,配合 pi 补丁(见 patches/)消除多余空行
72
72
  */
73
- export declare function writeCompactUiExtension(): void;
73
+ export declare function writeCompactUiExtension(outputDir?: string): void;
74
74
  /**
75
75
  * pi-web-ui 的就绪检测只认 <agentDir>/auth.json 里有没有凭据条目(models.json
76
76
  * 的 apiKey 它不看),空 {} 会在网页里弹「未检测到 pi agent 配置」引导装 pi。
@@ -388,8 +388,8 @@ export function writeAttributionExtension() {
388
388
  * - 每轮回复结束追加一条汇总行,如 ⚙ 9 tools · bash×4 · read×3 · ✗ 1
389
389
  * - 思考块折叠标签设为空,配合 pi 补丁(见 patches/)消除多余空行
390
390
  */
391
- export function writeCompactUiExtension() {
392
- const dir = join(agentDir, "extensions");
391
+ export function writeCompactUiExtension(outputDir) {
392
+ const dir = outputDir ?? join(agentDir, "extensions");
393
393
  mkdirSync(dir, { recursive: true });
394
394
  writeFileSync(join(dir, "u1s1-compact-ui.js"), `// 由 u1s1 每次启动自动生成,请勿手改
395
395
  function oneLine(text, max = 160) {
@@ -407,7 +407,7 @@ export default async function (pi) {
407
407
  let counts = {};
408
408
  let errors = 0;
409
409
  // 工具调用被折叠后,这一行摘要是新手了解「AI 刚才做了什么」的唯一入口,内置工具名给人话
410
- const TOOL_SUMMARY_LABELS: Record<string, string> = {
410
+ const TOOL_SUMMARY_LABELS = {
411
411
  read: "读文件", write: "写文件", edit: "改文件", bash: "跑命令", grep: "搜内容", find: "找文件", ls: "列目录",
412
412
  };
413
413
  pi.on("agent_start", async () => {
package/dist/brand.d.ts CHANGED
@@ -4,6 +4,15 @@ export declare const BRAND_CN = "\u6709\u4E00\u8BF4\u4E00";
4
4
  export declare const BRAND_TAGLINE = "\u8BF4\u4EBA\u8BDD\u7684 AI \u7F16\u7A0B\u642D\u5B50";
5
5
  export declare const DASHBOARD_URL = "https://u1s1.io/dashboard";
6
6
  export declare function formatHomePath(path: string): string;
7
+ /**
8
+ * 启动目录不对时的一句提醒(工单 b70c901e:用户在 C:\\Users\\1\\.u1s1 里跑 u1s1)。
9
+ * 配置目录里放着登录凭据与设置,模型的文件工具会直接看到;用户主目录则会让模型
10
+ * 翻遍整个主目录,又慢又容易改错地方。都不是错误,只提醒换到项目文件夹。
11
+ */
12
+ export declare function cwdAdvice(cwd: string, dirs?: {
13
+ home?: string;
14
+ configDir?: string;
15
+ }): string | undefined;
7
16
  export declare const HERO_ART: string[];
8
17
  /**
9
18
  * Startup hero, responsive to terminal width:
@@ -20,5 +29,9 @@ export declare function renderBrandHeader(theme: Theme, input: {
20
29
  };
21
30
  /** 当前目录为空时给可照抄的开场 prompt(见 starterLines)。 */
22
31
  starterTips?: boolean;
32
+ /** 启动目录不合适时的提醒(见 cwdAdvice)。 */
33
+ cwdWarning?: string;
34
+ /** 登录账号(config.email),跟在 cwd 后面;多账号/换机时一眼看到登的是谁。 */
35
+ account?: string;
23
36
  }): string[];
24
37
  export declare function printConsoleBanner(version: string): void;
package/dist/brand.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { resolve } from "node:path";
2
+ import { join, resolve, sep } from "node:path";
3
3
  export const BRAND_NAME = "u1s1";
4
4
  export const BRAND_CN = "有一说一";
5
5
  export const BRAND_TAGLINE = "说人话的 AI 编程搭子";
@@ -14,6 +14,24 @@ export function formatHomePath(path) {
14
14
  }
15
15
  return path;
16
16
  }
17
+ /**
18
+ * 启动目录不对时的一句提醒(工单 b70c901e:用户在 C:\\Users\\1\\.u1s1 里跑 u1s1)。
19
+ * 配置目录里放着登录凭据与设置,模型的文件工具会直接看到;用户主目录则会让模型
20
+ * 翻遍整个主目录,又慢又容易改错地方。都不是错误,只提醒换到项目文件夹。
21
+ */
22
+ export function cwdAdvice(cwd, dirs = {}) {
23
+ const home = resolve(dirs.home ?? homedir());
24
+ const configDir = resolve(dirs.configDir ?? join(home, ".u1s1"));
25
+ const here = resolve(cwd);
26
+ const within = (dir) => here === dir || here.startsWith(dir + sep);
27
+ if (within(configDir)) {
28
+ return "当前在 u1s1 的配置目录里(存着登录凭据和设置),模型的文件工具会直接看到它们;建议 /exit 后 cd 到你的项目文件夹再运行 u1s1";
29
+ }
30
+ if (here === home) {
31
+ return "当前在用户主目录,模型找文件会翻遍整个主目录,又慢又容易改错地方;建议 /exit 后 cd 到项目文件夹再运行 u1s1";
32
+ }
33
+ return undefined;
34
+ }
17
35
  // "u1s1" in FIGlet ANSI Shadow. Rows are joined per glyph so widths stay aligned.
18
36
  const GLYPH_U = ["██╗ ██╗", "██║ ██║", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "];
19
37
  const GLYPH_1 = [" ██╗", "███║", "╚██║", " ██║", " ██║", " ╚═╝"];
@@ -65,13 +83,16 @@ export function renderBrandHeader(theme, input) {
65
83
  const name = theme.bold(theme.fg("text", `${BRAND_NAME} v${input.version}`)) +
66
84
  (input.notice ? ` ${theme.fg("accent", input.notice)}` : "");
67
85
  const brand = theme.fg("muted", `${BRAND_CN} · ${BRAND_TAGLINE}`);
68
- const dir = theme.fg("dim", `cwd: ${formatHomePath(input.cwd)}`);
86
+ const dir = theme.fg("dim", `cwd: ${formatHomePath(input.cwd)}${input.account ? ` · 账号 ${input.account}` : ""}`);
69
87
  const hints = theme.fg("dim", "/help · Ctrl+V 图片 · Shift+Enter 换行 · Esc 中断");
70
88
  const starter = input.starterTips ? starterLines(theme) : [];
89
+ const warning = input.cwdWarning ? [` ${theme.fg("warning", "⚠")} ${theme.fg("warning", input.cwdWarning)}`] : [];
71
90
  if (input.width < ART_WIDTH + 4) {
72
91
  const lines = ["", ` ${theme.fg("accent", "✻")} ${name} ${theme.fg("muted", BRAND_CN)}`, ` ${dir}`, ` ${theme.fg("dim", "Ctrl+V 粘贴图片")}`];
73
92
  if (input.announcement)
74
93
  lines.push(` ${announcementLine(theme, input.announcement)}`);
94
+ if (warning.length)
95
+ lines.push("", ...warning);
75
96
  if (starter.length)
76
97
  lines.push("", ...starter);
77
98
  return [...lines, ""];
@@ -82,6 +103,8 @@ export function renderBrandHeader(theme, input) {
82
103
  const lines = ["", ...art, "", ` ${name} ${brand}`, ` ${dir}`, ` ${hints}`];
83
104
  if (input.announcement)
84
105
  lines.push(` ${announcementLine(theme, input.announcement)}`);
106
+ if (warning.length)
107
+ lines.push("", ...warning);
85
108
  if (starter.length)
86
109
  lines.push("", ...starter);
87
110
  return [...lines, ""];
@@ -95,6 +118,8 @@ export function renderBrandHeader(theme, input) {
95
118
  // 公告放信息列末尾(最后一行字模旁),够醒目又不挤掉常规信息
96
119
  if (input.announcement)
97
120
  rows[5] += `${gap}${announcementLine(theme, input.announcement)}`;
121
+ if (warning.length)
122
+ rows.push("", ...warning);
98
123
  if (starter.length)
99
124
  rows.push("", ...starter);
100
125
  return ["", ...rows, ""];
package/dist/config.d.ts CHANGED
@@ -142,6 +142,8 @@ export interface CliConfig {
142
142
  model?: string;
143
143
  /** preferred model 所属 provider;缺省 = u1s1(老配置兼容) */
144
144
  modelProvider?: string;
145
+ /** 登录时记下的账号邮箱,只用于横幅「账号」展示(真相以 u1s1 whoami 为准)。 */
146
+ email?: string;
145
147
  }
146
148
  export declare const u1s1Dir: string;
147
149
  /** pi keeps auth/models/settings/sessions under this dir — isolated from any real pi install. */
package/dist/config.js CHANGED
@@ -296,6 +296,7 @@ export function loadConfig() {
296
296
  // 有效性不在这里裁决:端点列表可能还没拉回来,交给 resolvePreferredModel
297
297
  model: file.model,
298
298
  modelProvider: typeof file.modelProvider === "string" ? file.modelProvider : undefined,
299
+ email: typeof file.email === "string" && file.email ? file.email : undefined,
299
300
  };
300
301
  }
301
302
  /**
@@ -12,8 +12,9 @@
12
12
  * 网关会在错误 JSON 里注入 error.request_id(见 gateway request-id.ts),
13
13
  * 拼进尾巴让用户报障时有编号可给,客服凭它直查日志和 usage 记录。
14
14
  *
15
- * 额度用尽(reason=exhausted)时网关还带 error.resets_at(免费池刷新时刻,ISO),
16
- * 这里换算成「距刷新还有 N 小时 M 分」接在正文后:比「北京时间 0 点」更省用户脑子。
15
+ * 额度用尽(reason=exhausted)且今天已打卡时网关还带 error.resets_at(下次可打卡时刻,
16
+ * 即北京时间 0 点,ISO),这里换算成「距下次可打卡还有 N 小时 M 分」接在正文后:比
17
+ * 「北京时间 0 点」更省用户脑子。今天没打卡时网关不带 resets_at(现在就能领)。
17
18
  */
18
19
  export declare function quotaResetHint(resetsAt: unknown, now?: number): string;
19
20
  /** 只取错误里的 request_id(纯函数,给 request-trace 记「最近一次请求编号」用)。 */
@@ -12,8 +12,9 @@
12
12
  * 网关会在错误 JSON 里注入 error.request_id(见 gateway request-id.ts),
13
13
  * 拼进尾巴让用户报障时有编号可给,客服凭它直查日志和 usage 记录。
14
14
  *
15
- * 额度用尽(reason=exhausted)时网关还带 error.resets_at(免费池刷新时刻,ISO),
16
- * 这里换算成「距刷新还有 N 小时 M 分」接在正文后:比「北京时间 0 点」更省用户脑子。
15
+ * 额度用尽(reason=exhausted)且今天已打卡时网关还带 error.resets_at(下次可打卡时刻,
16
+ * 即北京时间 0 点,ISO),这里换算成「距下次可打卡还有 N 小时 M 分」接在正文后:比
17
+ * 「北京时间 0 点」更省用户脑子。今天没打卡时网关不带 resets_at(现在就能领)。
17
18
  */
18
19
  export function quotaResetHint(resetsAt, now = Date.now()) {
19
20
  if (typeof resetsAt !== "string")
@@ -27,7 +28,7 @@ export function quotaResetHint(resetsAt, now = Date.now()) {
27
28
  const hours = Math.floor(remainingMinutes / 60);
28
29
  const minutes = remainingMinutes % 60;
29
30
  const span = hours > 0 ? `${hours} 小时${minutes > 0 ? ` ${minutes} 分` : ""}` : `${minutes} 分`;
30
- return `距免费额度刷新还有 ${span}`;
31
+ return `距下次可打卡领额度还有 ${span}`;
31
32
  }
32
33
  function parseErrorPayload(raw) {
33
34
  const jsonStart = raw.indexOf("{");
package/dist/feedback.js CHANGED
@@ -89,27 +89,35 @@ export async function submitFeedback(cfg, body) {
89
89
  throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
90
90
  }
91
91
  const value = await readJsonResponseCapped(resp, MAX_TICKET_RESPONSE_BYTES).catch(() => null);
92
- const data = value && typeof value === "object" && !Array.isArray(value)
93
- ? value
94
- : null;
95
- if (resp.status === 201 || resp.status === 200) {
96
- const id = typeof data?.id === "string" || typeof data?.id === "number" ? String(data.id) : "";
97
- const url = typeof data?.url === "string" && data.url.startsWith("https://") ? data.url : SUPPORT_URL;
98
- if (!id)
99
- return { ok: false, status: resp.status, message: "服务端工单响应格式不正确" };
100
- return { ok: true, id, url };
101
- }
92
+ const data = value && typeof value === "object" && !Array.isArray(value) ? value : null;
93
+ if (resp.status === 201 || resp.status === 200)
94
+ return createdOutcome(resp.status, data);
95
+ return {
96
+ ok: false,
97
+ status: resp.status,
98
+ message: failureMessage(resp.status, data),
99
+ retryAfterSeconds: retryAfterSeconds(resp, data),
100
+ };
101
+ }
102
+ function createdOutcome(status, data) {
103
+ const id = typeof data?.id === "string" || typeof data?.id === "number" ? String(data.id) : "";
104
+ if (!id)
105
+ return { ok: false, status, message: "服务端工单响应格式不正确" };
106
+ const url = typeof data?.url === "string" && data.url.startsWith("https://") ? data.url : SUPPORT_URL;
107
+ return { ok: true, id, url };
108
+ }
109
+ /** 优先 Retry-After 头,其次响应体里的 retry_after;都没有就不给等待时长。 */
110
+ function retryAfterSeconds(resp, data) {
102
111
  const header = Number(resp.headers.get("retry-after"));
112
+ if (Number.isFinite(header) && header > 0)
113
+ return header;
103
114
  const fromBody = Number(data?.error?.retry_after);
104
- const retryAfterSeconds = Number.isFinite(header) && header > 0
105
- ? header
106
- : Number.isFinite(fromBody) && fromBody > 0 ? fromBody : undefined;
107
- const message = typeof data?.error?.message === "string"
108
- ? data.error.message.slice(0, 500)
109
- : resp.status === 401
110
- ? "登录已失效,请重新运行 u1s1 login"
111
- : `服务端返回 ${resp.status},稍后再试`;
112
- return { ok: false, status: resp.status, message, retryAfterSeconds };
115
+ return Number.isFinite(fromBody) && fromBody > 0 ? fromBody : undefined;
116
+ }
117
+ function failureMessage(status, data) {
118
+ if (typeof data?.error?.message === "string")
119
+ return data.error.message.slice(0, 500);
120
+ return status === 401 ? "登录已失效,请重新运行 u1s1 login" : `服务端返回 ${status},稍后再试`;
113
121
  }
114
122
  /** 结果措辞,终端与会话内共用(每项一行)。 */
115
123
  export function feedbackOutcomeLines(outcome) {
@@ -1,12 +1,10 @@
1
- import { createInterface } from "node:readline/promises";
2
- import { stdin as input, stdout as output } from "node:process";
3
1
  import { join } from "node:path";
4
2
  import { formatHomePath } from "../brand.js";
5
3
  import { agentDir } from "../config.js";
6
4
  import { claudeAdapter, hydrateClaudeSession } from "./claude.js";
7
5
  import { codexAdapter, hydrateCodexSession } from "./codex.js";
8
6
  import { statSync } from "node:fs";
9
- import { asRecord, asString, formatBytes, oneLine, sessionBelongsToCwd } from "./util.js";
7
+ import { asRecord, asString, formatBytes, oneLine, sessionBelongsToCwd, confirm } from "./util.js";
10
8
  import { readJsonIfExists, writeConvertedSession, writeJson } from "./write.js";
11
9
  const ADAPTERS = {
12
10
  claude: claudeAdapter,
@@ -141,18 +139,6 @@ function printHelp() {
141
139
  console.log(" 导入后在对应项目里跑 u1s1,输入 /resume 就能看到。");
142
140
  console.log("");
143
141
  }
144
- async function confirm(question) {
145
- if (!input.isTTY || !output.isTTY)
146
- return true;
147
- const rl = createInterface({ input, output });
148
- try {
149
- const ans = (await rl.question(question)).trim().toLowerCase();
150
- return ans === "" || ans === "y" || ans === "yes" || ans === "是";
151
- }
152
- finally {
153
- rl.close();
154
- }
155
- }
156
142
  async function hydrateSession(session) {
157
143
  try {
158
144
  if (session.source === "claude")
@@ -1,12 +1,10 @@
1
- import { createInterface } from "node:readline/promises";
2
- import { stdin as input, stdout as output } from "node:process";
3
1
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
4
2
  import { homedir } from "node:os";
5
3
  import { basename, join, resolve } from "node:path";
6
4
  import { loadSkillsFromDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
7
5
  import { formatHomePath } from "../brand.js";
8
6
  import { agentDir } from "../config.js";
9
- import { asRecord, asString, formatBytes, listHomeClaudeDirs, oneLine, uniqueExistingDirs } from "./util.js";
7
+ import { asRecord, asString, formatBytes, listHomeClaudeDirs, oneLine, uniqueExistingDirs, confirm } from "./util.js";
10
8
  import { readJsonIfExists, writeJson } from "./write.js";
11
9
  const TOOL_LABEL = {
12
10
  claude: "Claude Code",
@@ -368,18 +366,6 @@ export function printSkillsHelp() {
368
366
  console.log(" 模型会按说明自动调用,也可以在会话里输入 /skill:名称 手动调用。");
369
367
  console.log("");
370
368
  }
371
- async function confirm(question) {
372
- if (!input.isTTY || !output.isTTY)
373
- return true;
374
- const rl = createInterface({ input, output });
375
- try {
376
- const ans = (await rl.question(question)).trim().toLowerCase();
377
- return ans === "" || ans === "y" || ans === "yes" || ans === "是";
378
- }
379
- finally {
380
- rl.close();
381
- }
382
- }
383
369
  function describeSource(skill) {
384
370
  return formatHomePath(skill.singleFile ? skill.skillFile : skill.baseDir);
385
371
  }
@@ -5,7 +5,6 @@ export declare function resolveExistingDir(path: string): string | undefined;
5
5
  export declare function listHomeClaudeDirs(home?: string): string[];
6
6
  export declare function uniqueExistingDirs(paths: Array<string | undefined>): string[];
7
7
  export declare function encodeClaudeProjectDir(cwd: string): string;
8
- export declare function samePath(a: string, b: string): boolean;
9
8
  /** Nearest git root at or above cwd. Stops at home so `~/` is never treated as a mega-project. */
10
9
  export declare function projectRoot(cwd: string): string | undefined;
11
10
  /** cwd + ancestors up to the git root (or just cwd when there is no repo). */
@@ -24,3 +23,5 @@ export declare function parseArgsJson(raw: string): Record<string, unknown>;
24
23
  export declare function fileMtimeMs(path: string): number;
25
24
  export declare function isProbablyInjection(text: string): boolean;
26
25
  export declare function readFirstJsonObject(path: string): Record<string, unknown> | undefined;
26
+ /** 交互式确认:回车/y/yes/是 视为同意;非 TTY(脚本、CI)直接放行,不阻塞。 */
27
+ export declare function confirm(question: string): Promise<boolean>;
@@ -1,3 +1,5 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
1
3
  import { closeSync, existsSync, openSync, readSync, readdirSync, realpathSync, statSync } from "node:fs";
2
4
  import { homedir } from "node:os";
3
5
  import { join } from "node:path";
@@ -46,11 +48,6 @@ export function encodeClaudeProjectDir(cwd) {
46
48
  const normalized = cwd.replace(/\\/g, "/");
47
49
  return normalized.replace(/[/:]/g, "-");
48
50
  }
49
- export function samePath(a, b) {
50
- const na = a.replace(/\\/g, "/").replace(/\/+$/, "");
51
- const nb = b.replace(/\\/g, "/").replace(/\/+$/, "");
52
- return na === nb;
53
- }
54
51
  function normPath(p) {
55
52
  return p.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
56
53
  }
@@ -228,3 +225,16 @@ export function readFirstJsonObject(path) {
228
225
  }
229
226
  }
230
227
  }
228
+ /** 交互式确认:回车/y/yes/是 视为同意;非 TTY(脚本、CI)直接放行,不阻塞。 */
229
+ export async function confirm(question) {
230
+ if (!input.isTTY || !output.isTTY)
231
+ return true;
232
+ const rl = createInterface({ input, output });
233
+ try {
234
+ const ans = (await rl.question(question)).trim().toLowerCase();
235
+ return ans === "" || ans === "y" || ans === "yes" || ans === "是";
236
+ }
237
+ finally {
238
+ rl.close();
239
+ }
240
+ }
package/dist/index.js CHANGED
@@ -518,144 +518,172 @@ async function runAgent(cfg, args) {
518
518
  await telemetry.shutdown();
519
519
  }
520
520
  }
521
- async function run() {
522
- const args = process.argv.slice(2);
523
- const cmd = args[0];
524
- if (cmd === "--version" || cmd === "-v") {
525
- console.log(`u1s1 v${VERSION}`);
521
+ function printHelp() {
522
+ printConsoleBanner(VERSION);
523
+ console.log(" 命令:");
524
+ console.log(" u1s1 进入对话界面(最常用,不带参数)");
525
+ console.log(" u1s1 -p \"一句话\" 不进界面直接回答,适合脚本里用");
526
+ console.log(" u1s1 login / logout 登录 / 退出登录");
527
+ console.log(" u1s1 model 查看或切换默认模型");
528
+ console.log(" u1s1 usage 查看剩余额度");
529
+ console.log(" u1s1 whoami 查看当前登录的账号");
530
+ console.log(" u1s1 update 升级到最新版");
531
+ console.log(" u1s1 deploy 发布网页(--public / --private)");
532
+ console.log(" u1s1 deploy list 查看已发布的站点");
533
+ console.log(" u1s1 deploy remove 删除已发布的站点");
534
+ console.log(" u1s1 feedback \"一句话\" 反馈问题或建议(自动建工单,--bug/--question…)");
535
+ console.log(" u1s1 import 导入历史会话(import skills 导入技能)");
536
+ console.log(" u1s1 mcp 配置第三方 MCP 服务(add/list/test/remove/import)");
537
+ console.log(" u1s1 bench 模型编码能力评测");
538
+ console.log(" u1s1 --version 查看版本");
539
+ console.log("");
540
+ console.log(" 对话里输入 /help 看会话内命令(/model /clear /feedback /exit …)");
541
+ console.log(" 匿名使用统计只记会话开始/结束等节点,不含内容;设 U1S1_TELEMETRY=0 关闭");
542
+ console.log(" 更多帮助 → https://u1s1.io/guides");
543
+ }
544
+ function wantsHelp(rest) {
545
+ return rest.includes("--help") || rest.includes("-h");
546
+ }
547
+ async function deployEntry(rest) {
548
+ const { deployCommand, printDeployHelp } = await import("./deploy.js");
549
+ // --help 不该先被拽去登录
550
+ if (wantsHelp(rest)) {
551
+ printDeployHelp();
526
552
  return;
527
553
  }
528
- if (cmd === "--help" || cmd === "-h" || cmd === "help") {
529
- printConsoleBanner(VERSION);
530
- console.log(" 命令:");
531
- console.log(" u1s1 进入对话界面(最常用,不带参数)");
532
- console.log(" u1s1 -p \"一句话\" 不进界面直接回答,适合脚本里用");
533
- console.log(" u1s1 login / logout 登录 / 退出登录");
534
- console.log(" u1s1 model 查看或切换默认模型");
535
- console.log(" u1s1 usage 查看剩余额度");
536
- console.log(" u1s1 update 升级到最新版");
537
- console.log(" u1s1 deploy 发布网页(--public / --private)");
538
- console.log(" u1s1 deploy list 查看已发布的站点");
539
- console.log(" u1s1 deploy remove 删除已发布的站点");
540
- console.log(" u1s1 feedback \"一句话\" 反馈问题或建议(自动建工单,--bug/--question…)");
541
- console.log(" u1s1 import 导入历史会话(import skills 导入技能)");
542
- console.log(" u1s1 mcp 配置第三方 MCP 服务(add/list/test/remove/import)");
543
- console.log(" u1s1 bench 模型编码能力评测");
544
- console.log(" u1s1 --version 查看版本");
545
- console.log("");
546
- console.log(" 对话里输入 /help 看会话内命令(/model /clear /feedback /exit …)");
547
- console.log(" 匿名使用统计只记会话开始/结束等节点,不含内容;设 U1S1_TELEMETRY=0 关闭");
548
- console.log(" 更多帮助 → https://u1s1.io/guides");
549
- return;
554
+ const { ensureAuth } = await import("./login.js");
555
+ const cfg = await ensureAuth();
556
+ await deployCommand(cfg, rest);
557
+ }
558
+ async function whoamiCommand() {
559
+ // 多账号/换机时最常见的问题是「我现在登的是哪个号」,usage 里那一行埋得太深
560
+ const { hasDeviceCredential } = await import("./device-auth.js");
561
+ const cfg = loadConfig();
562
+ if (!hasDeviceCredential(cfg)) {
563
+ console.error("还没登录,先跑 u1s1 login");
564
+ process.exit(1);
550
565
  }
551
- if (cmd === "web") {
552
- console.error("u1s1 web 已下线,请使用 Desktop App:https://u1s1.io/#download");
553
- process.exitCode = 1;
566
+ const { fetchMe } = await import("./api.js");
567
+ const me = await fetchMe(cfg).catch((e) => {
568
+ console.error(e.message);
569
+ process.exit(1);
570
+ });
571
+ console.log(me.email ?? "(未绑定邮箱)");
572
+ console.log(` 服务器 ${cfg.baseUrl}`);
573
+ console.log(" 换账号:u1s1 logout 再 u1s1 login;看额度:u1s1 usage");
574
+ }
575
+ async function logoutCommand() {
576
+ const { saveConfig } = await import("./config.js");
577
+ const current = loadConfig();
578
+ if (!current.deviceToken && !current.apiKey) {
579
+ console.log("当前本来就没有登录。运行 u1s1 即可登录。");
554
580
  return;
555
581
  }
556
- // `u1s1 login --help` 不该真的去登录、`logout --help` 不该真的退出:先看是不是在问用法
557
- if (cmd && SIMPLE_COMMAND_HELP[cmd] && (args[1] === "--help" || args[1] === "-h")) {
558
- for (const line of SIMPLE_COMMAND_HELP[cmd])
559
- console.log(line);
560
- return;
582
+ let serverRevoked = false;
583
+ if (current.deviceToken) {
584
+ const { authorizedFetch } = await import("./device-auth.js");
585
+ const resp = await authorizedFetch(current, `${current.baseUrl}/device`, { method: "DELETE" }).catch(() => null);
586
+ serverRevoked = resp?.ok ?? false;
561
587
  }
562
- if (cmd === "deploy") {
563
- const { deployCommand, printDeployHelp } = await import("./deploy.js");
564
- // --help 不该先被拽去登录
565
- if (args.slice(1).includes("--help") || args.slice(1).includes("-h")) {
566
- printDeployHelp();
567
- return;
568
- }
569
- const { ensureAuth } = await import("./login.js");
570
- const cfg = await ensureAuth();
571
- await deployCommand(cfg, args.slice(1));
572
- return;
588
+ saveConfig({
589
+ ...current,
590
+ apiKey: undefined,
591
+ deviceToken: undefined,
592
+ deviceId: undefined,
593
+ devicePrivateJwk: undefined,
594
+ devicePublicJwk: undefined,
595
+ email: undefined,
596
+ });
597
+ if (serverRevoked) {
598
+ console.log("已退出登录,这台设备在服务端的授权也已注销。");
573
599
  }
574
- if (cmd === "login") {
575
- const { login } = await import("./login.js");
576
- await login(args[1]);
577
- return;
600
+ else {
601
+ console.log("已退出本机登录。");
602
+ console.log("(没连上服务器注销这台设备;如有需要,可在 https://u1s1.io/dashboard 的设备列表里手动移除。)");
578
603
  }
579
- if (cmd === "usage") {
604
+ }
605
+ /** 子命令表:每条都按需 import,不带子命令直接进对话界面的路径不多加载任何模块。 */
606
+ const COMMANDS = {
607
+ deploy: deployEntry,
608
+ login: async (rest) => {
609
+ const { login } = await import("./login.js");
610
+ await login(rest[0]);
611
+ },
612
+ logout: logoutCommand,
613
+ usage: async () => {
580
614
  const { usage } = await import("./usage.js");
581
615
  await usage();
582
- return;
583
- }
584
- if (cmd === "feedback") {
616
+ },
617
+ whoami: whoamiCommand,
618
+ feedback: async (rest) => {
585
619
  const { feedbackCommand } = await import("./feedback.js");
586
- await feedbackCommand(args.slice(1));
587
- return;
588
- }
589
- if (cmd === "mcp") {
620
+ await feedbackCommand(rest);
621
+ },
622
+ mcp: async (rest) => {
590
623
  const { mcpCommand } = await import("./mcp/command.js");
591
- await mcpCommand(args.slice(1));
592
- return;
593
- }
594
- if (cmd === "model") {
624
+ await mcpCommand(rest);
625
+ },
626
+ model: async (rest) => {
595
627
  const { modelCommand } = await import("./model.js");
596
- await modelCommand(args[1]);
597
- return;
598
- }
599
- if (cmd === "logout") {
600
- const { saveConfig } = await import("./config.js");
601
- const current = loadConfig();
602
- if (!current.deviceToken && !current.apiKey) {
603
- console.log("当前本来就没有登录。运行 u1s1 即可登录。");
604
- return;
605
- }
606
- let serverRevoked = false;
607
- if (current.deviceToken) {
608
- const { authorizedFetch } = await import("./device-auth.js");
609
- const resp = await authorizedFetch(current, `${current.baseUrl}/device`, { method: "DELETE" }).catch(() => null);
610
- serverRevoked = resp?.ok ?? false;
611
- }
612
- saveConfig({
613
- ...current,
614
- apiKey: undefined,
615
- deviceToken: undefined,
616
- deviceId: undefined,
617
- devicePrivateJwk: undefined,
618
- devicePublicJwk: undefined,
619
- });
620
- if (serverRevoked) {
621
- console.log("已退出登录,这台设备在服务端的授权也已注销。");
622
- }
623
- else {
624
- console.log("已退出本机登录。");
625
- console.log("(没连上服务器注销这台设备;如有需要,可在 https://u1s1.io/dashboard 的设备列表里手动移除。)");
626
- }
627
- return;
628
- }
629
- if (cmd === "update") {
628
+ await modelCommand(rest[0]);
629
+ },
630
+ update: async () => {
630
631
  const { update } = await import("./update.js");
631
632
  await update();
633
+ },
634
+ import: async (rest) => {
635
+ const { importCommand } = await import("./import/index.js");
636
+ await importCommand(rest);
637
+ },
638
+ bench: async (rest) => {
639
+ const { benchCommand } = await import("./bench.js");
640
+ await benchCommand(rest);
641
+ },
642
+ web: async () => {
643
+ console.error("u1s1 web 已下线,请使用 Desktop App:https://u1s1.io/#download");
644
+ process.exitCode = 1;
645
+ },
646
+ };
647
+ /**
648
+ * 拼错的子命令(如 `u1s1 depoly`)不该被静默当成第一条消息发给模型白烧 token。
649
+ * 只拦「单个短英文词 + 与已知命令编辑距离很近」的情况,正常 prompt 不受影响;
650
+ * `u1s1 delpoy list` 这类带参数的拼错也拦;长句(≥4 个词)才当 prompt 放行。
651
+ */
652
+ function rejectMistypedCommand(cmd, argCount) {
653
+ if (argCount > 3 || !/^[a-z][a-z0-9-]{1,15}$/.test(cmd) || KNOWN_COMMANDS.includes(cmd))
654
+ return false;
655
+ const near = KNOWN_COMMANDS.find((k) => editDistance(k, cmd) <= (k.length <= 4 ? 1 : 2));
656
+ if (!near)
657
+ return false;
658
+ console.error(`不认识的命令「${cmd}」,你是不是想运行:u1s1 ${near}`);
659
+ console.error("查看全部命令:u1s1 --help;直接开始对话:运行 u1s1(不带参数)");
660
+ process.exitCode = 1;
661
+ return true;
662
+ }
663
+ async function run() {
664
+ const args = process.argv.slice(2);
665
+ const cmd = args[0];
666
+ const rest = args.slice(1);
667
+ if (cmd === "--version" || cmd === "-v") {
668
+ console.log(`u1s1 v${VERSION}`);
632
669
  return;
633
670
  }
634
- if (cmd === "import") {
635
- const { importCommand } = await import("./import/index.js");
636
- await importCommand(args.slice(1));
671
+ if (cmd === "--help" || cmd === "-h" || cmd === "help") {
672
+ printHelp();
637
673
  return;
638
674
  }
639
- if (cmd === "bench") {
640
- const { benchCommand } = await import("./bench.js");
641
- await benchCommand(args.slice(1));
675
+ // `u1s1 login --help` 不该真的去登录、`logout --help` 不该真的退出:先看是不是在问用法
676
+ if (cmd && Object.hasOwn(SIMPLE_COMMAND_HELP, cmd) && wantsHelp(rest)) {
677
+ for (const line of SIMPLE_COMMAND_HELP[cmd])
678
+ console.log(line);
642
679
  return;
643
680
  }
644
- // 拼错的子命令( `u1s1 depoly`)不该被静默当成第一条消息发给模型白烧 token。
645
- // 只拦「单个短英文词 + 与已知命令编辑距离很近」的情况,正常 prompt 不受影响。
646
- // `u1s1 delpoy list` 这类带参数的拼错也拦;长句(≥4 个词)才当 prompt 放行
647
- if (cmd
648
- && args.length <= 3
649
- && /^[a-z][a-z0-9-]{1,15}$/.test(cmd)
650
- && !KNOWN_COMMANDS.includes(cmd)) {
651
- const near = KNOWN_COMMANDS.find((k) => editDistance(k, cmd) <= (k.length <= 4 ? 1 : 2));
652
- if (near) {
653
- console.error(`不认识的命令「${cmd}」,你是不是想运行:u1s1 ${near}`);
654
- console.error("查看全部命令:u1s1 --help;直接开始对话:运行 u1s1(不带参数)");
655
- process.exitCode = 1;
656
- return;
657
- }
681
+ if (cmd && Object.hasOwn(COMMANDS, cmd)) {
682
+ await COMMANDS[cmd](rest);
683
+ return;
658
684
  }
685
+ if (cmd && rejectMistypedCommand(cmd, args.length))
686
+ return;
659
687
  const { ensureAuth } = await import("./login.js");
660
688
  const cfg = await ensureAuth();
661
689
  // 在后台检查更新(非阻塞,不影响启动速度)
@@ -664,7 +692,7 @@ async function run() {
664
692
  // autoUpdate 开着且启动时发现了新版:现在装(TUI 已退出,原地重铺安全)
665
693
  installPendingUpdate();
666
694
  }
667
- const KNOWN_COMMANDS = ["deploy", "login", "logout", "usage", "model", "update", "import", "mcp", "bench", "feedback", "help"];
695
+ const KNOWN_COMMANDS = ["deploy", "login", "logout", "usage", "whoami", "model", "update", "import", "mcp", "bench", "feedback", "help"];
668
696
  /** 没有自己 --help 处理的简单子命令:问用法时只打印说明,不执行任何动作。 */
669
697
  const SIMPLE_COMMAND_HELP = {
670
698
  login: [
@@ -680,6 +708,10 @@ const SIMPLE_COMMAND_HELP = {
680
708
  "用法: u1s1 usage",
681
709
  " 查看剩余额度:免费包、已购用量包与余额。会话内也可输入 /usage。",
682
710
  ],
711
+ whoami: [
712
+ "用法: u1s1 whoami",
713
+ " 打印当前登录的账号邮箱和服务器地址;换账号先 u1s1 logout 再 u1s1 login。",
714
+ ],
683
715
  update: [
684
716
  "用法: u1s1 update",
685
717
  " 检查并升级到最新版;npm 安装的走 npm,便携版原地自更新。",
package/dist/login.js CHANGED
@@ -228,7 +228,10 @@ export async function login(keyArg) {
228
228
  quotaNote = `今日免费还剩 $${me.daily_free_remaining_usd.toFixed(2)},永久余额 $${me.remaining_usd.toFixed(2)}`;
229
229
  }
230
230
  console.log(` ✓ 登录成功${me.email ? `(${me.email})` : ""},${quotaNote}。`);
231
- return next;
231
+ // 邮箱记进 config,启动横幅直接显示「账号」,不用再问「我现在登的是哪个号」
232
+ const withAccount = { ...next, email: me.email ?? undefined };
233
+ saveConfig(withAccount);
234
+ return withAccount;
232
235
  }
233
236
  /** Returns a config with a browser-approved, sender-constrained device credential. */
234
237
  export async function ensureAuth() {
@@ -315,36 +315,33 @@ class HttpTransport {
315
315
  /** 把 tools/call 返回的 content 数组压成模型可读的文本 */
316
316
  export function contentToText(result) {
317
317
  const rec = asRecord(result) ?? {};
318
- const parts = [];
319
318
  const content = Array.isArray(rec["content"]) ? rec["content"] : [];
320
- for (const raw of content) {
321
- const item = asRecord(raw);
322
- if (!item)
323
- continue;
324
- const type = item["type"];
325
- if (type === "text" && typeof item["text"] === "string") {
326
- parts.push(item["text"]);
327
- }
328
- else if (type === "image" || type === "audio") {
329
- const data = typeof item["data"] === "string" ? item["data"] : "";
330
- parts.push(`[${type} ${String(item["mimeType"] ?? "")} ${Buffer.from(data, "base64").length} bytes, omitted]`);
331
- }
332
- else if (type === "resource") {
333
- const res = asRecord(item["resource"]);
334
- if (res && typeof res["text"] === "string")
335
- parts.push(res["text"]);
336
- else
337
- parts.push(`[resource ${String(res?.["uri"] ?? "")} (binary, omitted)]`);
338
- }
339
- else if (type === "resource_link") {
340
- parts.push(`[link] ${String(item["uri"] ?? "")}${item["name"] ? ` ${String(item["name"])}` : ""}`);
341
- }
342
- }
319
+ const parts = content.map(contentPartText).filter((part) => part !== null);
343
320
  if (parts.length === 0 && rec["structuredContent"] !== undefined) {
344
321
  parts.push(JSON.stringify(rec["structuredContent"], null, 2));
345
322
  }
346
323
  return { text: parts.join("\n").trim(), isError: rec["isError"] === true };
347
324
  }
325
+ /** 单个 content 项的文本表示:文本原样,二进制只留类型与大小,未知类型忽略。 */
326
+ function contentPartText(raw) {
327
+ const item = asRecord(raw);
328
+ if (!item)
329
+ return null;
330
+ const type = item["type"];
331
+ if (type === "text")
332
+ return typeof item["text"] === "string" ? item["text"] : null;
333
+ if (type === "image" || type === "audio") {
334
+ const data = typeof item["data"] === "string" ? item["data"] : "";
335
+ return `[${type} ${String(item["mimeType"] ?? "")} ${Buffer.from(data, "base64").length} bytes, omitted]`;
336
+ }
337
+ if (type === "resource") {
338
+ const res = asRecord(item["resource"]);
339
+ return res && typeof res["text"] === "string" ? res["text"] : `[resource ${String(res?.["uri"] ?? "")} (binary, omitted)]`;
340
+ }
341
+ if (type === "resource_link")
342
+ return `[link] ${String(item["uri"] ?? "")}${item["name"] ? ` ${String(item["name"])}` : ""}`;
343
+ return null;
344
+ }
348
345
  export function normalizeToolInfo(raw) {
349
346
  const rec = asRecord(raw);
350
347
  if (!rec || typeof rec["name"] !== "string" || !rec["name"])
@@ -75,74 +75,82 @@ function dropCache(name) {
75
75
  delete cache.servers[name];
76
76
  saveMcpToolCache(cache);
77
77
  }
78
+ /** `--flag=value` 直接取值;`--flag value` 消费下一个参数并推进游标。 */
79
+ function optionValue(arg, inlinePrefix, args, cursor) {
80
+ return arg.startsWith(inlinePrefix) ? arg.slice(inlinePrefix.length) : args[++cursor.i];
81
+ }
82
+ function applyAddOption(arg, args, cursor, opts) {
83
+ if (arg === "--force" || arg === "-f") {
84
+ opts.force = true;
85
+ }
86
+ else if (arg === "--url" || arg.startsWith("--url=")) {
87
+ opts.url = optionValue(arg, "--url=", args, cursor);
88
+ if (!opts.url)
89
+ fail("--url 后面要跟地址");
90
+ }
91
+ else if (arg === "--env" || arg === "-e" || arg.startsWith("--env=")) {
92
+ const raw = optionValue(arg, "--env=", args, cursor);
93
+ const kv = raw ? parseKeyValue(raw, "=") : undefined;
94
+ if (!kv)
95
+ fail(`--env 要写成 KEY=VALUE:${raw ?? ""}`);
96
+ opts.env[kv[0]] = kv[1];
97
+ }
98
+ else if (arg === "--header" || arg === "-H" || arg.startsWith("--header=")) {
99
+ const raw = optionValue(arg, "--header=", args, cursor);
100
+ const kv = raw ? parseKeyValue(raw, ":") : undefined;
101
+ if (!kv)
102
+ fail(`--header 要写成 "Name: value":${raw ?? ""}`);
103
+ opts.headers[kv[0]] = kv[1];
104
+ }
105
+ else if (arg.startsWith("-") && arg.length > 1) {
106
+ fail(`不认识的选项 ${arg}(命令自己的参数请放在命令后面,或用 -- 隔开)`);
107
+ }
108
+ else {
109
+ opts.command = arg;
110
+ }
111
+ }
112
+ /** --url 走 HTTP 型(只认 --header),否则命令型(只认 --env);两者互斥。 */
113
+ function serverFromAddOptions(name, opts) {
114
+ const { url, command, force } = opts;
115
+ if (url && command)
116
+ fail("--url 和命令只能二选一");
117
+ if (url) {
118
+ if (!/^https?:\/\//i.test(url))
119
+ fail(`--url 必须是 http(s) 地址:${url}`);
120
+ if (Object.keys(opts.env).length)
121
+ fail("HTTP 型服务不支持 --env,鉴权请用 --header");
122
+ return { name, server: { type: "http", url, headers: opts.headers }, force };
123
+ }
124
+ if (!command)
125
+ fail("请给出启动命令(如 npx -y some-mcp-server)或 --url 地址");
126
+ if (Object.keys(opts.headers).length)
127
+ fail("命令型服务不支持 --header,请用 --env 传密钥");
128
+ return { name, server: { type: "stdio", command, args: opts.commandArgs, env: opts.env }, force };
129
+ }
78
130
  export function parseAddArgs(args) {
79
131
  const name = args[0];
80
132
  if (!name || name.startsWith("-"))
81
133
  fail("请给服务起个名字,例:u1s1 mcp add tavily npx -y tavily-mcp");
82
134
  if (!MCP_SERVER_NAME_RE.test(name))
83
135
  fail(`服务名只能用小写字母、数字、- 和 _,且以字母或数字开头(最长 32 位):${name}`);
84
- let url;
85
- const env = {};
86
- const headers = {};
87
- let force = false;
88
- let command;
89
- const commandArgs = [];
90
- for (let i = 1; i < args.length; i++) {
91
- const a = args[i];
92
- if (command !== undefined) {
93
- commandArgs.push(a);
136
+ const opts = { commandArgs: [], env: {}, headers: {}, force: false };
137
+ const cursor = { i: 1 };
138
+ for (; cursor.i < args.length; cursor.i++) {
139
+ const a = args[cursor.i];
140
+ if (opts.command !== undefined) {
141
+ opts.commandArgs.push(a);
94
142
  continue;
95
143
  }
96
144
  if (a === "--") {
97
- command = args[i + 1];
98
- commandArgs.push(...args.slice(i + 2));
99
- if (command === undefined)
145
+ opts.command = args[cursor.i + 1];
146
+ opts.commandArgs.push(...args.slice(cursor.i + 2));
147
+ if (opts.command === undefined)
100
148
  fail("-- 后面要跟命令");
101
149
  break;
102
150
  }
103
- if (a === "--force" || a === "-f") {
104
- force = true;
105
- }
106
- else if (a === "--url" || a.startsWith("--url=")) {
107
- url = a.includes("=") ? a.slice(6) : args[++i];
108
- if (!url)
109
- fail("--url 后面要跟地址");
110
- }
111
- else if (a === "--env" || a === "-e" || a.startsWith("--env=")) {
112
- const raw = a.includes("=") && a.startsWith("--env=") ? a.slice(6) : args[++i];
113
- const kv = raw ? parseKeyValue(raw, "=") : undefined;
114
- if (!kv)
115
- fail(`--env 要写成 KEY=VALUE:${raw ?? ""}`);
116
- env[kv[0]] = kv[1];
117
- }
118
- else if (a === "--header" || a === "-H" || a.startsWith("--header=")) {
119
- const raw = a.startsWith("--header=") ? a.slice(9) : args[++i];
120
- const kv = raw ? parseKeyValue(raw, ":") : undefined;
121
- if (!kv)
122
- fail(`--header 要写成 "Name: value":${raw ?? ""}`);
123
- headers[kv[0]] = kv[1];
124
- }
125
- else if (a.startsWith("-") && a.length > 1) {
126
- fail(`不认识的选项 ${a}(命令自己的参数请放在命令后面,或用 -- 隔开)`);
127
- }
128
- else {
129
- command = a;
130
- }
131
- }
132
- if (url && command)
133
- fail("--url 和命令只能二选一");
134
- if (url) {
135
- if (!/^https?:\/\//i.test(url))
136
- fail(`--url 必须是 http(s) 地址:${url}`);
137
- if (Object.keys(env).length)
138
- fail("HTTP 型服务不支持 --env,鉴权请用 --header");
139
- return { name, server: { type: "http", url, headers }, force };
151
+ applyAddOption(a, args, cursor, opts);
140
152
  }
141
- if (!command)
142
- fail("请给出启动命令(如 npx -y some-mcp-server)或 --url 地址");
143
- if (Object.keys(headers).length)
144
- fail("命令型服务不支持 --header,请用 --env 传密钥");
145
- return { name, server: { type: "stdio", command, args: commandArgs, env }, force };
153
+ return serverFromAddOptions(name, opts);
146
154
  }
147
155
  async function addCommand(args) {
148
156
  const { name, server, force } = parseAddArgs(args);
package/dist/style.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { readdirSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui";
4
- import { readSettings } from "./config.js";
5
- import { renderBrandHeader } from "./brand.js";
4
+ import { loadConfig, readSettings } from "./config.js";
5
+ import { cwdAdvice, renderBrandHeader } from "./brand.js";
6
6
  /**
7
7
  * 启动横幅版本号后面的升级状态(如「发现新版 v0.9.3,自动更新中…」)。
8
8
  * checkAndAutoUpdate 异步写入;横幅已挂载时主动触发一次重绘,否则等首帧渲染。
@@ -36,6 +36,17 @@ export function applyBrandUi(pi, version) {
36
36
  return false;
37
37
  }
38
38
  })();
39
+ // 在配置目录 / 用户主目录里启动:横幅下提醒换到项目文件夹
40
+ const cwdWarning = cwdAdvice(process.cwd());
41
+ // 登录账号:login 时记进 config.email;/clear 重新 session_start 也会重读
42
+ const account = (() => {
43
+ try {
44
+ return loadConfig().email;
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ })();
39
50
  // 检查设置:关掉就不显示启动横幅
40
51
  const settings = readSettings();
41
52
  if (settings.showStartupBanner !== false) {
@@ -51,6 +62,8 @@ export function applyBrandUi(pi, version) {
51
62
  notice: updateNotice,
52
63
  announcement,
53
64
  starterTips,
65
+ cwdWarning,
66
+ account,
54
67
  }).map((line) => truncateToWidth(line, width));
55
68
  },
56
69
  invalidate() { },
package/dist/tools.d.ts CHANGED
@@ -3,6 +3,17 @@ import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import type { CliConfig } from "./config.js";
5
5
  export declare function truncate(text: string): string;
6
+ /**
7
+ * 长网页按 30k 字符开窗返回:光截断而不给「怎么读下一段」,模型会拿前 30k 硬答
8
+ * (工单「内置搜索动不动就截断」)。尾注写明本窗范围和下一次该传的 offset。
9
+ */
10
+ export declare function windowText(text: string, offset: number): {
11
+ text: string;
12
+ truncated: boolean;
13
+ nextOffset: number | null;
14
+ };
15
+ /** 测试用:清掉进程内页面缓存。 */
16
+ export declare function resetPageCacheForTests(): void;
6
17
  /** 只下载前 maxBytes 就断流,超大页面不用整个拉完再丢。 */
7
18
  export declare function readBodyCapped(resp: Response, maxBytes: number): Promise<Uint8Array>;
8
19
  /** 联网工具通用渲染:调用行不占位,收起时只显一行摘要,ctrl+o 展开全文,出错显一行 ✗ */
@@ -75,4 +86,5 @@ export interface FetchToolConfig {
75
86
  */
76
87
  export declare function createFetchTool(cfg: FetchToolConfig): import("@earendil-works/pi-coding-agent").ToolDefinition<Type.TObject<{
77
88
  url: Type.TString;
89
+ offset: Type.TOptional<Type.TNumber>;
78
90
  }>, unknown, any> & import("@earendil-works/pi-coding-agent").ToolDefinition<any, any, any>;
package/dist/tools.js CHANGED
@@ -40,6 +40,50 @@ export function truncate(text) {
40
40
  return text;
41
41
  return `${text.slice(0, MAX_TEXT_CHARS)}\n\n…(内容过长已截断,共 ${text.length} 字符)`;
42
42
  }
43
+ /**
44
+ * 长网页按 30k 字符开窗返回:光截断而不给「怎么读下一段」,模型会拿前 30k 硬答
45
+ * (工单「内置搜索动不动就截断」)。尾注写明本窗范围和下一次该传的 offset。
46
+ */
47
+ export function windowText(text, offset) {
48
+ const start = Math.min(Math.max(0, Math.floor(offset || 0)), text.length);
49
+ const end = Math.min(start + MAX_TEXT_CHARS, text.length);
50
+ const body = text.slice(start, end);
51
+ if (start === 0 && end === text.length)
52
+ return { text: body, truncated: false, nextOffset: null };
53
+ const head = start > 0 ? `(从第 ${start} 字符起继续)\n\n` : "";
54
+ const tail = end < text.length
55
+ ? `\n\n…(内容过长已截断:本次为第 ${start}–${end} 字符,共 ${text.length} 字符;要继续读取,用同一 url 再调一次 web_fetch 并传 offset=${end})`
56
+ : `\n\n(已到末尾:第 ${start}–${end} 字符,共 ${text.length} 字符)`;
57
+ return { text: `${head}${body}${tail}`, truncated: end < text.length, nextOffset: end < text.length ? end : null };
58
+ }
59
+ /** 同一页面带 offset 续读时直接命中,不再重新抓取(云端渲染是按次计费的)。 */
60
+ const PAGE_CACHE_MAX = 8;
61
+ const PAGE_CACHE_TTL_MS = 10 * 60_000;
62
+ const pageCache = new Map();
63
+ function rememberPage(url, entry) {
64
+ pageCache.delete(url);
65
+ pageCache.set(url, { ...entry, at: Date.now() });
66
+ while (pageCache.size > PAGE_CACHE_MAX) {
67
+ const oldest = pageCache.keys().next().value;
68
+ if (oldest === undefined)
69
+ break;
70
+ pageCache.delete(oldest);
71
+ }
72
+ }
73
+ function recallPage(url) {
74
+ const hit = pageCache.get(url);
75
+ if (!hit)
76
+ return null;
77
+ if (Date.now() - hit.at > PAGE_CACHE_TTL_MS) {
78
+ pageCache.delete(url);
79
+ return null;
80
+ }
81
+ return hit;
82
+ }
83
+ /** 测试用:清掉进程内页面缓存。 */
84
+ export function resetPageCacheForTests() {
85
+ pageCache.clear();
86
+ }
43
87
  /** 只下载前 maxBytes 就断流,超大页面不用整个拉完再丢。 */
44
88
  export async function readBodyCapped(resp, maxBytes) {
45
89
  if (!resp.body)
@@ -109,6 +153,8 @@ export function compactResultRender(input) {
109
153
  const text = resultText(result);
110
154
  return new Text(text, 0, 0);
111
155
  }
156
+ /** 网关 search.ts 的 MAX_DOUBAO_QUERY_CHARS:豆包 Query 超 100 字符服务端自行截断。 */
157
+ const SEARCH_QUERY_MAX_CHARS = 100;
112
158
  /** 联网搜索工具:走 u1s1 网关代理,上游 key 不落到用户机器上。 */
113
159
  export function createSearchTool(cfg) {
114
160
  return defineTool({
@@ -121,7 +167,9 @@ export function createSearchTool(cfg) {
121
167
  "Prefer one focused web_search query over several near-duplicate queries; each search costs the user credits.",
122
168
  ],
123
169
  parameters: Type.Object({
124
- query: Type.String({ description: "Search query. Be specific; keep it under ~15 words." }),
170
+ query: Type.String({
171
+ description: "Search query. Be specific; keep it under ~15 words / 100 characters (the search engine only reads the first 100).",
172
+ }),
125
173
  maxResults: Type.Optional(Type.Number({ description: "How many results to return (1-10, default 5).", minimum: 1, maximum: 10 })),
126
174
  }),
127
175
  // 精简展示:收起时一行摘要(🔍 搜索 "…" · N 条结果),ctrl+o 展开全文
@@ -139,6 +187,10 @@ export function createSearchTool(cfg) {
139
187
  async execute(_toolCallId, params, signal) {
140
188
  const data = await searchWeb(cfg, { query: params.query, maxResults: params.maxResults, signal });
141
189
  const lines = [];
190
+ // 上游(豆包)只按前 100 字符检索,长句子会被悄悄砍掉后半段;提醒模型下次改短
191
+ if (params.query.length > SEARCH_QUERY_MAX_CHARS) {
192
+ lines.push(`Note: the query was ${params.query.length} characters; the search engine only used the first ${SEARCH_QUERY_MAX_CHARS}. Retry with a shorter keyword query if the results look off.`, "");
193
+ }
142
194
  if (data.answer)
143
195
  lines.push(`Answer: ${data.answer}`, "");
144
196
  if (data.results.length === 0) {
@@ -529,13 +581,17 @@ export function createFetchTool(cfg) {
529
581
  return defineTool({
530
582
  name: "web_fetch",
531
583
  label: "读取网页",
532
- description: "Fetch a URL and return its readable text content (HTML is stripped to text; JSON and plain text are returned as-is). Use it to read a page found via web_search, or any URL the user pasted. If the direct fetch fails or the page needs JavaScript, it automatically retries through a cloud headless browser.",
584
+ description: "Fetch a URL and return its readable text content (HTML is stripped to text; JSON and plain text are returned as-is). Use it to read a page found via web_search, or any URL the user pasted. If the direct fetch fails or the page needs JavaScript, it automatically retries through a cloud headless browser. Long pages are returned in 30,000-character windows: when the result says it was truncated, call again with the same url and the offset it gives to read the next part.",
533
585
  promptSnippet: "抓取指定网址并转成可读文本",
534
586
  promptGuidelines: [
535
587
  "Use web_fetch to read a specific URL, and web_search when you still need to find the URL.",
536
588
  ],
537
589
  parameters: Type.Object({
538
590
  url: Type.String({ description: "Absolute http(s) URL to fetch." }),
591
+ offset: Type.Optional(Type.Number({
592
+ description: "Character offset to start from (default 0). Use the offset given at the end of a truncated result to continue reading the same page.",
593
+ minimum: 0,
594
+ })),
539
595
  }),
540
596
  // 精简展示:收起时一行摘要(🌐 url · N 字符 · ☁ 渲染),ctrl+o 展开全文
541
597
  renderShell: "self",
@@ -547,8 +603,9 @@ export function createFetchTool(cfg) {
547
603
  const d = result.details;
548
604
  const u = previewLine(d?.url ?? "…", 90);
549
605
  const n = typeof d?.chars === "number" ? ` · ${d.chars} 字符` : "";
606
+ const win = d?.offset ? ` · 从第 ${d.offset} 字符续读` : d?.truncated ? " · 只读了开头" : "";
550
607
  const cloud = d?.via === "render" ? " · ☁ 浏览器渲染" : "";
551
- return compactResultRender({ result, options, theme, context, summaryLine: `🌐 ${u}${n}${cloud}` });
608
+ return compactResultRender({ result, options, theme, context, summaryLine: `🌐 ${u}${n}${win}${cloud}` });
552
609
  },
553
610
  async execute(_toolCallId, params, signal) {
554
611
  let url;
@@ -562,10 +619,19 @@ export function createFetchTool(cfg) {
562
619
  if (url.protocol !== "http:" && url.protocol !== "https:") {
563
620
  throw new Error(`只支持 http/https,收到 ${url.protocol}`);
564
621
  }
565
- const ok = (text, via, contentType) => ({
566
- content: [{ type: "text", text: truncate(`# ${url.href}\n\n${text || "(空白页面)"}`) }],
567
- details: { url: url.href, contentType, via, chars: text.length },
568
- });
622
+ const offset = Math.max(0, Math.floor(params.offset ?? 0));
623
+ const ok = (text, via, contentType) => {
624
+ rememberPage(url.href, { text, via, contentType });
625
+ const win = windowText(text || "(空白页面)", offset);
626
+ return {
627
+ content: [{ type: "text", text: `# ${url.href}\n\n${win.text}` }],
628
+ details: { url: url.href, contentType, via, chars: text.length, offset, truncated: win.truncated, nextOffset: win.nextOffset },
629
+ };
630
+ };
631
+ // 续读同一页:10 分钟内直接用上次抓到的正文,不重新请求(渲染按次计费)
632
+ const cached = offset > 0 ? recallPage(url.href) : null;
633
+ if (cached)
634
+ return ok(cached.text, cached.via, cached.contentType);
569
635
  let direct = null;
570
636
  try {
571
637
  direct = await fetchDirect(url, signal);
package/dist/usage.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type MeResponse } from "./api.js";
2
2
  export declare const TOPUP_URL = "https://u1s1.io/dashboard#usage-topup-card";
3
3
  /**
4
- * 报告尾巴的两条行动入口:邀请(永久加量)与充值(撞线用户的直接出口,运营清单 A2)。
4
+ * 报告尾巴的两条行动入口:打卡/邀请领包与充值(撞线用户的直接出口,运营清单 A2)。
5
+ * 08-25 起没有「每天自动恢复」的免费池,邀请包也和打卡包一样有有效期,别再写「永久加量」。
5
6
  * 支付通道关闭时充值是死路(仪表盘会报「支付通道未开放」),改说即将上线并指向
6
7
  * 仪表盘的加量包/打卡入口;老网关没有 pay_enabled 字段,按开放处理。
7
8
  */
package/dist/usage.js CHANGED
@@ -54,9 +54,11 @@ function packageScopeNote(pkg) {
54
54
  if (pkg.kind === "login_checkin") {
55
55
  return "仅限 u1s1 客户端使用 · 全模型可用";
56
56
  }
57
- return pkg.scope === "free"
58
- ? "免费包适用模型 · 0 点恢复"
59
- : "仅限 u1s1 客户端使用 · 免费包适用模型";
57
+ if (pkg.scope === "free") {
58
+ // 只有按日发放的免费包(首月/年度包的每日额度)才 0 点恢复;一次性免费包用完就没了。
59
+ return pkg.daily_tokens != null ? "免费包适用模型 · 每日额度北京时间 0 点恢复" : "免费包适用模型";
60
+ }
61
+ return "仅限 u1s1 客户端使用 · 免费包适用模型";
60
62
  }
61
63
  function packageLabel(pkg) {
62
64
  const count = pkg.count > 1 ? ` ×${pkg.count}` : "";
@@ -123,14 +125,15 @@ function legacyUsageLines(me, tokensPerUsd) {
123
125
  }
124
126
  export const TOPUP_URL = "https://u1s1.io/dashboard#usage-topup-card";
125
127
  /**
126
- * 报告尾巴的两条行动入口:邀请(永久加量)与充值(撞线用户的直接出口,运营清单 A2)。
128
+ * 报告尾巴的两条行动入口:打卡/邀请领包与充值(撞线用户的直接出口,运营清单 A2)。
129
+ * 08-25 起没有「每天自动恢复」的免费池,邀请包也和打卡包一样有有效期,别再写「永久加量」。
127
130
  * 支付通道关闭时充值是死路(仪表盘会报「支付通道未开放」),改说即将上线并指向
128
131
  * 仪表盘的加量包/打卡入口;老网关没有 pay_enabled 字段,按开放处理。
129
132
  */
130
133
  export function usageCtaLines(me) {
131
134
  const payClosed = me?.pay_enabled === false;
132
135
  return [
133
- " 免费额度每天自动恢复;邀请朋友双方各得永久加量 → https://u1s1.io/dashboard",
136
+ " 每天到仪表盘打卡领免费包;邀请朋友双方都加量 → https://u1s1.io/dashboard",
134
137
  payClosed
135
138
  ? " 充值通道即将开放;额度不够先到仪表盘领加量包、每日打卡 → https://u1s1.io/dashboard"
136
139
  : ` 额度不够用?充值 → ${TOPUP_URL}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {