cortico 0.1.0 → 0.1.2

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 (112) hide show
  1. package/README.md +7 -14
  2. package/package.json +1 -3
  3. package/src/boot.ts +18 -0
  4. package/src/bot.ts +41 -6
  5. package/src/core/README.md +4 -6
  6. package/src/core/blobs.ts +23 -2
  7. package/src/core/config-schema.ts +1 -1
  8. package/src/core/config.ts +2 -16
  9. package/src/core/core.ts +15 -5
  10. package/src/core/loop.ts +55 -27
  11. package/src/core/session.ts +3 -2
  12. package/src/core/timers.ts +8 -6
  13. package/src/core/types.ts +19 -3
  14. package/src/core/util.ts +17 -1
  15. package/src/deploy.ts +4 -1
  16. package/src/extensions/README.md +6 -4
  17. package/src/extensions/dry-mount.ts +23 -2
  18. package/src/extensions/manifest.ts +21 -11
  19. package/src/extensions.ts +147 -23
  20. package/src/launcher.ts +23 -9
  21. package/src/protocol/open-responses/context-log.ts +37 -9
  22. package/src/providers/README.md +32 -8
  23. package/src/providers/base.ts +3 -1
  24. package/src/providers/configuration.ts +2 -1
  25. package/src/providers/console/hub.ts +270 -0
  26. package/src/providers/console/settings.ts +8 -18
  27. package/src/providers/console/types.ts +5 -0
  28. package/src/providers/hub-api.ts +3 -0
  29. package/src/providers/llamacpp/config.ts +8 -6
  30. package/src/providers/llamacpp/console/runtime-panel.ts +5 -1
  31. package/src/providers/llamacpp/console/server.ts +3 -1
  32. package/src/providers/llamacpp/index.ts +3 -1
  33. package/src/providers/llamacpp/native.ts +10 -5
  34. package/src/providers/llamacpp/strings.ts +6 -6
  35. package/src/providers/name.ts +8 -0
  36. package/src/providers/openai-responses-compat/config.ts +13 -0
  37. package/src/providers/openai-responses-compat/console/client.ts +5 -0
  38. package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
  39. package/src/providers/openai-responses-compat/console/server.ts +127 -0
  40. package/src/providers/openai-responses-compat/index.ts +38 -13
  41. package/src/providers/openai-responses-compat/native.ts +9 -4
  42. package/src/providers/openai-responses-compat/strings.ts +62 -1
  43. package/src/providers/registry.ts +5 -0
  44. package/src/providers/transport/responses-input.ts +59 -10
  45. package/src/web/README.md +11 -6
  46. package/src/web/auth.ts +80 -0
  47. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +5 -4
  48. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
  49. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +6 -8
  50. package/src/web/client/console-pages/host.ts +24 -7
  51. package/src/web/client/core/api.ts +5 -1
  52. package/src/web/client/core/router.ts +15 -0
  53. package/src/web/client/features/core/strings.ts +2 -2
  54. package/src/web/client/features/extensions/index.ts +276 -41
  55. package/src/web/client/features/extensions/strings.ts +84 -10
  56. package/src/web/client/features/feature.ts +2 -2
  57. package/src/web/client/features/live/diagnostics.ts +32 -0
  58. package/src/web/client/features/live/index.ts +34 -11
  59. package/src/web/client/features/live/onboarding.ts +4 -1
  60. package/src/web/client/features/live/protocol.ts +1 -0
  61. package/src/web/client/features/live/strings.ts +20 -14
  62. package/src/web/client/features/live/timeline.ts +2 -1
  63. package/src/web/client/features/providers/detail.ts +262 -0
  64. package/src/web/client/features/providers/drafts.ts +23 -0
  65. package/src/web/client/features/providers/index.ts +173 -87
  66. package/src/web/client/features/providers/strings.ts +44 -17
  67. package/src/web/client/features/providers/types.ts +15 -0
  68. package/src/web/client/features/settings/general.ts +13 -0
  69. package/src/web/client/features/settings/index.ts +1 -0
  70. package/src/web/client/features/settings/strings.ts +6 -0
  71. package/src/web/client/features/worlds/index.ts +1 -0
  72. package/src/web/client/main.ts +4 -0
  73. package/src/web/client/shell/index.ts +28 -48
  74. package/src/web/client/shell/strings.ts +0 -22
  75. package/src/web/client/ui/icons.ts +14 -1
  76. package/src/web/client/ui/prompt-input.tsx +17 -5
  77. package/src/web/client/ui/strings.ts +0 -2
  78. package/src/web/console-pages.ts +1 -1
  79. package/src/web/diagnostics.ts +133 -0
  80. package/src/web/public/login.html +67 -0
  81. package/src/web/public/styles.css +143 -26
  82. package/src/web/server.ts +233 -19
  83. package/src/web/shared/client-panel.ts +4 -1
  84. package/src/web/shared/console-protocol.ts +4 -1
  85. package/src/worlds/bilibili/README.md +1 -1
  86. package/src/worlds/bilibili/overlay/server.ts +4 -2
  87. package/src/worlds/minecraft/ADAPT.md +66 -0
  88. package/src/worlds/minecraft/README.md +69 -11
  89. package/src/worlds/minecraft/cell-facts.ts +226 -0
  90. package/src/worlds/minecraft/chests.ts +5 -0
  91. package/src/worlds/minecraft/containers.ts +325 -0
  92. package/src/worlds/minecraft/entity-facts.ts +31 -5
  93. package/src/worlds/minecraft/executor.ts +283 -10348
  94. package/src/worlds/minecraft/inventory.ts +268 -0
  95. package/src/worlds/minecraft/melee.ts +419 -0
  96. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  97. package/src/worlds/minecraft/placed-ledger.ts +152 -0
  98. package/src/worlds/minecraft/placement.ts +1038 -0
  99. package/src/worlds/minecraft/receipt.ts +340 -0
  100. package/src/worlds/minecraft/skill-context.ts +358 -0
  101. package/src/worlds/minecraft/skills-build.ts +1169 -0
  102. package/src/worlds/minecraft/skills-container.ts +1343 -0
  103. package/src/worlds/minecraft/skills-craft.ts +333 -0
  104. package/src/worlds/minecraft/skills-dig.ts +624 -0
  105. package/src/worlds/minecraft/skills-gather.ts +1230 -0
  106. package/src/worlds/minecraft/skills-interact.ts +1559 -0
  107. package/src/worlds/minecraft/tools.ts +331 -0
  108. package/src/worlds/minecraft/travel.ts +763 -0
  109. package/src/worlds/minecraft/until.ts +75 -0
  110. package/src/worlds/qq/normalize.ts +14 -0
  111. package/src/worlds/qq/world.ts +53 -7
  112. package/src/worlds/terminal/world.ts +5 -3
package/src/core/types.ts CHANGED
@@ -516,6 +516,7 @@ export interface TimerEntry {
516
516
 
517
517
  /** 跨重启恢复的定时器。 */
518
518
  export interface TimersApi {
519
+ /** 到期回调在 set 返回之后才发生,包括 atIso 已经过去的条目。 */
519
520
  set(atIso: string, payload?: Record<string, unknown>): { ok: true; id: string } | { ok: false; error: string };
520
521
  cancel(id: string): boolean;
521
522
  list(): ReadonlyArray<TimerEntry>;
@@ -833,8 +834,11 @@ export interface World {
833
834
  * 语言不缓存,不影响发给模型的文本;未实现时仅显示通用信息。
834
835
  */
835
836
  console?(language?: Language): WorldConsoleDecl;
836
- /** 主 session 的输出流接收器;装配层合并已挂载 World 的接收器,随挂载变化更新。 */
837
- outputTap?(): OutputTap;
837
+ /**
838
+ * 主 session 的输出流接收器;装配层合并已挂载 World 的接收器,随挂载变化更新。
839
+ * 这一刻没有接收器(未连接、模式关着)时返回 undefined,合并侧跳过。
840
+ */
841
+ outputTap?(): OutputTap | undefined;
838
842
  /** 挂载时接收宿主并开始连接平台、推送事件。 */
839
843
  start(host: WorldHost): Promise<void>;
840
844
  stop(): Promise<void>;
@@ -930,7 +934,7 @@ export interface Persona {
930
934
  /**
931
935
  * session 的合成开头:每次请求置于 system 前缀之后、持久历史之前,不写入 session,交接时
932
936
  * 不进保留内容。Core 每次出请求前调用一次,丢弃 system 与 developer 项,补齐工具配对,
933
- * 计入 hardTokens。内容由 Persona 的当前输入决定。
937
+ * 计入 hardTokens。内容只应随 Persona 自己的输入变化;每次不同就每次打穿前缀缓存。
934
938
  */
935
939
  sessionHead?(): Item[];
936
940
  /** Memory 实例。Core 不读它的内容;bot 没给 memoryName 时控制台以它的类名作 Memory 页标题。 */
@@ -1120,6 +1124,18 @@ export interface CoreConfig {
1120
1124
  };
1121
1125
  web: {
1122
1126
  port: number;
1127
+ /** 控制台监听地址;缺省 127.0.0.1。`0.0.0.0` 或 `::` 监听所有网卡。 */
1128
+ host?: string;
1129
+ /**
1130
+ * 回环名与监听地址之外还接受的 Host 名(域名、反向代理对外的名字),同时放行以它为
1131
+ * Origin 的写请求与 WebSocket;缺省为空。
1132
+ */
1133
+ allowedHosts?: string[];
1134
+ /**
1135
+ * 控制台访问密码;缺省或空串时不要求登录。密钥 `CORTICO_WEB_PASSWORD`(进程环境或部署
1136
+ * `.env`)非空时优先于此键。
1137
+ */
1138
+ password?: string;
1123
1139
  /** 控制台配色方案 id;浏览器没有保存过选择时用它,认不出的 id 落到框架默认方案。 */
1124
1140
  theme: string;
1125
1141
  };
package/src/core/util.ts CHANGED
@@ -372,7 +372,8 @@ export function shortId(prefix = ''): string {
372
372
  }
373
373
 
374
374
  /**
375
- * BOM 读取 UTF-8 UTF-16 LE 文本,返回值不含 BOM;无 BOM 时使用 UTF-8。
375
+ * 读一个手写的文本文件:按 BOM 选编码,UTF-8 UTF-16 LE 都读得出来,BOM 不进返回值。
376
+ * 没有 BOM 就是 UTF-8。Windows 的 shell 重定向写出的是带 BOM 的 UTF-8 或 UTF-16 LE。
376
377
  */
377
378
  export function readTextFile(file: string): string {
378
379
  const bytes = readFileSync(file);
@@ -384,3 +385,18 @@ export function readTextFile(file: string): string {
384
385
  }
385
386
  return bytes.toString('utf8');
386
387
  }
388
+
389
+ /** 键名像凭据的值抹成 `***`;数组与嵌套对象逐层走。导出记录与配置副本前过一遍。 */
390
+ const SECRET_KEY = /secret|token|key|password/i;
391
+
392
+ export function redactSecrets(value: unknown): unknown {
393
+ if (Array.isArray(value)) return value.map(redactSecrets);
394
+ if (value && typeof value === 'object') {
395
+ const out: Record<string, unknown> = {};
396
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
397
+ out[k] = SECRET_KEY.test(k) ? '***' : redactSecrets(v);
398
+ }
399
+ return out;
400
+ }
401
+ return value;
402
+ }
package/src/deploy.ts CHANGED
@@ -27,7 +27,10 @@ export function listBots(root = deploymentRoot()): string[] {
27
27
  /** 部署根下一份部署都没有时自建的那份,以及它引用的 bot 代码包。 */
28
28
  export const DEFAULT_DEPLOYMENT = { name: 'mybot', bot: 'cormini' } as const;
29
29
 
30
- /** 部署目录中的开场引导标记。 */
30
+ /**
31
+ * 开场引导的一次性标记,放在部署目录下。自建部署时写入,控制台见到它才给引导,操作员
32
+ * 开口或按下那颗按钮后删除。手动建的部署没有这个文件,也就不会看到引导。
33
+ */
31
34
  export const ONBOARDING_FLAG_FILE = '.onboarding';
32
35
 
33
36
  /** 部署根下另有用途的目录名,不能拿来当部署名。 */
@@ -19,7 +19,7 @@ World 调用 `create()` / `tools()` / `console()`,provider 调用 `create()`,bot
19
19
  "keywords": ["cortico-world"], // npm 搜索按类关键字:cortico-world / cortico-provider / cortico-bot
20
20
  "cortico": {
21
21
  "kind": "world", // world | provider | bot
22
- "api": 4, // 扩展契约版本,与 EXTENSION_API_VERSION 相等才加载
22
+ "api": 5, // 这一类的契约版本,与 EXTENSION_API_VERSIONS[kind] 相等才加载
23
23
  "consoleClient": "dist/console.js", // 可选:预构建的面板 bundle,包内相对路径
24
24
  "consoleStyle": "dist/console.css" // 可选:随 bundle 注入的样式
25
25
  }
@@ -27,9 +27,11 @@ World 调用 `create()` / `tools()` / `console()`,provider 调用 `create()`,bot
27
27
  ```
28
28
 
29
29
  `parseExtensionManifest(pkg)` 只做解析与校验,不碰文件系统;装载器与
30
- `pnpm check:extension <dir>` 共用它。`api` 与框架不等时不加载,扩展页说明哪一边旧。
31
- `WorldDefinition`、`ProviderModule`、`BotDefinition`(连同 `BotParts`、`Persona`、`LoadedConfig`)
32
- `ConsolePanelContext` 任一不兼容变更就把 `EXTENSION_API_VERSION` 加一。同一次发布里的多处变更合计加一。
30
+ `pnpm check:extension <dir>` 共用它。`api` 与这一类的契约版本不等时不加载,扩展页说明哪一边旧。
31
+
32
+ `EXTENSION_API_VERSIONS` kind 各记一个版本:`WorldDefinition` 不兼容变更只加 world,
33
+ `ProviderModule` 只加 provider,`BotDefinition`(连同 `BotParts`、`Persona`、`LoadedConfig`)只加 bot;
34
+ `ConsolePanelContext` 与其余共用接口变更时三类一起加一。同一次发布里的多处变更合计加一。
33
35
 
34
36
  ## 装载
35
37
 
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * 在临时部署中检查扩展构造与声明接口,不调用 start()。
3
- * World 使用默认配置、无密钥,检查 create、tools、envPromptVars、console 与工具名冲突;
3
+ * World 使用默认配置、无密钥,检查 create、tools、envPromptVars、console、工具名冲突与配置路径;
4
4
  * provider 使用测试端点 create,bot 使用测试部署 build。
5
5
  * World 构造失败为错误;provider 仅在绑定端点时构造,测试条目不完整导致的失败记为警告。
6
6
  * 临时文件位于 scratchDir,调用方负责创建与清理。
7
7
  */
8
8
  import { existsSync, mkdirSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
- import type { CoreConfig, World } from '../core/types.ts';
10
+ import type { ConfigGroup, CoreConfig, World } from '../core/types.ts';
11
11
  import { MODULE_LAMP_MAX } from '../core/types.ts';
12
12
  import type { LoadedConfig } from '../core/config.ts';
13
13
  import { CORE_DEFAULTS } from '../core/config.ts';
14
+ import { getByPath } from '../core/config-schema.ts';
14
15
  import { RESERVED_FRAME_NAMES } from '../core/loop.ts';
15
16
  import { nullLogger } from '../core/util.ts';
16
17
  import type { Language } from '../core/language.ts';
@@ -38,6 +39,24 @@ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
38
39
 
39
40
  const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
40
41
 
42
+ /**
43
+ * 声明过的每个配置路径都要在 `defaults()` 里有对应项,且落在 prefix 指的段内。
44
+ * 缺默认值时控制台照样渲染旋钮、也照样写回 config.json,而读到的是代码里另一处的兜底值。
45
+ */
46
+ function configPathProblems(group: ConfigGroup, defaults: Record<string, unknown>, prefix: string): string[] {
47
+ const problems: string[] = [];
48
+ for (const path of Object.keys(group.schema.properties ?? {})) {
49
+ if (!path.startsWith(prefix)) {
50
+ problems.push(`配置组「${group.id}」声明的「${path}」不在 ${prefix} 段里:写回按路径走,值会落到别人的段上。`);
51
+ continue;
52
+ }
53
+ if (getByPath(defaults, path.slice(prefix.length)) === undefined) {
54
+ problems.push(`配置组「${group.id}」声明的「${path}」在 defaults() 里没有对应项:旋钮能改、能写回 config.json,读到的仍是代码里的兜底值。`);
55
+ }
56
+ }
57
+ return problems;
58
+ }
59
+
41
60
  /** 面板 id 的形状:控制台一页内的局部 id。 */
42
61
  const PANEL_ID = /^[a-z0-9-]+$/;
43
62
  /** 工具名带前缀:`<短名>_` 起头。 */
@@ -190,6 +209,7 @@ export async function dryMountWorld(def: WorldDefinition<WorldSection>, opts: Wo
190
209
  }
191
210
  for (const group of decl.config ?? []) {
192
211
  if (group.owner !== `world:${def.id}`) warnings.push(`配置组「${group.id}」的 owner 是「${group.owner}」,World 的配置组 owner 应为 world:${def.id}。`);
212
+ failures.push(...configPathProblems(group, defaults, `worlds.${def.id}.`));
193
213
  }
194
214
  const keys = new Set<string>();
195
215
  for (const doc of decl.promptDocs ?? []) {
@@ -385,6 +405,7 @@ export function dryMountBot(def: BotDefinition<CoreConfig>, opts: BotDryMountOpt
385
405
 
386
406
  for (const group of parts.console?.configGroups ?? []) {
387
407
  if (group.owner !== 'persona') warnings.push(`配置组「${group.id}」的 owner 是「${group.owner}」,Persona 的配置组 owner 应为 persona。`);
408
+ failures.push(...configPathProblems(group, config, ''));
388
409
  }
389
410
  return report;
390
411
  }
@@ -1,18 +1,25 @@
1
1
  /**
2
2
  * 解析 package.json 的 cortico 扩展声明,不访问文件系统;装载器与 check:extension 共用。
3
- * kind 必须为 world、provider 或 bot;api 必须等于 EXTENSION_API_VERSION。
4
- * WorldDefinition、ProviderModule、BotDefinition 及其可达接口或 ConsolePanelContext
5
- * 发生不兼容变更时递增版本。
3
+ * kind 必须为 world、provider 或 bot;api 必须等于这一类的契约版本。
6
4
  * consoleClient 和 consoleStyle 是预构建产物的包内相对路径;服务端分配 URL。
7
5
  * 包必须使用 type=module,使扩展与框架通过同一 ESM 解析方式共享模块实例。
8
6
  */
9
7
 
10
- /** 扩展接口发生不兼容变更时递增。 */
11
- export const EXTENSION_API_VERSION = 4;
12
-
13
8
  export type ExtensionKind = 'world' | 'provider' | 'bot';
14
9
  export const EXTENSION_KINDS: readonly ExtensionKind[] = ['world', 'provider', 'bot'];
15
10
 
11
+ /**
12
+ * 三类扩展各自的契约版本。一类的接口不兼容变更只递增这一类:
13
+ * world 看 `WorldDefinition`,provider 看 `ProviderModule`,bot 看 `BotDefinition`
14
+ * (连同 `BotParts`、`Persona`、`LoadedConfig`);`ConsolePanelContext` 与其余共用接口
15
+ * 变更时三类一起加一。同一次发布里的多处变更合计加一。
16
+ */
17
+ export const EXTENSION_API_VERSIONS: Readonly<Record<ExtensionKind, number>> = {
18
+ world: 5,
19
+ provider: 5,
20
+ bot: 5,
21
+ };
22
+
16
23
  /** npm 上按类发现用的关键字。 */
17
24
  export const EXTENSION_KEYWORDS: Readonly<Record<ExtensionKind, string>> = {
18
25
  world: 'cortico-world',
@@ -79,12 +86,15 @@ export function parseExtensionManifest(pkg: ExtensionPackageJson): ExtensionMani
79
86
  }
80
87
 
81
88
  const api = m.api;
89
+ // kind 认不出时不比版本:每一类的契约版本各走各的,不知道是哪一类就没有可比的数。
90
+ const expected = kindOk ? EXTENSION_API_VERSIONS[kind as ExtensionKind] : undefined;
82
91
  if (typeof api !== 'number' || !Number.isInteger(api) || api < 1) {
83
- reasons.push(`cortico.api 必须是正整数(当前框架契约 v${EXTENSION_API_VERSION}),现在是 ${JSON.stringify(api)}。`);
84
- } else if (api < EXTENSION_API_VERSION) {
85
- reasons.push(`扩展按契约 v${api} 编写,本框架是 v${EXTENSION_API_VERSION}:扩展需要升级。`);
86
- } else if (api > EXTENSION_API_VERSION) {
87
- reasons.push(`扩展要求契约 v${api},本框架只到 v${EXTENSION_API_VERSION}:框架需要升级。`);
92
+ const note = expected === undefined ? '' : `(本框架的 ${kind} 契约是 v${expected})`;
93
+ reasons.push(`cortico.api 必须是正整数${note},现在是 ${JSON.stringify(api)}。`);
94
+ } else if (expected !== undefined && api < expected) {
95
+ reasons.push(`扩展按 ${kind} 契约 v${api} 编写,本框架的 ${kind} 契约是 v${expected}:扩展需要升级。`);
96
+ } else if (expected !== undefined && api > expected) {
97
+ reasons.push(`扩展要求 ${kind} 契约 v${api},本框架的 ${kind} 契约只到 v${expected}:框架需要升级。`);
88
98
  }
89
99
 
90
100
  const client = m.consoleClient;
package/src/extensions.ts CHANGED
@@ -14,8 +14,14 @@ import type { CoreConfig, Logger } from './core/types.ts';
14
14
  import type { BotDefinition } from './bot.ts';
15
15
  import type { WorldDefinition, WorldSection } from './world.ts';
16
16
  import type { ProviderModule } from './providers/base.ts';
17
- import type { ExtensionInfo, ExtensionInstallTarget, ExtensionSearchHit } from './web/server.ts';
17
+ import type {
18
+ ExtensionInfo,
19
+ ExtensionInstallTarget,
20
+ ExtensionPackageDetail,
21
+ ExtensionSearchHit,
22
+ } from './web/server.ts';
18
23
  import {
24
+ EXTENSION_API_VERSIONS,
19
25
  EXTENSION_KEYWORDS,
20
26
  parseExtensionManifest,
21
27
  type ExtensionConsoleAsset,
@@ -368,11 +374,59 @@ interface RegistrySearchResponse {
368
374
  description?: string;
369
375
  keywords?: string[];
370
376
  date?: string;
377
+ license?: string;
371
378
  links?: { npm?: string; homepage?: string; repository?: string };
372
379
  publisher?: { username?: string };
373
380
  };
381
+ /** 被依赖的包数。热门包回字符串,新包回数字。 */
382
+ dependents?: number | string;
374
383
  downloads?: { monthly?: number };
375
384
  }>;
385
+ total?: number;
386
+ }
387
+
388
+ /** registry 的包文档(`GET /<name>`)。只列我们要读的字段。 */
389
+ interface RegistryPackument {
390
+ 'dist-tags'?: Record<string, string>;
391
+ versions?: Record<string, ExtensionPackageJson & {
392
+ license?: string;
393
+ deprecated?: string;
394
+ engines?: Record<string, string>;
395
+ maintainers?: Array<{ username?: string; name?: string }>;
396
+ _npmUser?: { name?: string };
397
+ dist?: { unpackedSize?: number; fileCount?: number };
398
+ homepage?: string;
399
+ bugs?: { url?: string } | string;
400
+ repository?: { url?: string } | string;
401
+ }>;
402
+ /** `created`、`modified` 和每个版本号各一条发布时间 */
403
+ time?: Record<string, string>;
404
+ }
405
+
406
+ /** 一页取满 250:registry 的上限(传更大也只回 250)。 */
407
+ const SEARCH_PAGE_SIZE = 250;
408
+ /**
409
+ * 最多翻四页。`keywords:` 过滤后的全集现在是个位数;真涨到一千条,说明关键字被滥用,
410
+ * 再往后翻的也不是操作员要找的包。
411
+ */
412
+ const SEARCH_MAX_HITS = 1000;
413
+
414
+ /**
415
+ * registry 给的仓库地址是 npm 规范化过的 `git+https://….git`:浏览器不认这个 scheme。
416
+ * 收成可点的 https;认不出形状就原样返回,让操作员自己看。
417
+ */
418
+ export function repositoryWebUrl(raw: string): string {
419
+ let url = raw.trim().replace(/^git\+/, '').replace(/^git:\/\//, 'https://');
420
+ const ssh = /^(?:ssh:\/\/)?git@([^:/]+)[:/](.+)$/.exec(url);
421
+ if (ssh) url = `https://${ssh[1]}/${ssh[2]}`;
422
+ url = url.replace(/\.git$/, '');
423
+ return /^https?:\/\//.test(url) ? url : raw;
424
+ }
425
+
426
+ /** `{ url }` 或裸串两种写法都收。 */
427
+ function urlOf(v: { url?: string } | string | undefined): string | undefined {
428
+ const raw = typeof v === 'string' ? v : v?.url;
429
+ return raw && raw.trim() ? raw.trim() : undefined;
376
430
  }
377
431
 
378
432
  export interface ExtensionManagerOptions {
@@ -441,35 +495,105 @@ export class ExtensionManager {
441
495
  return { dir: this.dir, extensions: out };
442
496
  }
443
497
 
444
- async search(query: string, kind: ExtensionKind = 'world'): Promise<ExtensionSearchHit[]> {
498
+ /**
499
+ * 列出 npm 上带这一类关键字的全部包。**不接文本查询**:registry 的 `text=` 在
500
+ * `keywords:` 过滤之下只影响排序不缩小结果(实测 `keywords:cortico-world 任意词`
501
+ * 仍返回同样 8 条),筛选交给控制台在整份结果上做。
502
+ */
503
+ async search(kind: ExtensionKind = 'world'): Promise<ExtensionSearchHit[]> {
445
504
  const keyword = EXTENSION_KEYWORDS[kind];
446
- const text = `keywords:${keyword} ${query.trim()}`.trim();
447
- const url = `${this.registry}/-/v1/search?text=${encodeURIComponent(text)}&size=50`;
448
- const data = (await this.fetchJson(url)) as RegistrySearchResponse;
449
505
  const installed = new Set(readInstalled(this.dir).map((p) => p.name));
450
506
  const hits: ExtensionSearchHit[] = [];
451
- for (const obj of data.objects ?? []) {
452
- const p = obj.package;
453
- if (!p?.name || !p.version || !(p.keywords ?? []).includes(keyword)) continue;
454
- hits.push({
455
- name: p.name,
456
- version: p.version,
457
- description: p.description ?? '',
458
- kind,
459
- ...(p.date ? { date: p.date } : {}),
460
- ...(p.publisher?.username ? { publisher: p.publisher.username } : {}),
461
- downloads: obj.downloads?.monthly ?? 0,
462
- links: {
463
- ...(p.links?.npm ? { npm: p.links.npm } : {}),
464
- ...(p.links?.repository ? { repository: p.links.repository } : {}),
465
- ...(p.links?.homepage ? { homepage: p.links.homepage } : {}),
466
- },
467
- installed: installed.has(p.name),
468
- });
507
+ const seen = new Set<string>();
508
+ for (let from = 0; from < SEARCH_MAX_HITS; from += SEARCH_PAGE_SIZE) {
509
+ const text = encodeURIComponent(`keywords:${keyword}`);
510
+ const url = `${this.registry}/-/v1/search?text=${text}&size=${SEARCH_PAGE_SIZE}&from=${from}`;
511
+ const data = (await this.fetchJson(url)) as RegistrySearchResponse;
512
+ const objects = data.objects ?? [];
513
+ for (const obj of objects) {
514
+ const p = obj.package;
515
+ if (!p?.name || !p.version || !(p.keywords ?? []).includes(keyword)) continue;
516
+ if (seen.has(p.name)) continue;
517
+ seen.add(p.name);
518
+ const repository = p.links?.repository;
519
+ hits.push({
520
+ name: p.name,
521
+ version: p.version,
522
+ description: p.description ?? '',
523
+ kind,
524
+ ...(p.date ? { date: p.date } : {}),
525
+ ...(p.publisher?.username ? { publisher: p.publisher.username } : {}),
526
+ ...(p.license ? { license: p.license } : {}),
527
+ ...(p.keywords?.length ? { keywords: p.keywords } : {}),
528
+ downloads: obj.downloads?.monthly ?? 0,
529
+ dependents: Number(obj.dependents) || 0,
530
+ links: {
531
+ ...(p.links?.npm ? { npm: p.links.npm } : {}),
532
+ ...(repository ? { repository: repositoryWebUrl(repository) } : {}),
533
+ ...(p.links?.homepage ? { homepage: p.links.homepage } : {}),
534
+ },
535
+ installed: installed.has(p.name),
536
+ });
537
+ }
538
+ if (objects.length < SEARCH_PAGE_SIZE) break;
469
539
  }
470
540
  return hits;
471
541
  }
472
542
 
543
+ /**
544
+ * 一个包的详情。控制台在操作员点开某张卡片时才调,因此这里取整份包文档
545
+ * (`GET /<name>`,比搜索结果多出 cortico 声明、许可证、体积与版本史)。
546
+ * readme 不回传:一份 30KB 以上的 markdown,控制台也不渲染它。
547
+ */
548
+ async packageInfo(name: string): Promise<ExtensionPackageDetail> {
549
+ if (!PACKAGE_NAME.test(name)) throw new Error(`不是合法的 npm 包名: ${name}`);
550
+ const doc = (await this.fetchJson(`${this.registry}/${name.replace('/', '%2F')}`)) as RegistryPackument;
551
+ const latest = doc['dist-tags']?.latest;
552
+ const v = latest ? doc.versions?.[latest] : undefined;
553
+ if (!latest || !v) throw new Error(`registry 没有给出 ${name} 的 latest 版本`);
554
+
555
+ const parsed = parseExtensionManifest(v);
556
+ const times = Object.entries(doc.time ?? {}).filter(([k]) => k !== 'created' && k !== 'modified');
557
+ const history = times
558
+ .sort((a, b) => (a[1] < b[1] ? 1 : -1))
559
+ .slice(0, 6)
560
+ .map(([version, date]) => ({ version, date }));
561
+ const spec = readInstalled(this.dir).find((p) => p.name === name)?.spec;
562
+ const repository = urlOf(v.repository);
563
+ const bugs = urlOf(v.bugs);
564
+
565
+ return {
566
+ name,
567
+ version: latest,
568
+ ...(v.description ? { description: v.description } : {}),
569
+ ...(v.license ? { license: v.license } : {}),
570
+ ...(v.keywords?.length ? { keywords: v.keywords } : {}),
571
+ ...(doc.time?.[latest] ? { published: doc.time[latest] } : {}),
572
+ ...(doc.time?.created ? { created: doc.time.created } : {}),
573
+ versionCount: times.length,
574
+ history,
575
+ ...(v.deprecated ? { deprecated: v.deprecated } : {}),
576
+ ...(parsed.ok
577
+ ? { manifest: parsed.manifest, frameworkApi: EXTENSION_API_VERSIONS[parsed.manifest.kind] }
578
+ : { problems: parsed.reasons }),
579
+ warnings: parsed.warnings,
580
+ ...(v.engines?.node ? { engines: v.engines.node } : {}),
581
+ ...(v.dist?.unpackedSize ? { unpackedSize: v.dist.unpackedSize } : {}),
582
+ ...(v.dist?.fileCount ? { fileCount: v.dist.fileCount } : {}),
583
+ dependencies: Object.keys(v.dependencies ?? {}),
584
+ maintainers: (v.maintainers ?? []).map((m) => m.username ?? m.name ?? '').filter(Boolean),
585
+ ...(v._npmUser?.name ? { publisher: v._npmUser.name } : {}),
586
+ links: {
587
+ npm: `https://www.npmjs.com/package/${name}`,
588
+ ...(repository ? { repository: repositoryWebUrl(repository) } : {}),
589
+ ...(v.homepage ? { homepage: v.homepage } : {}),
590
+ ...(bugs ? { bugs } : {}),
591
+ },
592
+ installed: spec !== undefined,
593
+ ...(spec !== undefined ? { installedSpec: spec } : {}),
594
+ };
595
+ }
596
+
473
597
  async install(target: ExtensionInstallTarget): Promise<string> {
474
598
  const spec = this.installSpec(target);
475
599
  if (!existsSync(this.dir)) mkdirSync(this.dir, { recursive: true });
package/src/launcher.ts CHANGED
@@ -13,7 +13,7 @@ import { createBot } from './bot.ts';
13
13
  import { createDeployment, ensureDeployment, listBots, loadDeployment } from './deploy.ts';
14
14
  import { buildListing, type BotDefaults } from './deploy-listing.ts';
15
15
  import { secretReader } from './core/secrets.ts';
16
- import { announceDataDir, consumeBootFlags } from './boot.ts';
16
+ import { announceDataDir, consoleUrlOf, consumeBootFlags, listensOnEveryInterface, startsPaused } from './boot.ts';
17
17
  import { extensionsDir, importBotDefinition, loadExtensions, locateBotPackage, readInstalled, type ActiveBotPackage } from './extensions.ts';
18
18
  import { withWorlds } from './world.ts';
19
19
  import { BUILTIN_WORLDS } from './worlds/index.ts';
@@ -160,6 +160,22 @@ async function main(): Promise<void> {
160
160
  cfg.logging.file = levelArg as LogLevel;
161
161
  }
162
162
 
163
+ // 监听地址与端口:--host= / --port= 只覆盖本次运行,不写回 config.json。
164
+ const hostArg = flagValue('host');
165
+ if (hostArg !== null) {
166
+ if (!hostArg.trim()) { console.error('--host 需要一个地址,如 0.0.0.0'); process.exit(1); }
167
+ cfg.web.host = hostArg.trim();
168
+ }
169
+ const portArg = flagValue('port');
170
+ if (portArg !== null) {
171
+ const port = Number(portArg);
172
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
173
+ console.error(`无效端口: ${portArg}(0–65535 的整数,0 由系统分配)`);
174
+ process.exit(1);
175
+ }
176
+ cfg.web.port = port;
177
+ }
178
+
163
179
  // 重启标志必须在装配与 session 加载之前处理
164
180
  consumeBootFlags(loaded.dataDir);
165
181
 
@@ -179,17 +195,15 @@ async function main(): Promise<void> {
179
195
  const bot = createBot(loaded, definition, { extensions });
180
196
 
181
197
  // 必须在启动主循环前暂停,避免首批事件提前投递。
182
- const startPaused =
183
- process.env.CORTICO_START_PAUSED === '1' ||
184
- process.env.CORTICO_START_PAUSED === 'true' ||
185
- process.argv.includes('--paused');
186
- if (startPaused) bot.core.bus.setPaused(true);
198
+ const paused = startsPaused();
199
+ if (paused) bot.core.bus.setPaused(true);
187
200
 
188
201
  const { port } = await bot.start();
189
202
 
190
203
  console.log(`\n Bot: ${botName}${cfg.displayName && cfg.displayName !== botName ? ` (${cfg.displayName})` : ''}`);
204
+ const consoleUrl = port === null ? null : consoleUrlOf(bot.webApp?.boundAddress ?? null, port);
191
205
  if (port !== null) {
192
- console.log(` 控制台: http://127.0.0.1:${port}/`);
206
+ console.log(` 控制台: ${consoleUrl}${listensOnEveryInterface(bot.webApp?.boundAddress ?? null) ? `(监听 ${bot.webApp?.boundAddress},所有网卡)` : ''}`);
193
207
  const assetsProblem = webAssetsProblem(fileURLToPath(new URL('../dist/web', import.meta.url)));
194
208
  if (assetsProblem) {
195
209
  console.log(` ⚠ 控制台产物不完整(${assetsProblem});停止 bot 后运行 pnpm build:web`);
@@ -214,7 +228,7 @@ async function main(): Promise<void> {
214
228
  const where = existsSync(envFile) ? `${envFile} 里也没有` : `${envFile} 不存在`;
215
229
  console.log(` ⚠ 缺少 ${missingSecret}:进程环境里没有,${where};可在控制台「模型提供商」页修改密钥变量名或补填密钥`);
216
230
  }
217
- if (startPaused) {
231
+ if (paused) {
218
232
  console.log(' ⏸ 已暂停');
219
233
  }
220
234
  console.log('');
@@ -223,7 +237,7 @@ async function main(): Promise<void> {
223
237
  process.env.CORTICO_OPEN_BROWSER === '1' ||
224
238
  process.env.CORTICO_OPEN_BROWSER === 'true' ||
225
239
  process.argv.includes('--open');
226
- if (openBrowserFlag && port !== null) openBrowser(`http://127.0.0.1:${port}/`);
240
+ if (openBrowserFlag && consoleUrl !== null) openBrowser(consoleUrl);
227
241
 
228
242
  // 此时限覆盖整个关机流程;各步骤的时限由 bot.shutdown() 管理。
229
243
  const SHUTDOWN_GRACE_MS = 35_000;
@@ -1,4 +1,4 @@
1
- import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, truncateSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import type { ContextRecord } from './context.ts';
4
4
 
@@ -10,6 +10,20 @@ function freeze<T>(value: T): T {
10
10
  return value;
11
11
  }
12
12
 
13
+ function isRecord(row: unknown): row is ContextRecord {
14
+ const r = row as ContextRecord | null;
15
+ return !!r && r.version === 2 && !!r.item && typeof r.item === 'object' && !Array.isArray(r.item)
16
+ && !!r.context && typeof r.context === 'object' && !Array.isArray(r.context);
17
+ }
18
+
19
+ /**
20
+ * The repair `load()` applied to a final line that has no trailing newline. A line that does not
21
+ * parse is cut off; a complete record only gets its newline.
22
+ */
23
+ export type ContextLoadRepair =
24
+ | { kind: 'torn-tail'; bytes: number }
25
+ | { kind: 'unterminated-tail' };
26
+
13
27
  /** Immutable standard Items with runtime metadata stored in a separate field. */
14
28
  export class ContextLog {
15
29
  private entries: readonly ContextRecord[] = Object.freeze([]);
@@ -19,18 +33,32 @@ export class ContextLog {
19
33
 
20
34
  get records(): readonly ContextRecord[] { return this.entries; }
21
35
 
22
- load(): void {
23
- if (!existsSync(this.file)) { this.entries = Object.freeze([]); return; }
24
- const rows = readFileSync(this.file, 'utf8').split(/\r?\n/).filter(line => line.trim()).map((line, index) => {
25
- let row: ContextRecord;
26
- try { row = JSON.parse(line) as ContextRecord; }
36
+ /** Any other damaged line throws and leaves the file untouched. */
37
+ load(): ContextLoadRepair | null {
38
+ if (!existsSync(this.file)) { this.entries = Object.freeze([]); return null; }
39
+ const raw = readFileSync(this.file, 'utf8');
40
+ let body = raw;
41
+ let repair: ContextLoadRepair | null = null;
42
+ if (raw.length > 0 && !raw.endsWith('\n')) {
43
+ const cut = raw.lastIndexOf('\n') + 1;
44
+ const tail = raw.slice(cut);
45
+ let parsed: unknown = null;
46
+ try { parsed = JSON.parse(tail); } catch { /* torn */ }
47
+ if (isRecord(parsed)) repair = { kind: 'unterminated-tail' };
48
+ else { body = raw.slice(0, cut); repair = { kind: 'torn-tail', bytes: Buffer.byteLength(tail, 'utf8') }; }
49
+ }
50
+ const rows = body.split(/\r?\n/).filter(line => line.trim()).map((line, index) => {
51
+ let row: unknown;
52
+ try { row = JSON.parse(line); }
27
53
  catch { throw new Error(`Invalid session JSON at ${this.file}:${index + 1}`); }
28
- if (!row || row.version !== 2 || !row.item || typeof row.item !== 'object' || Array.isArray(row.item)
29
- || !row.context || typeof row.context !== 'object' || Array.isArray(row.context))
30
- throw new Error(`Invalid context record at ${this.file}:${index + 1}`);
54
+ if (!isRecord(row)) throw new Error(`Invalid context record at ${this.file}:${index + 1}`);
31
55
  return freeze(row);
32
56
  });
57
+ // Written only after every kept line validated.
58
+ if (repair?.kind === 'torn-tail') truncateSync(this.file, Buffer.byteLength(body, 'utf8'));
59
+ else if (repair) appendFileSync(this.file, '\n');
33
60
  this.entries = Object.freeze(rows);
61
+ return repair;
34
62
  }
35
63
 
36
64
  append(entry: ContextRecord): ContextRecord {
@@ -1,4 +1,4 @@
1
- <!-- Owner: src/providers/base.ts, src/providers/registry.ts, src/providers/console/settings.ts, src/providers/console/config.ts, src/providers/openai-responses-compat/config.ts -->
1
+ <!-- Owner: src/providers/base.ts, src/providers/console/hub.ts, src/providers/hub-api.ts, src/providers/name.ts, src/providers/registry.ts, src/providers/console/settings.ts, src/providers/console/config.ts, src/providers/openai-responses-compat/config.ts, src/providers/transport/responses-input.ts, src/providers/transport/history.ts, src/providers/llamacpp/native.ts -->
2
2
 
3
3
  # src/providers
4
4
 
@@ -22,15 +22,16 @@
22
22
  ## ProviderModule
23
23
 
24
24
  `id` 必须等于目录名(扩展包里则是包声明的 id)。必填:`title`、`reasoningTiers`(空表 = 开放,
25
- effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可选:`defaultBaseUrl` 与
25
+ effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可选:`description`、`defaultBaseUrl` 与
26
26
  `baseUrlSuggestions`、`effortSuggestions`、`temperatureNote`、`localize()`、`normalize()`、
27
27
  `validateEntry()`、`validateModel()`、`accepts()`(多模态判定)、`config()` 与 `console()`
28
28
  (附加配置组与面板)、`prices()`、`estimateTokens()`、`contextOverflow()`。
29
29
 
30
- 地址、密钥变量名与图像开关由 `console/config.ts` 声明为 ConfigGroup。模块自己的
31
- 配置通过 `config()` 声明。模块可在 `instance` 插槽中渲染这些 schema,
32
- 通过 `ctx.setConfig` 保存;`console().config` 显式为空时省略独立配置页签,
33
- 配置组仍参与服务端校验。运行时启停和模型操作由面板 invoke 提供。
30
+ 地址、密钥变量名与图像开关不归模块:框架在 `console/config.ts` 里为每个端点声明这一组,
31
+ 扩展来的模块照样有。模块自己的 `options.*` 走 `config()` 的配置组,面板在 `instance` 插槽里
32
+ 用控制台的 schema 渲染器画同一份声明、经 `ctx.setConfig` 暂存到连接草稿——内建 llamacpp 的运行时与启动
33
+ 两段走的是这条。`console()` 显式给空 `config` 表示这一页不另开配置页签,声明仍参与服务端
34
+ 校验。启停与模型操作这类动作走面板 invoke。
34
35
 
35
36
  `create()` 返回 `ProviderInstance`:`client`(实现 `respond`)、`listModels?`、`control?`、
36
37
  `compatibilityKey?`、`start?` / `stop?`、`contextWindow?(model)`。
@@ -69,8 +70,11 @@ Core 侧:`activeProviderEntry()` / `activeSpec()` 每次现读;`contextWindowOf(
69
70
  `LLMError`;每次请求尝试记录 `meters` 与 `charges`。
70
71
 
71
72
  `responses-input.ts` 是原生 Responses 的无状态重放(system / developer 上提为
72
- `instructions`);`chat.ts` + `native-input.ts` / `history.ts` 转换 Chat Completions 请求(历史
73
- 思维链不回传);`response-assembly.ts` 把两种流归一成 Open Responses Item 流,
73
+ `instructions`;思维链按 `reasoningReplay` 以签名块或 `reasoning_text` 回传,明文形态下没有记录来源
74
+ 的调用前补一项合成推理);`chat.ts` + `native-input.ts` / `history.ts` 转换 Chat Completions 请求(历史
75
+ 思维链回不回传由模块定:`renderMessagesWithMedia` 的 `keepReasoning` 决定出不出线,`dropPastThinking`
76
+ 把合成开头之外的每条抹空);
77
+ `response-assembly.ts` 把两种流归一成 Open Responses 的 Item 流,
74
78
  `finish_reason` 的 `length` / `content_filter` 落成 `incomplete_details.reason`;
75
79
  `response-meters.ts` 把两种 usage 归一成 `TokenMeters`,缺项保持 null。
76
80
 
@@ -84,3 +88,23 @@ Core 侧:`activeProviderEntry()` / `activeSpec()` 每次现读;`contextWindowOf(
84
88
  模块默认,快照带 sha256 id 与 `capturedAt`。计量键:`input` / `output` / `total` /
85
89
  `cachedInput` / `uncachedInput` / `reasoning` 与 `detail:*`,单价按每百万 token。实际扣费在
86
90
  `src/core/generation.ts` 的 `priceUsage()`,缺计量记 `amount: null`。
91
+
92
+ ## 端点配置接口
93
+
94
+ `console/hub.ts` 的 `ProviderHub` 是控制台改端点的唯一入口,`hub-api.ts` 把它接到
95
+ `/api/providers`(聚合、按名读取、保存、删除、设为当前、测试、模型列表)与
96
+ `/api/provider-modules`(已注册模块)。列表保留 `kind` 没有对应模块的端点,active 与可用性
97
+ 分别报告;状态帧的 `modelConnection` 带当前端点的名称、模型、模块与地址。
98
+
99
+ 新名称由 `name.ts` 校验:英文字母、数字、`-` 或 `_`,首字符是字母或数字,不收空格与 Windows
100
+ 设备名——名字就是 `<部署根>/providers/` 下的目录名。已在磁盘上的名字不改就继续有效。保存带
101
+ 读取时的 revision(端点 `config.json` 与 `.env` 的 sha256),对不上返回 409。共享目录上的写锁
102
+ `.write-lock` 串行化各进程的写入。改名同时改目录和部署根内各 `deployment.json` 的
103
+ `activeProvider`,中途失败回滚已动过的文件和目录;还被引用的端点不能删。
104
+
105
+ 保存一次校验模型、连接与模块配置,再写 `config.json` 和可选密钥;密钥进该端点的 `.env`,读取
106
+ 接口不返回密钥值。写完清掉本进程的实例缓存,已绑定的请求与 fork 保持原客户端;其他进程在下次
107
+ 重读共享配置时跟上。
108
+
109
+ `ProviderConsoleHost.editing` 表示这个实例来自还没保存的表单,模块据此拒绝运行时副作用,
110
+ `host.save` 把配置交回浏览器暂存。`ProviderRegistry.preview` 拿到的实例不进运行实例缓存。