sideby 0.16.0 → 0.17.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 (77) hide show
  1. package/README.md +5 -3
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +16 -0
  4. package/dist/src/cli/commands.js +460 -5
  5. package/dist/src/cli/json-schemas.js +115 -0
  6. package/dist/src/cli/main.js +93 -3
  7. package/dist/src/core/ask.d.ts +150 -0
  8. package/dist/src/core/ask.js +365 -0
  9. package/dist/src/core/attachments.d.ts +50 -0
  10. package/dist/src/core/attachments.js +442 -0
  11. package/dist/src/core/autostart.d.ts +88 -0
  12. package/dist/src/core/autostart.js +256 -0
  13. package/dist/src/core/config.d.ts +97 -0
  14. package/dist/src/core/config.js +278 -10
  15. package/dist/src/core/launch.d.ts +15 -4
  16. package/dist/src/core/launch.js +27 -23
  17. package/dist/src/core/local-build.d.ts +17 -0
  18. package/dist/src/core/local-build.js +39 -3
  19. package/dist/src/core/probe.d.ts +78 -0
  20. package/dist/src/core/probe.js +127 -0
  21. package/dist/src/core/same-login.d.ts +9 -4
  22. package/dist/src/core/same-login.js +9 -4
  23. package/dist/src/core/secret-file.d.ts +26 -0
  24. package/dist/src/core/secret-file.js +126 -7
  25. package/dist/src/core/terminal.d.ts +93 -0
  26. package/dist/src/core/terminal.js +371 -0
  27. package/dist/src/core/update.d.ts +17 -0
  28. package/dist/src/core/update.js +63 -1
  29. package/dist/src/families/claude/index.js +3 -0
  30. package/dist/src/families/claude/quota-cache.d.ts +24 -7
  31. package/dist/src/families/claude/quota-cache.js +72 -21
  32. package/dist/src/families/codex/index.js +7 -0
  33. package/dist/src/families/codex/login-key.d.ts +8 -0
  34. package/dist/src/families/codex/login-key.js +33 -0
  35. package/dist/src/families/grok/index.js +3 -0
  36. package/dist/src/families/login-keys.d.ts +6 -0
  37. package/dist/src/families/login-keys.js +6 -0
  38. package/dist/src/families/pi/index.js +4 -0
  39. package/dist/src/panel/background.js +3 -2
  40. package/dist/src/panel/handler.d.ts +29 -2
  41. package/dist/src/panel/handler.js +396 -9
  42. package/dist/src/panel/i18n.d.ts +175 -2
  43. package/dist/src/panel/i18n.js +381 -35
  44. package/dist/src/panel/page-logic.d.ts +214 -0
  45. package/dist/src/panel/page-logic.js +394 -0
  46. package/dist/src/panel/page-script.js +984 -134
  47. package/dist/src/panel/page-styles.js +69 -19
  48. package/dist/src/panel/page.d.ts +5 -0
  49. package/dist/src/panel/page.js +2 -0
  50. package/dist/src/panel/server.d.ts +16 -2
  51. package/dist/src/panel/server.js +41 -13
  52. package/dist/src/plugins/check.d.ts +47 -0
  53. package/dist/src/plugins/check.js +544 -0
  54. package/dist/src/plugins/scaffold.d.ts +21 -0
  55. package/dist/src/plugins/scaffold.js +123 -0
  56. package/dist/src/quota/statusline-tap.js +1 -1
  57. package/dist/src/runtime.d.ts +10 -0
  58. package/dist/src/runtime.js +47 -16
  59. package/dist/src/types.d.ts +19 -0
  60. package/package.json +2 -2
  61. package/schemas/ask.json +180 -0
  62. package/schemas/autostart.json +165 -0
  63. package/schemas/config-get.json +132 -0
  64. package/schemas/config-set.json +107 -0
  65. package/schemas/config.json +73 -6
  66. package/schemas/key.json +93 -0
  67. package/schemas/local.json +4 -0
  68. package/schemas/plugins-check.json +113 -0
  69. package/schemas/plugins-new.json +87 -0
  70. package/schemas/terminal.json +152 -0
  71. package/schemas/try.json +109 -0
  72. package/schemas/update.json +4 -0
  73. package/skills/sideby/SKILL.md +12 -6
  74. package/skills/sideby/references/accounts.md +1 -1
  75. package/skills/sideby/references/ai-tasks.md +116 -0
  76. package/skills/sideby/references/panel.md +14 -0
  77. package/skills/sideby/references/update.md +1 -1
@@ -77,6 +77,15 @@ export declare function attentionReasons(a: LogicAccount, health: {
77
77
  fail: number;
78
78
  warn: number;
79
79
  } | null, now: number, warn: number): string[];
80
+ /**
81
+ * The one status badge an account row shows after its name (spec §3.27), most important first: `health-fail`
82
+ * (needs fixing), `quota-high` (running low), the other attention labels (`signed-out`, `not-installed`,
83
+ * `health-warn`), then `next` (recommended) and last `api`. Null when none applies. `reasons` are attentionReasons'.
84
+ */
85
+ export declare function rowBadge(reasons: string[], opts: {
86
+ next?: boolean;
87
+ api?: boolean;
88
+ }): string | null;
80
89
  /**
81
90
  * The most serious reason an Account needs a look, as a message key and its values for the Panel to show where the
82
91
  * attention count is: `why.<reason>`; for `quota-high` the fullest live window at or over `warn`, with its label and
@@ -177,6 +186,24 @@ export declare function panelHint(hint: string, fixable: boolean, action: string
177
186
  * Whether the Panel fixes Finding `f` with a button of its own: a sideby skill behind this version while the
178
187
  * Update plan says sideby may update it (`skillUpdatable`). Such Findings stay out of the prompt for an AI agent.
179
188
  */
189
+ /**
190
+ * A Local Build the Panel can leave for npm itself (ADR-0024): the global `sideby` still runs it and the Panel may
191
+ * write. Then switching to the release is the update, offered as a button instead of the `pnpm local --off` command.
192
+ */
193
+ export declare function canLeaveLocal(u: {
194
+ localSwitch?: {
195
+ running: string | null;
196
+ };
197
+ } | null, readOnly: boolean): boolean;
198
+ /**
199
+ * What a failed Local Build switch request means (ADR-0024): a lost connection (a background Panel restarting) or a
200
+ * Portal's 5xx (it moved to the new code mid-request) may follow a switch that happened, so the state decides;
201
+ * anything else is the server saying it did not switch.
202
+ */
203
+ export declare function switchFailure(e: {
204
+ network?: boolean;
205
+ status?: number;
206
+ }): 'wait' | 'check' | 'fail';
180
207
  export declare function panelFixes(f: {
181
208
  code?: string;
182
209
  }, skillUpdatable: boolean): boolean;
@@ -284,5 +311,192 @@ export declare function remoteControlSummary(groups: readonly {
284
311
  export declare function remoteControlTodo<G extends {
285
312
  state: string;
286
313
  }>(groups: readonly G[], to: 'on' | 'off'): G[];
314
+ /**
315
+ * Where a menu or popover goes next to its anchor, all in viewport pixels. It opens below when its whole height fits
316
+ * there, else above when it fits there, else on the side with more room, its height capped at that room so it scrolls
317
+ * inside. Below `margin` from each edge and `gap` from the anchor. When neither side has even `min` pixels (a tiny
318
+ * window, an anchor near an edge), it takes the whole height of the window instead, over the anchor. Horizontally it
319
+ * lines up with the anchor's left edge (`start`) or right edge (`end`), kept inside the window.
320
+ */
321
+ export declare function menuPlacement(anchor: {
322
+ top: number;
323
+ bottom: number;
324
+ left: number;
325
+ right: number;
326
+ }, size: {
327
+ width: number;
328
+ height: number;
329
+ }, view: {
330
+ width: number;
331
+ height: number;
332
+ }, align: 'start' | 'end', gap?: number, margin?: number, min?: number): {
333
+ left: number;
334
+ top: number;
335
+ maxHeight: number;
336
+ above: boolean;
337
+ };
287
338
  /** The key a Same Login group keeps its folded state under: its members, in a fixed order. */
288
339
  export declare function loginKey(refs: readonly string[]): string;
340
+ /**
341
+ * `text` without key-shaped strings (spec §3.28), each replaced by `[key removed]`. First, anywhere in a word, URLs
342
+ * and query strings included: a known key prefix (`sk-`, which covers `sk-ant-` and `sk-proj-`, `xai-`, `gsk_`,
343
+ * `AIza`, `ghp_`, `github_pat_`) followed by 16 or more token characters, an AWS access key id (`AKIA` or `ASIA` and
344
+ * 16 capitals or digits), and a JWT (three base64url parts joined by dots, the first starting `eyJ`). Then any run of
345
+ * 32 or more letters, digits, `_`, `-` or `+` that mixes letters and digits, unless the whitespace-free word it sits
346
+ * in is a URL or a file path (it has a `/`); a 40-character git SHA is such a run. Everything else, whitespace and
347
+ * newlines included, stays as it was. `removed` says whether anything was taken out, so the caller can point the
348
+ * user to the key box.
349
+ */
350
+ export declare function stripSecrets(text: string): {
351
+ text: string;
352
+ removed: boolean;
353
+ };
354
+ /** The kinds of AI Task (spec §3.28); anything else is refused. */
355
+ export declare const AI_TASK_KINDS: readonly ['free', 'fix', 'quota', 'account', 'host', 'plugin', 'terminal', 'handoff'];
356
+ export type AiTaskKind = (typeof AI_TASK_KINDS)[number];
357
+ /**
358
+ * The message keys of the AI dialog for `task` (spec §3.28 "每个入口的提示"): its title, the text box placeholder and
359
+ * the example chips. Each comes from the task's own key (`ask.title.<task>`, `ask.ph.<task>`, `ask.ex.<task>.1` to
360
+ * `.3`) when `has` knows it, otherwise from `free`'s: `ask.title`, `ask.ph.free` and `freeChips`.
361
+ */
362
+ export declare function askCopy(task: string, has: (key: string) => boolean, freeChips: readonly string[]): {
363
+ title: string;
364
+ placeholder: string;
365
+ chips: string[];
366
+ };
367
+ /**
368
+ * The Task Prompt of an AI Task (spec §3.28), in `lang` (`zh`, otherwise English): what to do, the context given,
369
+ * the fixed rules, pointers for the task and the invitation to contribute. For `free` the user's `text` is what to
370
+ * do; for the other tasks it follows the task's own sentence. `text` and every context string go through `strip`
371
+ * (pass `stripSecrets`), so no key-shaped string reaches the prompt; the caller still checks `text` itself to warn
372
+ * the user. Context carries only what the caller passes, never a credential value.
373
+ */
374
+ export declare function taskPrompt(input: {
375
+ task: string;
376
+ text?: string;
377
+ context?: {
378
+ account?: string;
379
+ family?: string;
380
+ findings?: readonly {
381
+ code?: string;
382
+ account?: string;
383
+ item?: string;
384
+ message: string;
385
+ hint?: string;
386
+ }[];
387
+ quotaReason?: string;
388
+ version?: string;
389
+ /** The custom terminal command saved now (`terminal` task). */
390
+ terminalCustom?: string;
391
+ /** Names of the supported terminals installed on this computer (`terminal` task). */
392
+ terminals?: readonly string[];
393
+ /** Auto Handoff facts, one per line, as `handoffTaskFacts` gives them (`handoff` task). */
394
+ handoff?: readonly string[];
395
+ };
396
+ /** Files saved for the task (spec §3.28 "附件"): absolute paths and their kind. */
397
+ attachments?: readonly {
398
+ path: string;
399
+ kind: string;
400
+ }[];
401
+ lang: string;
402
+ }, strip: (text: string) => {
403
+ text: string;
404
+ removed: boolean;
405
+ }): string;
406
+ /**
407
+ * The accounts an AI Task can start (spec §3.28), in the order the dialog lists them: signed-in subscription
408
+ * accounts first, then API accounts, each by ref. A subscription that is not signed in cannot start one.
409
+ */
410
+ export declare function aiTaskAccounts<A extends {
411
+ ref: string;
412
+ kind?: string;
413
+ login?: string;
414
+ }>(accounts: readonly A[]): A[];
415
+ /**
416
+ * Whether a quota cell offers "Ask AI why" (spec §3.28): it shows no data or an error the user cannot fix with a
417
+ * button of its own. Quota that is off (it has its own switch), an API account and a Family without a public source
418
+ * do not.
419
+ */
420
+ export declare function quotaAskable(reason: string | undefined): boolean;
421
+ /** A file size for an attachment tile (spec §3.28 "附件"): KB below 1 MB, MB with one decimal from there. */
422
+ export declare function fileSize(bytes: number): string;
423
+ /** Findings as an AI Task's context carries them: only the fields a Task Prompt shows. */
424
+ export declare function taskFindings(findings: readonly {
425
+ code?: string;
426
+ account?: string;
427
+ item?: string;
428
+ message: string;
429
+ hint?: string;
430
+ }[]): {
431
+ code?: string;
432
+ account?: string;
433
+ item?: string;
434
+ message: string;
435
+ hint?: string;
436
+ }[];
437
+ /**
438
+ * The theme and language the Panel shows (spec §3.29). A Panel that can write takes them from config `panel`; a
439
+ * read-only one from what this browser saved. `language` is empty for the browser's own language.
440
+ */
441
+ export declare function panelPrefs(config: {
442
+ theme?: string;
443
+ language?: string;
444
+ }, local: {
445
+ theme: string;
446
+ lang: string;
447
+ }, fromConfig: boolean): {
448
+ theme: string;
449
+ language: string;
450
+ };
451
+ /**
452
+ * What this browser's own choice adds to config `panel` the first time this version opens (spec §3.29): only keys
453
+ * config does not have yet, and only a choice the person made, not a default. Null when nothing is to be written.
454
+ */
455
+ export declare function panelMigration(config: {
456
+ theme?: string;
457
+ language?: string;
458
+ }, local: {
459
+ theme: string;
460
+ lang: string;
461
+ }): {
462
+ theme?: string;
463
+ language?: string;
464
+ } | null;
465
+ /**
466
+ * The line under the start-at-login switch (spec §3.29) as a message key, whether it is on, whether the switch can
467
+ * be moved, and the action beside it: `fix` turns it on again so it starts this sideby. Read-only: no switch, no action.
468
+ */
469
+ export declare function autostartNote(s: {
470
+ supported: boolean;
471
+ state: string;
472
+ current: boolean;
473
+ } | null, readOnly: boolean): {
474
+ key: string;
475
+ on: boolean;
476
+ toggle: boolean;
477
+ action: 'fix' | null;
478
+ };
479
+ /**
480
+ * The facts an Auto Handoff AI Task carries (spec §3.29), one per line, named as `sideby handoff status --json`
481
+ * names them: the settings, the order per Family, and where each account's quota comes from. Account refs only,
482
+ * never an email. `status` is the readiness, when the page has it.
483
+ */
484
+ export declare function handoffTaskFacts(settings: {
485
+ auto: boolean;
486
+ sameFamily: boolean;
487
+ threshold: number;
488
+ prepareAt: number;
489
+ waitIfResetWithinMinutes?: number;
490
+ countdownSeconds?: number;
491
+ families?: Record<string, {
492
+ policy: string;
493
+ order: readonly string[];
494
+ }>;
495
+ }, starters: readonly string[], accounts: readonly LogicAccount[], now: number, status?: {
496
+ families?: readonly {
497
+ family: string;
498
+ ready: boolean;
499
+ problems: readonly string[];
500
+ }[];
501
+ nextSteps?: readonly string[];
502
+ } | null): string[];
@@ -87,6 +87,21 @@ export function attentionReasons(a, health, now, warn) {
87
87
  out.push('health-warn');
88
88
  return out;
89
89
  }
90
+ /**
91
+ * The one status badge an account row shows after its name (spec §3.27), most important first: `health-fail`
92
+ * (needs fixing), `quota-high` (running low), the other attention labels (`signed-out`, `not-installed`,
93
+ * `health-warn`), then `next` (recommended) and last `api`. Null when none applies. `reasons` are attentionReasons'.
94
+ */
95
+ export function rowBadge(reasons, opts) {
96
+ for (const r of ['health-fail', 'quota-high', 'signed-out', 'not-installed', 'health-warn'])
97
+ if (reasons.includes(r))
98
+ return r;
99
+ if (opts.next)
100
+ return 'next';
101
+ if (opts.api)
102
+ return 'api';
103
+ return null;
104
+ }
90
105
  /**
91
106
  * The most serious reason an Account needs a look, as a message key and its values for the Panel to show where the
92
107
  * attention count is: `why.<reason>`; for `quota-high` the fullest live window at or over `warn`, with its label and
@@ -361,6 +376,23 @@ export function panelHint(hint, fixable, action) {
361
376
  * Whether the Panel fixes Finding `f` with a button of its own: a sideby skill behind this version while the
362
377
  * Update plan says sideby may update it (`skillUpdatable`). Such Findings stay out of the prompt for an AI agent.
363
378
  */
379
+ /**
380
+ * A Local Build the Panel can leave for npm itself (ADR-0024): the global `sideby` still runs it and the Panel may
381
+ * write. Then switching to the release is the update, offered as a button instead of the `pnpm local --off` command.
382
+ */
383
+ export function canLeaveLocal(u, readOnly) {
384
+ return !readOnly && !!u?.localSwitch && u.localSwitch.running === 'local';
385
+ }
386
+ /**
387
+ * What a failed Local Build switch request means (ADR-0024): a lost connection (a background Panel restarting) or a
388
+ * Portal's 5xx (it moved to the new code mid-request) may follow a switch that happened, so the state decides;
389
+ * anything else is the server saying it did not switch.
390
+ */
391
+ export function switchFailure(e) {
392
+ if (e.network)
393
+ return 'wait';
394
+ return (e.status ?? 0) >= 500 ? 'check' : 'fail';
395
+ }
364
396
  export function panelFixes(f, skillUpdatable) {
365
397
  return skillUpdatable && f.code === 'skill.outdated';
366
398
  }
@@ -525,7 +557,369 @@ export function remoteControlSummary(groups, apiRefs) {
525
557
  export function remoteControlTodo(groups, to) {
526
558
  return groups.filter((g) => g.state === 'default' || g.state === (to === 'on' ? 'off' : 'on'));
527
559
  }
560
+ /**
561
+ * Where a menu or popover goes next to its anchor, all in viewport pixels. It opens below when its whole height fits
562
+ * there, else above when it fits there, else on the side with more room, its height capped at that room so it scrolls
563
+ * inside. Below `margin` from each edge and `gap` from the anchor. When neither side has even `min` pixels (a tiny
564
+ * window, an anchor near an edge), it takes the whole height of the window instead, over the anchor. Horizontally it
565
+ * lines up with the anchor's left edge (`start`) or right edge (`end`), kept inside the window.
566
+ */
567
+ export function menuPlacement(anchor, size, view, align, gap = 4, margin = 8, min = 120) {
568
+ const x = align === 'start' ? anchor.left : anchor.right - size.width;
569
+ const left = Math.max(margin, Math.min(view.width - size.width - margin, x));
570
+ const below = Math.floor(view.height - margin - anchor.bottom - gap);
571
+ const above = Math.floor(anchor.top - gap - margin);
572
+ if (size.height <= below)
573
+ return { left, top: anchor.bottom + gap, maxHeight: below, above: false };
574
+ if (size.height <= above)
575
+ return { left, top: anchor.top - gap - size.height, maxHeight: above, above: true };
576
+ if (Math.max(below, above) < Math.min(size.height, min)) {
577
+ return { left, top: margin, maxHeight: Math.max(0, Math.floor(view.height - 2 * margin)), above: false };
578
+ }
579
+ if (below >= above)
580
+ return { left, top: anchor.bottom + gap, maxHeight: below, above: false };
581
+ return { left, top: margin, maxHeight: above, above: true };
582
+ }
528
583
  /** The key a Same Login group keeps its folded state under: its members, in a fixed order. */
529
584
  export function loginKey(refs) {
530
585
  return `login:${[...refs].sort().join(',')}`;
531
586
  }
587
+ /**
588
+ * `text` without key-shaped strings (spec §3.28), each replaced by `[key removed]`. First, anywhere in a word, URLs
589
+ * and query strings included: a known key prefix (`sk-`, which covers `sk-ant-` and `sk-proj-`, `xai-`, `gsk_`,
590
+ * `AIza`, `ghp_`, `github_pat_`) followed by 16 or more token characters, an AWS access key id (`AKIA` or `ASIA` and
591
+ * 16 capitals or digits), and a JWT (three base64url parts joined by dots, the first starting `eyJ`). Then any run of
592
+ * 32 or more letters, digits, `_`, `-` or `+` that mixes letters and digits, unless the whitespace-free word it sits
593
+ * in is a URL or a file path (it has a `/`); a 40-character git SHA is such a run. Everything else, whitespace and
594
+ * newlines included, stays as it was. `removed` says whether anything was taken out, so the caller can point the
595
+ * user to the key box.
596
+ */
597
+ export function stripSecrets(text) {
598
+ const mark = '[key removed]';
599
+ let removed = false;
600
+ const out = String(text || '').replace(/\S+/g, (word) => {
601
+ let w = word.replace(/(?<![A-Za-z0-9_-])(?:(?:sk-proj-|sk-|xai-|gsk_|AIza|ghp_|github_pat_)[A-Za-z0-9_-]{16,}|(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9])|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*)/g, () => {
602
+ removed = true;
603
+ return mark;
604
+ });
605
+ if (w.includes('/'))
606
+ return w;
607
+ w = w.replace(/[A-Za-z0-9_+-]{32,}=*/g, (run) => {
608
+ if (!/[A-Za-z]/.test(run) || !/[0-9]/.test(run))
609
+ return run;
610
+ removed = true;
611
+ return mark;
612
+ });
613
+ return w;
614
+ });
615
+ return { text: out, removed };
616
+ }
617
+ /** The kinds of AI Task (spec §3.28); anything else is refused. */
618
+ export const AI_TASK_KINDS = [
619
+ 'free',
620
+ 'fix',
621
+ 'quota',
622
+ 'account',
623
+ 'host',
624
+ 'plugin',
625
+ 'terminal',
626
+ 'handoff',
627
+ ];
628
+ /**
629
+ * The message keys of the AI dialog for `task` (spec §3.28 "每个入口的提示"): its title, the text box placeholder and
630
+ * the example chips. Each comes from the task's own key (`ask.title.<task>`, `ask.ph.<task>`, `ask.ex.<task>.1` to
631
+ * `.3`) when `has` knows it, otherwise from `free`'s: `ask.title`, `ask.ph.free` and `freeChips`.
632
+ */
633
+ export function askCopy(task, has, freeChips) {
634
+ const own = task !== 'free';
635
+ const title = own && has('ask.title.' + task) ? 'ask.title.' + task : 'ask.title';
636
+ const placeholder = own && has('ask.ph.' + task) ? 'ask.ph.' + task : 'ask.ph.free';
637
+ const chips = own ? [1, 2, 3].map((n) => 'ask.ex.' + task + '.' + n).filter(has) : [];
638
+ return { title, placeholder, chips: chips.length ? chips : freeChips.slice() };
639
+ }
640
+ /**
641
+ * The Task Prompt of an AI Task (spec §3.28), in `lang` (`zh`, otherwise English): what to do, the context given,
642
+ * the fixed rules, pointers for the task and the invitation to contribute. For `free` the user's `text` is what to
643
+ * do; for the other tasks it follows the task's own sentence. `text` and every context string go through `strip`
644
+ * (pass `stripSecrets`), so no key-shaped string reaches the prompt; the caller still checks `text` itself to warn
645
+ * the user. Context carries only what the caller passes, never a credential value.
646
+ */
647
+ export function taskPrompt(input, strip) {
648
+ const zh = input.lang === 'zh';
649
+ const clean = (s) => strip(String(s || '')).text.trim();
650
+ const c = input.context || {};
651
+ const text = clean(input.text);
652
+ const account = clean(c.account);
653
+ const family = clean(c.family);
654
+ const L = zh
655
+ ? {
656
+ task: '要做的事',
657
+ context: '上下文',
658
+ rules: '规则',
659
+ pointers: '提示',
660
+ says: '我的原话:',
661
+ free: '问我想用 sideby 做什么,然后帮我做。',
662
+ fix: 'sideby 的体检发现了下面这些不能自动修复的问题,请帮我修好。',
663
+ quota: '额度格没有显示' + (account ? ' ' + account + ' 的' : '') + '额度,请找出原因并让它显示出来。',
664
+ account: '帮我在 sideby 里加一个' +
665
+ (family ? ' ' + family + ' ' : '') +
666
+ '账号(订阅账号,或者用 API 地址的账号)。',
667
+ host: '帮我给 sideby 加一个它还不支持的 AI 编程工具(宿主),先做成本机的 Family 插件。',
668
+ plugin: '帮我写一个 sideby 插件或 hook。',
669
+ terminal: '帮我让 sideby 能在我用的终端里打开 AI 任务:写一条自定义终端命令。',
670
+ handoff: '帮我调好 sideby 的自动接力:根据我的账号推荐什么时候接力(阈值)和接力的顺序。',
671
+ cHandoff: '自动接力现在的设置(来自 `sideby handoff status --json`):',
672
+ pHandoff: '先运行 `sideby handoff status --json` 和 `sideby quota --json`,看清每个账号的额度来源和用量。只用 `sideby handoff enable …` 或 `sideby config set handoff.<键> <值>` 改设置;先不加 `--yes` 给我看预览,我同意后再加 `--yes`。没有额度数据的账号排在顺序后面,并告诉我怎么让它显示额度。',
673
+ cAccount: '账号:',
674
+ cFamily: '家族:',
675
+ cFindings: '体检发现:',
676
+ cQuota: '额度原因:',
677
+ cVersion: 'sideby 版本:',
678
+ cCustom: '现在的自定义终端命令:',
679
+ cTerminals: '本机装了的、sideby 支持的终端:',
680
+ cNoTerminals: '本机没找到 sideby 支持的终端',
681
+ cLang: '面板语言:中文(请用中文和我交流)',
682
+ r1: '先用 sideby skill,怎么做见它的 references/ai-tasks.md。没装 skill 就先问我,我同意后运行 `npx skills add Adonis0123/sideby -g`。',
683
+ r2: '会改文件的命令先给我看预览,我同意后才加 `--yes`。',
684
+ r3: '不要替我登录,也不要让我把密钥发给你:密钥由我自己用 `sideby key <账号>` 输入。',
685
+ pFix: '先运行 `sideby doctor --json` 看清问题;能自动修的交给 `sideby doctor --fix`。修完再跑一次 `sideby doctor`,告诉我还剩什么。',
686
+ pQuota: '用 `sideby quota --json` 和 `sideby doctor --json` 看原因。',
687
+ pAccount: '地址、模型名、环境变量以厂商的官方文档为准。账号建好后,让我用 `sideby key <账号>` 输入密钥,再用 `sideby try <账号>` 试一次。订阅账号要登录时,让我点面板上的「登录」,或者自己运行 `sideby login <账号>`。',
688
+ pHost: '用 `sideby plugins new <id>` 生成插件骨架;收集事实时写明宿主的版本和日期;用 `sideby plugins check <id>` 检查,并在演示 HOME 里试一遍。',
689
+ pPlugin: '照 sideby 仓库的插件指南 docs/guide/plugins.zh-CN.md 写;写完用 `sideby plugins check <id>` 检查。',
690
+ pTerminal: "先问我用的是什么终端。只看这个终端的官方文档和它的 `--help`,找出「在指定目录新开一个窗口运行一条命令」的写法,写明你看的版本。写成一个模板:`{cmd}` 是要运行的命令,`{cwd}` 是文件夹,sideby 按参数填入,不经过 shell。保存要用 `sideby terminal --custom '<模板>'`,它会改 sideby 的设置,先问我。保存后让我在面板上点一次「让 AI 帮我」试一下,打不开就一起看错误再改。",
691
+ attached: '附件',
692
+ image: '图片',
693
+ video: '视频',
694
+ videoHint: '需要看视频内容时,用 ffmpeg 抽几帧再看,例如 `ffmpeg -i <视频> -vf fps=1 -frames:v 5 frame-%d.png`。',
695
+ share: '做好之后,问我愿不愿意把这次的改进分享给 sideby。我同意后,按 sideby 仓库的 CONTRIBUTING.md 提一个 pull request,提交前去掉我自己的账号名、路径和邮箱。',
696
+ shareTerminal: '跑通之后,问我愿不愿意把这个终端做成 sideby 的内置终端。我同意后,在 sideby 仓库 src/core/terminal.ts 的终端表里加一行并补上测试,按 CONTRIBUTING.md 提一个 pull request,提交前去掉我自己的账号名、路径和邮箱。',
697
+ }
698
+ : {
699
+ task: 'What to do',
700
+ context: 'Context',
701
+ rules: 'Rules',
702
+ pointers: 'Pointers',
703
+ says: 'In my words: ',
704
+ free: 'Ask me what I want to do with sideby, then do it for me.',
705
+ fix: "Fix the problems below that sideby's health check cannot fix by itself.",
706
+ quota: 'sideby shows no quota' + (account ? ' for ' + account : '') + '. Find out why and make it show.',
707
+ account: 'Help me add a ' +
708
+ (family ? family + ' ' : '') +
709
+ 'account to sideby (a subscription, or an API address).',
710
+ host: 'Help me add an AI coding tool (Host) that sideby does not support yet, first as a local Family Plugin.',
711
+ plugin: 'Help me write a sideby plugin or hook.',
712
+ terminal: 'Help sideby open AI Tasks in the terminal I use: write a custom terminal command.',
713
+ handoff: "Help me tune sideby's Auto Handoff: from my accounts, recommend when to hand over (the threshold) and in what order.",
714
+ cHandoff: 'Auto Handoff now (from `sideby handoff status --json`):',
715
+ pHandoff: 'Run `sideby handoff status --json` and `sideby quota --json` first to see where each account gets its quota and how much is used. Change settings only with `sideby handoff enable …` or `sideby config set handoff.<key> <value>`; show me the preview without `--yes` first, and add `--yes` only after I agree. Put accounts without quota data last in the order, and tell me how to make them show it.',
716
+ cAccount: 'Account: ',
717
+ cFamily: 'Family: ',
718
+ cFindings: 'Health check findings:',
719
+ cQuota: 'Quota reason: ',
720
+ cVersion: 'sideby version: ',
721
+ cCustom: 'Custom terminal command now: ',
722
+ cTerminals: 'Supported terminals installed here: ',
723
+ cNoTerminals: 'No supported terminal is installed here',
724
+ cLang: 'Panel language: English (talk to me in English)',
725
+ r1: 'Use the sideby skill; its references/ai-tasks.md says how. If the skill is not installed, ask me first, and with my yes run `npx skills add Adonis0123/sideby -g`.',
726
+ r2: 'Show me the preview of any command that changes files; add `--yes` only after I agree.',
727
+ r3: 'Never sign in for me and never ask me to send you an API key: I enter keys myself with `sideby key <account>`.',
728
+ pFix: 'Run `sideby doctor --json` first; `sideby doctor --fix` fixes what it can by itself. When you are done, run `sideby doctor` and tell me what is left.',
729
+ pQuota: 'Use `sideby quota --json` and `sideby doctor --json` to see why.',
730
+ pAccount: "Take addresses, model names and variables from the vendor's official docs. When the account exists, have me enter the key with `sideby key <account>`, then try it with `sideby try <account>`. For a subscription sign-in, have me click Sign in in the Panel or run `sideby login <account>` myself.",
731
+ pHost: 'Start with `sideby plugins new <id>`; note the Host version and the date with every fact you gather; check it with `sideby plugins check <id>` and try it in a demo HOME.',
732
+ pPlugin: 'Follow the plugins guide in the sideby repository (docs/guide/plugins.md); check it with `sideby plugins check <id>`.',
733
+ pTerminal: "Ask me which terminal I use first. Read only that terminal's official docs and its `--help` to find how it opens a new window that runs one command in a given folder, and note the version you read. Write it as a template: `{cmd}` is the command and `{cwd}` the folder; sideby fills them in as separate arguments, no shell runs it. Save it with `sideby terminal --custom '<template>'`, which changes sideby's settings, so ask me first. Then have me click Let AI do it in the Panel once to try it; if it does not open, look at the error with me and fix the template.",
734
+ attached: 'Attached files',
735
+ image: 'image',
736
+ video: 'video',
737
+ videoHint: 'To see what a video shows, extract a few frames with ffmpeg first, for example `ffmpeg -i <video> -vf fps=1 -frames:v 5 frame-%d.png`.',
738
+ share: 'When it works, ask me whether to share the improvement with sideby. Only with my yes, follow the CONTRIBUTING.md of the sideby repository to open a pull request, and first remove my own account names, paths and emails.',
739
+ shareTerminal: 'When it works, ask me whether to make this terminal built into sideby. Only with my yes, add a row to the terminal table in src/core/terminal.ts of the sideby repository with tests, follow its CONTRIBUTING.md to open a pull request, and first remove my own account names, paths and emails.',
740
+ };
741
+ const task = AI_TASK_KINDS.indexOf(input.task) >= 0 ? input.task : 'free';
742
+ const sentence = L[task] || L.free;
743
+ const what = task === 'free' ? text || sentence : text ? sentence + '\n' + L.says + text : sentence;
744
+ const facts = [];
745
+ if (account)
746
+ facts.push('- ' + L.cAccount + account);
747
+ if (family)
748
+ facts.push('- ' + L.cFamily + family);
749
+ const findings = c.findings || [];
750
+ if (findings.length) {
751
+ facts.push('- ' + L.cFindings);
752
+ findings.forEach((f, i) => {
753
+ const where = [clean(f.account), clean(f.item)].filter(Boolean).join(' · ');
754
+ const code = clean(f.code);
755
+ facts.push(' ' +
756
+ (i + 1) +
757
+ '. ' +
758
+ (code ? '[' + code + '] ' : '') +
759
+ (where ? where + ': ' : '') +
760
+ clean(f.message));
761
+ const hint = clean(f.hint);
762
+ if (hint)
763
+ facts.push(' ' + hint);
764
+ });
765
+ }
766
+ const reason = clean(c.quotaReason);
767
+ if (reason)
768
+ facts.push('- ' + L.cQuota + reason);
769
+ if (task === 'terminal') {
770
+ const custom = clean(c.terminalCustom);
771
+ if (custom)
772
+ facts.push('- ' + L.cCustom + custom);
773
+ const terms = (c.terminals || []).map(clean).filter(Boolean);
774
+ facts.push('- ' + (terms.length ? L.cTerminals + terms.join(', ') : L.cNoTerminals));
775
+ }
776
+ if (task === 'handoff') {
777
+ const lines = (c.handoff || []).map(clean).filter(Boolean);
778
+ if (lines.length)
779
+ facts.push('- ' + L.cHandoff, ...lines.map((l) => ' - ' + l));
780
+ }
781
+ const version = clean(c.version);
782
+ if (version)
783
+ facts.push('- ' + L.cVersion + version);
784
+ facts.push('- ' + L.cLang);
785
+ const pointer = L['p' + task.charAt(0).toUpperCase() + task.slice(1)];
786
+ const parts = [L.task + '\n' + what, L.context + '\n' + facts.join('\n')];
787
+ const files = input.attachments || [];
788
+ if (files.length) {
789
+ const lines = files.map((f) => '- ' + f.path + ' (' + (f.kind === 'video' ? L.video : L.image) + ')');
790
+ if (files.some((f) => f.kind === 'video'))
791
+ lines.push(L.videoHint);
792
+ parts.push(L.attached + '\n' + lines.join('\n'));
793
+ }
794
+ parts.push(L.rules + '\n- ' + L.r1 + '\n- ' + L.r2 + '\n- ' + L.r3);
795
+ if (pointer)
796
+ parts.push(L.pointers + '\n' + pointer);
797
+ parts.push(task === 'terminal' ? L.shareTerminal : L.share);
798
+ return parts.join('\n\n');
799
+ }
800
+ /**
801
+ * The accounts an AI Task can start (spec §3.28), in the order the dialog lists them: signed-in subscription
802
+ * accounts first, then API accounts, each by ref. A subscription that is not signed in cannot start one.
803
+ */
804
+ export function aiTaskAccounts(accounts) {
805
+ const by = (x, y) => (x.ref < y.ref ? -1 : x.ref > y.ref ? 1 : 0);
806
+ const subs = accounts.filter((a) => a.kind !== 'api' && a.login === 'logged-in').sort(by);
807
+ const apis = accounts.filter((a) => a.kind === 'api').sort(by);
808
+ return subs.concat(apis);
809
+ }
810
+ /**
811
+ * Whether a quota cell offers "Ask AI why" (spec §3.28): it shows no data or an error the user cannot fix with a
812
+ * button of its own. Quota that is off (it has its own switch), an API account and a Family without a public source
813
+ * do not.
814
+ */
815
+ export function quotaAskable(reason) {
816
+ if (!reason)
817
+ return false;
818
+ return ['not-enabled', 'api-account', 'no-source'].indexOf(reason) < 0;
819
+ }
820
+ /** A file size for an attachment tile (spec §3.28 "附件"): KB below 1 MB, MB with one decimal from there. */
821
+ export function fileSize(bytes) {
822
+ if (bytes < 1024 * 1024)
823
+ return Math.max(1, Math.round(bytes / 1024)) + ' KB';
824
+ const mb = bytes / (1024 * 1024);
825
+ return (mb < 10 ? Math.round(mb * 10) / 10 : Math.round(mb)) + ' MB';
826
+ }
827
+ /** Findings as an AI Task's context carries them: only the fields a Task Prompt shows. */
828
+ export function taskFindings(findings) {
829
+ return findings.map((f) => {
830
+ const out = {
831
+ message: String(f.message),
832
+ };
833
+ if (f.code)
834
+ out.code = f.code;
835
+ if (f.account)
836
+ out.account = f.account;
837
+ if (f.item)
838
+ out.item = f.item;
839
+ if (f.hint)
840
+ out.hint = f.hint;
841
+ return out;
842
+ });
843
+ }
844
+ /**
845
+ * The theme and language the Panel shows (spec §3.29). A Panel that can write takes them from config `panel`; a
846
+ * read-only one from what this browser saved. `language` is empty for the browser's own language.
847
+ */
848
+ export function panelPrefs(config, local, fromConfig) {
849
+ if (!fromConfig)
850
+ return { theme: local.theme, language: local.lang };
851
+ const theme = config.theme === 'light' || config.theme === 'dark' ? config.theme : 'system';
852
+ const language = config.language === 'en' || config.language === 'zh' ? config.language : '';
853
+ return { theme, language };
854
+ }
855
+ /**
856
+ * What this browser's own choice adds to config `panel` the first time this version opens (spec §3.29): only keys
857
+ * config does not have yet, and only a choice the person made, not a default. Null when nothing is to be written.
858
+ */
859
+ export function panelMigration(config, local) {
860
+ const patch = {};
861
+ if (config.theme === undefined && (local.theme === 'light' || local.theme === 'dark'))
862
+ patch.theme = local.theme;
863
+ if (config.language === undefined && (local.lang === 'en' || local.lang === 'zh'))
864
+ patch.language = local.lang;
865
+ return patch.theme || patch.language ? patch : null;
866
+ }
867
+ /**
868
+ * The line under the start-at-login switch (spec §3.29) as a message key, whether it is on, whether the switch can
869
+ * be moved, and the action beside it: `fix` turns it on again so it starts this sideby. Read-only: no switch, no action.
870
+ */
871
+ export function autostartNote(s, readOnly) {
872
+ if (!s)
873
+ return { key: 'as.checking', on: false, toggle: false, action: null };
874
+ if (!s.supported)
875
+ return { key: 'as.unsupported', on: false, toggle: false, action: null };
876
+ if (s.state === 'foreign')
877
+ return { key: 'as.foreign', on: false, toggle: false, action: null };
878
+ const on = s.state === 'on' || s.state === 'stale';
879
+ const fix = s.state === 'stale' ? 'as.stale' : on && !s.current ? 'as.other' : null;
880
+ return { key: fix || 'as.hint', on, toggle: !readOnly, action: fix && !readOnly ? 'fix' : null };
881
+ }
882
+ /**
883
+ * The facts an Auto Handoff AI Task carries (spec §3.29), one per line, named as `sideby handoff status --json`
884
+ * names them: the settings, the order per Family, and where each account's quota comes from. Account refs only,
885
+ * never an email. `status` is the readiness, when the page has it.
886
+ */
887
+ export function handoffTaskFacts(settings, starters, accounts, now, status) {
888
+ const out = [
889
+ 'auto: ' + settings.auto,
890
+ 'sameFamily: ' + settings.sameFamily,
891
+ 'threshold: ' + settings.threshold,
892
+ 'prepareAt: ' + settings.prepareAt,
893
+ ];
894
+ if (settings.waitIfResetWithinMinutes !== undefined)
895
+ out.push('waitIfResetWithinMinutes: ' + settings.waitIfResetWithinMinutes);
896
+ if (settings.countdownSeconds !== undefined)
897
+ out.push('countdownSeconds: ' + settings.countdownSeconds);
898
+ out.push('families that can start a handoff: ' + (starters.length ? starters.join(', ') : 'none'));
899
+ const fams = settings.families || {};
900
+ for (const f of Object.keys(fams).sort()) {
901
+ const o = fams[f];
902
+ out.push('order of ' + f + ': ' + o.policy + (o.order.length ? ' [' + o.order.join(', ') + ']' : ''));
903
+ }
904
+ for (const a of accounts) {
905
+ const q = a.quota;
906
+ let line;
907
+ if (a.kind === 'api')
908
+ line = 'API account, no quota';
909
+ else if (q && q.status === 'ok') {
910
+ const p = quotaPressure(a, now);
911
+ line = 'quota from ' + (q.source || 'unknown') + (p >= 0 ? ', ' + Math.round(p) + '% used' : '');
912
+ }
913
+ else
914
+ line = 'no quota data' + (q?.reason ? ' (' + q.reason + ')' : '');
915
+ if (a.kind !== 'api' && a.login && a.login !== 'logged-in')
916
+ line += ', ' + a.login;
917
+ out.push(a.ref + ': ' + line);
918
+ }
919
+ for (const f of status?.families || [])
920
+ if (!f.ready && f.problems.length)
921
+ out.push('not ready, ' + f.family + ': ' + f.problems.join('; '));
922
+ if (status?.nextSteps?.length)
923
+ out.push('next steps: ' + status.nextSteps.join('; '));
924
+ return out;
925
+ }