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/README.md CHANGED
@@ -15,8 +15,7 @@ import type { EventEnvelope, WorldHost } from 'cortico/core/types.ts';
15
15
 
16
16
  Inside a running instance those specifiers are resolved by the host's module hook to the
17
17
  framework that instance runs. This package supplies the same files to the editor, the type
18
- checker and the test runner before the extension is installed anywhere, which is the whole of
19
- what it does: it has no entry point and starts nothing.
18
+ checker and the test runner. It has no entry point and starts nothing.
20
19
 
21
20
  ## Install
22
21
 
@@ -24,9 +23,9 @@ what it does: it has no entry point and starts nothing.
24
23
  pnpm add -D cortico
25
24
  ```
26
25
 
27
- The extension contract is API version 4; a package declares it as `"cortico": { "kind": ..., "api": 4 }`
28
- in its own `package.json`. This package's minor version tracks the framework, so pin the range
29
- your extension was written against.
26
+ Each extension kind has its own contract version world 5, provider 5, bot 5 and a package
27
+ declares its own as `"cortico": { "kind": "world", "api": 5 }` in its `package.json`. This
28
+ package's minor version tracks the framework, so pin the range your extension was written against.
30
29
 
31
30
  `templates/extension/` in the repository holds a ready package for each kind, and
32
31
  [docs/extensions.md](https://github.com/Pal-AI-Lab/Cortico/blob/main/docs/extensions.md) states
@@ -34,14 +33,8 @@ the packaging rules.
34
33
 
35
34
  ## Running a bot
36
35
 
37
- Running Cortico itself means cloning the repository; deployments, bot packages and the web
38
- console live in the checkout.
39
-
40
- ```bash
41
- git clone https://github.com/Pal-AI-Lab/Cortico.git
42
- cd Cortico
43
- corepack pnpm install
44
- pnpm start
45
- ```
36
+ Deployments, bot packages and the web console live in a checkout of the repository, which is
37
+ where a bot runs from:
38
+ [Quick Start](https://github.com/Pal-AI-Lab/Cortico#quick-start).
46
39
 
47
40
  MIT licensed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "A TypeScript framework for event-driven, long-running persona bots",
6
6
  "license": "MIT",
@@ -36,8 +36,6 @@
36
36
  "@types/express": "^4.17.21",
37
37
  "@types/ws": "^8.5.13",
38
38
  "express": "^4.21.2",
39
- "fflate": "0.8.3",
40
- "tar-stream": "3.2.1",
41
39
  "ws": "^8.18.0"
42
40
  }
43
41
  }
package/src/boot.ts CHANGED
@@ -19,6 +19,14 @@ export function isSupervised(env: NodeJS.ProcessEnv = process.env): boolean {
19
19
  return env[SUPERVISED_ENV] === '1' || env[SUPERVISED_ENV] === 'true';
20
20
  }
21
21
 
22
+ /**
23
+ * 启动时是否暂停事件投递。每一轮受监管的启动拿到同一份环境和同一串参数,
24
+ * 所以这个值也是重启后的状态。
25
+ */
26
+ export function startsPaused(env: NodeJS.ProcessEnv = process.env, argv: readonly string[] = process.argv): boolean {
27
+ return env.CORTICO_START_PAUSED === '1' || env.CORTICO_START_PAUSED === 'true' || argv.includes('--paused');
28
+ }
29
+
22
30
  function notifyLauncher(message: { type: string; dataDir?: string }, env = process.env): void {
23
31
  if (!isSupervised(env)) return;
24
32
  process.send?.(message);
@@ -44,3 +52,13 @@ export function consumeBootFlags(dataDir: string): void {
44
52
  }
45
53
  }
46
54
  }
55
+
56
+ export function listensOnEveryInterface(bound: string | null): boolean {
57
+ return bound === '0.0.0.0' || bound === '::';
58
+ }
59
+
60
+ /** 本机打得开的控制台地址:监听所有网卡时给回环地址,IPv6 加方括号。 */
61
+ export function consoleUrlOf(bound: string | null, port: number): string {
62
+ const host = bound === null || listensOnEveryInterface(bound) ? '127.0.0.1' : bound.includes(':') ? `[${bound}]` : bound;
63
+ return `http://${host}:${port}/`;
64
+ }
package/src/bot.ts CHANGED
@@ -19,7 +19,7 @@ import { coreConfigGroup } from './core/config.ts';
19
19
  import { pick, resolveLanguage, type Language } from './core/language.ts';
20
20
  import { updateJsonObject } from './config-file.ts';
21
21
  import { ONBOARDING_FLAG_FILE } from './deploy.ts';
22
- import { isSupervised, requestRestart } from './boot.ts';
22
+ import { isSupervised, requestRestart, startsPaused } from './boot.ts';
23
23
  import { ExtensionManager, type ExtensionSet } from './extensions.ts';
24
24
  import { WorldAssembly, type WorldDefinition, type WorldDeclaration, type WorldSection } from './world.ts';
25
25
  import { Core, type WorldStopFailure } from './core/core.ts';
@@ -30,6 +30,7 @@ import { assembleSystemSegments, envPromptOverridePath, envPromptTemplateSource,
30
30
  import { aggregateUsage } from './core/cost.ts';
31
31
  import { nowIso, withDeadline } from './core/util.ts';
32
32
  import { closeRun } from './core/run.ts';
33
+ import { ProviderHub } from './providers/console/hub.ts';
33
34
  import { ProviderSettings } from './providers/console/settings.ts';
34
35
  import { providerModules } from './providers/registry.ts';
35
36
  import { readGroupValues, setByPath as setConfigPath } from './core/config-schema.ts';
@@ -51,6 +52,9 @@ import {
51
52
  type ConsolePageContribution,
52
53
  } from './web/shared/console-protocol.ts';
53
54
 
55
+ /** 控制台访问密码的密钥名;进程环境或部署 .env 里非空时优先于 web.password。 */
56
+ export const WEB_PASSWORD_SECRET = 'CORTICO_WEB_PASSWORD';
57
+
54
58
  /** 启动器使用的 bot 包装配契约。 */
55
59
  export interface BotDefinition<C extends CoreConfig = CoreConfig> {
56
60
  /** 控制台页 id `persona:<id>` 与产物键取它;仓内包的 id 与目录同名。 */
@@ -166,7 +170,7 @@ const BOT_TEXT = {
166
170
  },
167
171
  session: {
168
172
  label: '主session(当前对话上下文)',
169
- note: '清除对话上下文并重新开场,保留 Memory 和事件库。建议在空闲时操作',
173
+ note: '清除对话上下文并重新开场,保留 Memory 和事件库。正在处理批次时等该批结束后执行',
170
174
  stat: (records: number, ktok: number, size: string) => `${records}条 / ~${ktok}k tok / ${size}`,
171
175
  cleared: 'session已清空重开(system前缀+开场消息)',
172
176
  },
@@ -212,6 +216,12 @@ const BOT_TEXT = {
212
216
  stat: (n: number) => `${n}条待投递`,
213
217
  cleared: (n: number) => `已丢弃${n}条待投递事件`,
214
218
  },
219
+ media: {
220
+ label: '附件库(事件与工具回执里的图片、音频)',
221
+ note: '删除全部附件文件,保留引用它们的事件与 session 记录;删掉的附件在上下文里只剩文字说明',
222
+ stat: (n: number, size: string) => `${n}份 / ${size}`,
223
+ cleared: (n: number) => `已删除${n}份附件`,
224
+ },
215
225
  },
216
226
  config: {
217
227
  unknownGroup: (id: string) => `没有这一组配置: ${id}`,
@@ -260,7 +270,7 @@ const BOT_TEXT = {
260
270
  },
261
271
  session: {
262
272
  label: 'Main session (current conversation context)',
263
- note: 'Clears the conversation and reopens the session, keeping Memory and the event store. Prefer clearing while idle',
273
+ note: 'Clears the conversation and reopens the session, keeping Memory and the event store. While a batch is in progress, runs after it finishes',
264
274
  stat: (records: number, ktok: number, size: string) => `${records} records / ~${ktok}k tok / ${size}`,
265
275
  cleared: 'Session cleared and reopened (system prefix + opening message)',
266
276
  },
@@ -306,6 +316,12 @@ const BOT_TEXT = {
306
316
  stat: (n: number) => `${n} pending`,
307
317
  cleared: (n: number) => `Discarded ${n} pending events`,
308
318
  },
319
+ media: {
320
+ label: 'Attachment store (images and audio from events and tool results)',
321
+ note: 'Deletes every attachment file and keeps the events and session records that reference them; a deleted attachment leaves only its text description in context',
322
+ stat: (n: number, size: string) => `${n} files / ${size}`,
323
+ cleared: (n: number) => `Deleted ${n} attachments`,
324
+ },
309
325
  },
310
326
  config: {
311
327
  unknownGroup: (id: string) => `No such config group: ${id}`,
@@ -466,6 +482,19 @@ function deriveStorage<C extends CoreConfig>(core: Core<C>, dataDir: string, lan
466
482
  // 保留延迟渲染项,其回调还负责复位 World 的排队状态。
467
483
  clear: () => s.pending.cleared(core.discardPendingEvents()),
468
484
  },
485
+ {
486
+ key: 'media',
487
+ label: s.media.label,
488
+ kind: 'disk',
489
+ location: 'data/media/',
490
+ danger: true,
491
+ note: s.media.note,
492
+ stat: () => {
493
+ const { count, bytes } = core.logBlobs.stat();
494
+ return s.media.stat(count, `${(bytes / 1024).toFixed(1)}KB`);
495
+ },
496
+ clear: () => s.media.cleared(core.logBlobs.clear()),
497
+ },
469
498
  ];
470
499
  }
471
500
 
@@ -867,7 +896,7 @@ export function deriveConsolePageSources(
867
896
  parts: { assembly: WorldAssembly; persona?: Persona },
868
897
  /**
869
898
  * bot 标识、展示名、Memory 名与配置组;配置组归入 Persona 页面。
870
- * 展示名由部署配置提供;Persona 页标题取 Persona 的类名。
899
+ * 展示名是这一台 bot 的名字(底栏头像旁边那个),Persona 页的标题另取 Persona 的类名。
871
900
  */
872
901
  bot?: { id: string; label: string; memoryName?: string; configGroups?: readonly ConfigGroup[] },
873
902
 
@@ -990,6 +1019,7 @@ export function createBot<C extends CoreConfig>(
990
1019
 
991
1020
  // 共享端点配置位于部署根的 providers/;activeProvider 属于当前部署。
992
1021
  const providerSettings = new ProviderSettings(cfg,core.providers,join(loaded.rootDir,'config.json'),loaded.providersDir ?? join(loaded.rootDir,'providers'));
1022
+ const providerHub = new ProviderHub(cfg, core.providers, providerSettings, join(loaded.rootDir, 'config.json'), loaded.providersDir ?? join(loaded.rootDir, 'providers'));
993
1023
  const allConfigGroups = (language: Language) => [...configGroups(language),...providerSettings.groups(language)];
994
1024
  const llmManagers = new Map<string,{stop():Promise<unknown>}>([['providers',{stop:()=>core.providers.stopAll()}]]);
995
1025
 
@@ -1058,6 +1088,9 @@ export function createBot<C extends CoreConfig>(
1058
1088
  dataDir: loaded.dataDir,
1059
1089
  botDir: loaded.rootDir,
1060
1090
  language,
1091
+ ...(cfg.web.host ? { host: cfg.web.host } : {}),
1092
+ allowedHosts: cfg.web.allowedHosts ?? [],
1093
+ password: loaded.secret(WEB_PASSWORD_SECRET) || (cfg.web.password ?? ''),
1061
1094
  defaultScheme: cfg.web.theme,
1062
1095
  sessions: core.sessions,
1063
1096
  storage: consoleStorage,
@@ -1099,7 +1132,7 @@ export function createBot<C extends CoreConfig>(
1099
1132
  ? (language) => contribution.consolePages!({ storage: consoleStorage(language), language })
1100
1133
  : undefined,
1101
1134
  )(),...providerSettings.sources()],
1102
- providersLamp: (language) => providerSettings.providersLamp(language),
1135
+ providers: providerHub,
1103
1136
  worldVisibility: {
1104
1137
  state: () => core.worldVisibility(),
1105
1138
  set: (id, visible, language) => {
@@ -1127,6 +1160,7 @@ export function createBot<C extends CoreConfig>(
1127
1160
  return beginShutdown('控制台重启键', { closeWeb: false, exit: true, language });
1128
1161
  },
1129
1162
  supervised: isSupervised(),
1163
+ startsPaused: startsPaused(),
1130
1164
  },
1131
1165
  onboarding: {
1132
1166
  dismiss: () => { try { unlinkSync(join(loaded.rootDir, ONBOARDING_FLAG_FILE)); } catch { /* 已经删过 */ } },
@@ -1166,6 +1200,7 @@ export function createBot<C extends CoreConfig>(
1166
1200
  },
1167
1201
  getStatus: () => ({
1168
1202
  displayName: cfg.displayName,
1203
+ modelConnection: providerHub.current(language),
1169
1204
  startedAt,
1170
1205
  loop: core.loop.getStatus(),
1171
1206
  eventCount: core.store.latestCursor(),
@@ -1199,7 +1234,7 @@ export function createBot<C extends CoreConfig>(
1199
1234
 
1200
1235
  const orphans = Object.entries(cfg.providers).filter(([, entry]) => !providerModules.some((m) => m.id === entry.kind)).map(([name, entry]) => `${name}(kind=${entry.kind})`);
1201
1236
  if (orphans.length) core.runlog.logger('provider').warn('端点条目没有对应的 Provider 模块,不可用', { orphans });
1202
- void core.providers.start(cfg.activeProvider).catch(error=>core.runlog.logger('provider').error('Provider 启动失败',{error:String(error)}));
1237
+ if (cfg.activeProvider && cfg.providers[cfg.activeProvider]) void core.providers.start(cfg.activeProvider).catch(error=>core.runlog.logger('provider').error('Provider 启动失败',{error:String(error)}));
1203
1238
  await parts.onStart?.({ core, loaded, port });
1204
1239
  await core.start();
1205
1240
  return { port };
@@ -51,14 +51,16 @@ piggyback 只入队,随后续唤醒一起投递。
51
51
  更新后的值在下一次入队时参与计算。
52
52
 
53
53
  投递水位 `lastDeliveredCursor` 持久化,队列不持久化。重启时补投水位之后的外部事件;
54
- 已被候选处理结果引用的原始归档不重复投递。内部事件仅在当次运行投递。
54
+ 已被候选处理结果引用的原始归档不重复投递。内部事件仅在当次运行投递。清空分片或跳过损坏行
55
+ 留下的游标空位没有可投递内容,不阻止水位推进。
55
56
 
56
57
  ## 主循环
57
58
 
58
59
  每次唤醒处理一批事件,可进行多轮模型调用。`SessionDecl.rounds()` 提供
59
60
  `{ soft, hard, softHint? }`:到 soft 轮时将 `softHint` 追加到最后一条工具回执;到 hard 轮时
60
61
  记录 warn 并结束本批。`endsTurn` 工具和自然结束共用结束处理;本批结束时尚未处理的事件
61
- 退回总线,进入下一批。
62
+ 退回总线,进入下一批。控制台的前缀重载与清空 session 在批次边界执行:正在处理批次时等该批
63
+ 结束,空闲时立即;并发请求复用同一事务。
62
64
 
63
65
  状态 0、429 或 5xx 的模型调用失败,可保留已记录的输出和工具回执,按 `ResubmitPolicy` 重试。
64
66
  默认允许连续重试 2 次、每批最多 4 次,退避为 2 秒、10 秒;上下文超限、抢占、关机或轮数
@@ -77,12 +79,8 @@ handler 异常转为失败回执。流式生成时 `EagerDispatch` 可提前执
77
79
  `Persona.onHandoff(snapshot, { hardTokens })` 返回 `{ tail, trim? }`。Core 重建 system 前缀,
78
80
  按 `hardTokens − estimate(prefix)` 限制保留上下文,其中 prefix 包括 system 前缀和 Persona 的合成
79
81
  开头(`sessionHead()`)。Core 校验工具调用配对,并重置 session。
80
- 合成开头每次请求现取,不写入持久 session;Core 不据此判定远端缓存是否命中。
81
82
  阶段预算与保留比例由 Persona 决定,模型配置来自当前 provider。
82
83
 
83
- 存储清单使用 `StorageOwner` 标识 Core、Persona、Memory 与 World 的归属。
84
- 页面组织和 Memory 展示名称由装配层处理。
85
-
86
84
  ## 错误隔离
87
85
 
88
86
  装配层将 World 构造失败限制在该 World。`start()` 抛错时不挂载;`stop()` 失败或超时会记录
package/src/core/blobs.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * `mem:<后端标识>` 由 Persona 的 BlobStore 解析,后端决定标识格式。
5
5
  */
6
6
  import { createHash } from 'node:crypto';
7
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import type { BlobRef } from './types.ts';
10
10
 
@@ -58,7 +58,7 @@ export function withBlobLines(text: string, refs: readonly BlobRef[] | undefined
58
58
  }
59
59
 
60
60
  /**
61
- * 字节按内容哈希存入 data/media/,重复写入不增加副本;删除由运维处理。
61
+ * 字节按内容哈希存入 data/media/,重复写入不增加副本。
62
62
  * 读取接受完整句柄或唯一的十六进制前缀(至少 8 位)。
63
63
  */
64
64
  export class LogBlobStore {
@@ -81,6 +81,27 @@ export class LogBlobStore {
81
81
  return `${LOG_SCHEME}${name}`;
82
82
  }
83
83
 
84
+ /** 现存份数与占用字节;目录还没建时都是 0。 */
85
+ stat(): { count: number; bytes: number } {
86
+ if (!existsSync(this.dir)) return { count: 0, bytes: 0 };
87
+ const names = readdirSync(this.dir);
88
+ return { count: names.length, bytes: names.reduce((sum, name) => sum + statSync(join(this.dir, name)).size, 0) };
89
+ }
90
+
91
+ /**
92
+ * 删掉目录里的全部附件,返回删掉的份数。附件那一行正文在落库时已写定
93
+ * (句柄、mime 与 fallbackText),此后 read() 返回 null,投递时不再附字节。
94
+ */
95
+ clear(): number {
96
+ if (!existsSync(this.dir)) return 0;
97
+ let removed = 0;
98
+ for (const name of readdirSync(this.dir)) {
99
+ rmSync(join(this.dir, name), { force: true });
100
+ removed += 1;
101
+ }
102
+ return removed;
103
+ }
104
+
84
105
  /** 按句柄取回;句柄不合形状、前缀不唯一或文件不在了返回 null。 */
85
106
  read(handle: string): { bytes: Buffer; mime: string } | null {
86
107
  const name = this.resolveName(handle);
@@ -12,7 +12,7 @@ import type { CoreConfig } from './types.ts';
12
12
  import { pick, type Language } from './language.ts';
13
13
 
14
14
  export interface ConfigProperty {
15
- type: 'integer' | 'number' | 'boolean' | 'string' | 'array';
15
+ type: 'integer' | 'number' | 'boolean' | 'string' | 'array' | 'object';
16
16
  title: string;
17
17
  description?: string;
18
18
  minimum?: number;
@@ -1,6 +1,5 @@
1
1
  /** Core 默认配置与深合并工具。Persona 和 World 参数由各自声明,部署合并顺序见 src/deploy.ts。 */
2
2
  import type { CoreConfig } from './types.ts';
3
- import type { PriceDefinition } from '../providers/pricebook.ts';
4
3
  import type { ConfigGroup } from './config-schema.ts';
5
4
  import { pick, type Language } from './language.ts';
6
5
 
@@ -186,21 +185,8 @@ export const CORE_DEFAULTS = {
186
185
  /** 用于控制台标题和终端消息的发送方名称。 */
187
186
  displayName: 'Cortico Bot',
188
187
  timezone: 'Asia/Shanghai',
189
- /** 默认端点;部署根 providers/ 中的同名端点配置覆盖此项。 */
190
- providers: {
191
- deepseek: {
192
- kind: 'openai-responses-compat' as const,
193
- baseUrl: 'https://api.deepseek.com',
194
- secret: 'DEEPSEEK_API_KEY',
195
- spec: { model: 'deepseek-flash', thinking: false },
196
- // 默认价目为零;操作者可在控制台填写实际价格。
197
- pricing: [{
198
- models: ['*'], currency: 'USD', basis: 'marginal', source: 'console',
199
- rules: [{ meter: 'cachedInput', perMillion: 0 }, { meter: 'uncachedInput', perMillion: 0 }, { meter: 'output', perMillion: 0 }],
200
- }] as PriceDefinition[],
201
- },
202
- },
203
- activeProvider: 'deepseek',
188
+ providers: {} as Record<string, import('./types.ts').LLMProviderEntry>,
189
+ activeProvider: '',
204
190
  web: { port: 7777, theme: 'mint' },
205
191
  paths: { memory: 'memory', data: 'data' },
206
192
  batching: { quietGapMs: 2500, minBatchAgeMs: 0, maxBatchAgeMs: 15000, maxBatchSize: 100 },
package/src/core/core.ts CHANGED
@@ -32,7 +32,7 @@ import { SessionLog } from './session.ts';
32
32
  import { CoreState } from './state.ts';
33
33
  import { ProviderRegistry } from '../providers/registry.ts';
34
34
  import { runForkLoop } from './fork.ts';
35
- import { providerModule } from '../providers/registry.ts';
35
+ import { providerModule, providerModules } from '../providers/registry.ts';
36
36
  import { LogBlobStore, blobScheme, mimeOfHandle, withBlobLines } from './blobs.ts';
37
37
  import { TimerStore } from './timers.ts';
38
38
  import { SessionTracker, type SessionHandle } from './sessions.ts';
@@ -289,15 +289,19 @@ export class Core<C extends CoreConfig = CoreConfig> {
289
289
 
290
290
  /** 按当前活跃端点读取模型上下文事实。 */
291
291
  private contextFacts(): ContextFacts {
292
- const module = () => providerModule(this.activeProviderEntry().entry.kind);
292
+ const module = () => {
293
+ const entry = this.config.providers[this.config.activeProvider];
294
+ return entry ? providerModules.find(module => module.id === entry.kind) : undefined;
295
+ };
293
296
  return {
294
297
  hardTokens: () => {
298
+ if (!module() || !this.config.providers[this.config.activeProvider]?.spec) return null;
295
299
  const spec = this.activeSpec();
296
300
  const window = this.contextWindowOf(spec);
297
301
  return window === undefined ? null : Math.max(0, window - (spec.maxTokens ?? 0));
298
302
  },
299
- estimateTokens: (records) => module().estimateTokens?.(records, this.activeSpec()) ?? estimateMessagesTokens(records),
300
- contextOverflow: (error) => module().contextOverflow?.(error) ?? false,
303
+ estimateTokens: (records) => module()?.estimateTokens?.(records, this.activeSpec()) ?? estimateMessagesTokens(records),
304
+ contextOverflow: (error) => module()?.contextOverflow?.(error) ?? false,
301
305
  };
302
306
  }
303
307
 
@@ -587,7 +591,13 @@ export class Core<C extends CoreConfig = CoreConfig> {
587
591
  const dataDir = this.loaded.dataDir;
588
592
  if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
589
593
  // 构造期已加载状态;此处再次 load 会覆盖装配后、启动前的修改。
590
- this.session.load();
594
+ const repair = this.session.load();
595
+ if (repair) {
596
+ this.log.warn(
597
+ repair.kind === 'torn-tail' ? 'session 末行未写完,已截掉' : 'session 末行缺换行,已补上',
598
+ { ...repair, file: 'session-main.jsonl' },
599
+ );
600
+ }
591
601
  for (const mod of this.worlds) {
592
602
  try {
593
603
  await mod.start(this.makeHost(mod));
package/src/core/loop.ts CHANGED
@@ -245,8 +245,9 @@ export class MainLoop {
245
245
  /** 截断与前缀重载共用的维护串行链,避免两个 session.reset 互相覆盖。 */
246
246
  private maintenanceChain: Promise<void> = Promise.resolve();
247
247
  private prefixReloadPromise: Promise<void> | null = null;
248
- /** 在一轮处理中请求重载时,等自然回合边界再释放。 */
249
- private releasePrefixReload: (() => void) | null = null;
248
+ private clearSessionPromise: Promise<void> | null = null;
249
+ /** 在一轮处理中请求的前缀重载与清空,等自然回合边界再释放。 */
250
+ private releaseAtBoundary: Array<() => void> = [];
250
251
  /** 当前正在处理一个事件批;手动交接必须等到该批自然结束,不能重置半轮session。 */
251
252
  private processingBatch = false;
252
253
  private handoffRequested = false;
@@ -722,13 +723,15 @@ export class MainLoop {
722
723
  const latest = store.latestCursor();
723
724
  for (let c = top + 1; c <= latest; c++) {
724
725
  const next = store.get(c);
725
- if (!next) break;
726
- // 未处理的 archive-only 项必须保留在水位之后,重启才会补投。
727
- // 已处理项由 settledArchives 标识。
728
- const skippable = next.origin === 'internal'
729
- || (next.contextDelivery === 'archive-only' && this.settledArchives.has(c));
730
- // 集合记录存储位置;装载期重排保证 next.cursor === c
731
- if (!skippable && !this.deliveredCursors.has(c)) break;
726
+ // 清空分片或跳过损坏行留下的空位没有可投递内容,不阻止水位推进。
727
+ if (next) {
728
+ // 未处理的 archive-only 项必须保留在水位之后,重启才会补投。
729
+ // 已处理项由 settledArchives 标识。
730
+ const skippable = next.origin === 'internal'
731
+ || (next.contextDelivery === 'archive-only' && this.settledArchives.has(c));
732
+ // 集合记录存储位置;装载期重排保证 next.cursor === c
733
+ if (!skippable && !this.deliveredCursors.has(c)) break;
734
+ }
732
735
  this.deliveredCursors.delete(c);
733
736
  this.settledArchives.delete(c);
734
737
  top = c;
@@ -904,8 +907,8 @@ export class MainLoop {
904
907
  } finally {
905
908
  this.processingBatch = false;
906
909
  }
907
- // 异常退出由 stop 释放排队请求;只有正常批次边界执行重载。
908
- await this.flushRequestedPrefixReload(generation);
910
+ // 异常退出由 stop 释放排队请求;只有正常批次边界执行重载与清空。
911
+ await this.flushBoundaryMaintenance();
909
912
  }
910
913
  } finally {
911
914
  this.stop();
@@ -933,7 +936,11 @@ export class MainLoop {
933
936
  for (let round = 1; ; round++) {
934
937
  if (!this.active(generation)) return;
935
938
  this.roundsLastBatch = round;
936
- const spec = this.d.spec();
939
+ let spec: ModelSpec;
940
+ try { spec = this.d.spec(); } catch (error) {
941
+ log.warn('模型配置不可用', { error: String(error) });
942
+ return;
943
+ }
937
944
  // 后续轮输入超限时结束本批,由批末检查执行交接。
938
945
  // 首轮仍处理本批新投递的事件;上一批的容量检查已在批末执行。
939
946
  if (round > 1) {
@@ -1425,9 +1432,25 @@ export class MainLoop {
1425
1432
  return rebuildTail(paired, budget, estimate);
1426
1433
  }
1427
1434
 
1428
- /** 清空主 session,重建 system 前缀并调用 Persona 开场钩子;事件库保留。 */
1429
- async clearSession(): Promise<void> {
1435
+ /**
1436
+ * 清空主 session,重建 system 前缀并调用 Persona 开场钩子;事件库保留。
1437
+ * 正在处理批次时等到该批自然结束,不重置半轮 session;并发请求复用同一 Promise。
1438
+ */
1439
+ clearSession(): Promise<void> {
1430
1440
  const generation = this.generation;
1441
+ if (!this.active(generation)) return Promise.resolve();
1442
+ if (this.clearSessionPromise) return this.clearSessionPromise;
1443
+ let tracked: Promise<void>;
1444
+ tracked = this.safeBoundary()
1445
+ .then(() => this.enqueueMaintenance(() => this.performClearSession(generation), generation))
1446
+ .finally(() => {
1447
+ if (this.clearSessionPromise === tracked) this.clearSessionPromise = null;
1448
+ });
1449
+ this.clearSessionPromise = tracked;
1450
+ return tracked;
1451
+ }
1452
+
1453
+ private async performClearSession(generation: number): Promise<void> {
1431
1454
  if (!this.active(generation)) return;
1432
1455
  const { session, log } = this.d;
1433
1456
  const sysMsg = await this.buildSystem();
@@ -1447,11 +1470,8 @@ export class MainLoop {
1447
1470
  const generation = this.generation;
1448
1471
  if (!this.active(generation)) return Promise.resolve();
1449
1472
  if (this.prefixReloadPromise) return this.prefixReloadPromise;
1450
- const safeBoundary = this.processingBatch
1451
- ? new Promise<void>((resolve) => { this.releasePrefixReload = resolve; })
1452
- : Promise.resolve();
1453
1473
  let tracked: Promise<void>;
1454
- tracked = safeBoundary
1474
+ tracked = this.safeBoundary()
1455
1475
  .then(() => this.enqueueMaintenance(() => this.performSystemPrefixReload(generation), generation))
1456
1476
  .finally(() => {
1457
1477
  if (this.prefixReloadPromise === tracked) this.prefixReloadPromise = null;
@@ -1460,12 +1480,22 @@ export class MainLoop {
1460
1480
  return tracked;
1461
1481
  }
1462
1482
 
1463
- private async flushRequestedPrefixReload(_generation: number): Promise<boolean> {
1464
- const release = this.releasePrefixReload;
1465
- if (!release) return false;
1466
- this.releasePrefixReload = null;
1467
- release();
1468
- await this.prefixReloadPromise;
1483
+ /** 空闲时立即;正在处理批次时等 run() 在批末释放,或 stop() 释放。 */
1484
+ private safeBoundary(): Promise<void> {
1485
+ if (!this.processingBatch) return Promise.resolve();
1486
+ return new Promise<void>((resolve) => { this.releaseAtBoundary.push(resolve); });
1487
+ }
1488
+
1489
+ private releaseBoundary(): void {
1490
+ const waiting = this.releaseAtBoundary;
1491
+ this.releaseAtBoundary = [];
1492
+ for (const release of waiting) release();
1493
+ }
1494
+
1495
+ private async flushBoundaryMaintenance(): Promise<boolean> {
1496
+ if (this.releaseAtBoundary.length === 0) return false;
1497
+ this.releaseBoundary();
1498
+ await Promise.all([this.prefixReloadPromise, this.clearSessionPromise]);
1469
1499
  return true;
1470
1500
  }
1471
1501
 
@@ -1721,9 +1751,7 @@ export class MainLoop {
1721
1751
  this.backoffWake?.();
1722
1752
  if (!this.shutdown.signal.aborted) this.shutdown.abort(new Error('core 正在关机'));
1723
1753
  this.handoffRequested = false;
1724
- const releasePrefixReload = this.releasePrefixReload;
1725
- this.releasePrefixReload = null;
1726
- releasePrefixReload?.();
1754
+ this.releaseBoundary();
1727
1755
  this.stopFn?.();
1728
1756
  const round = this.currentRound;
1729
1757
  if (round && !round.controller.signal.aborted) {
@@ -1,7 +1,7 @@
1
1
  /** Session context is append-only between lifecycle resets. */
2
2
  import { join } from 'node:path';
3
3
  import type { ContextRecord } from '../protocol/open-responses/context.ts';
4
- import { ContextLog } from '../protocol/open-responses/context-log.ts';
4
+ import { ContextLog, type ContextLoadRepair } from '../protocol/open-responses/context-log.ts';
5
5
  import { estimateMessagesTokens } from './util.ts';
6
6
 
7
7
 
@@ -38,7 +38,8 @@ export class SessionLog {
38
38
  }
39
39
  }
40
40
 
41
- load(): void { this.context.load(); }
41
+ /** 返回对未写完末行的修复;其他损坏行抛错。 */
42
+ load(): ContextLoadRepair | null { return this.context.load(); }
42
43
 
43
44
  /** 整体替换:先写临时文件,再 rename 覆盖目标文件。 */
44
45
  reset(messages: readonly ContextRecord[]): void {
@@ -2,7 +2,7 @@
2
2
  * 通用持久定时器:到点回调持有方、跨重启恢复。载荷不透明——
3
3
  * 闹钟的备注、阻断、关键词这些语义归持有它的Persona。
4
4
  */
5
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import type { Logger, TimerEntry, TimersApi } from './types.ts';
8
8
  import { nullLogger, shortId } from './util.ts';
@@ -47,7 +47,8 @@ export class TimerStore implements TimersApi {
47
47
  const entry: TimerEntry = { id: shortId('wake_'), atIso, payload };
48
48
  this.entries.push(entry);
49
49
  this.save();
50
- if (this.started) this.arm(entry);
50
+ // 已到期的也等 set 返回后再回调,持有方先拿到 id。
51
+ if (this.started) this.arm(entry, 'next-tick');
51
52
  return { ok: true, id: entry.id };
52
53
  }
53
54
 
@@ -76,9 +77,10 @@ export class TimerStore implements TimersApi {
76
77
  return n;
77
78
  }
78
79
 
79
- private arm(entry: TimerEntry): void {
80
+ /** 已到期的条目按 whenDue 立即回调或下一个宏任务回调;start() 用前者。 */
81
+ private arm(entry: TimerEntry, whenDue: 'now' | 'next-tick' = 'now'): void {
80
82
  const delay = Date.parse(entry.atIso) - Date.now();
81
- if (delay <= 0) {
83
+ if (delay <= 0 && whenDue === 'now') {
82
84
  this.fire(entry.id);
83
85
  return;
84
86
  }
@@ -88,7 +90,7 @@ export class TimerStore implements TimersApi {
88
90
  // 超长延迟按 MAX_TIMEOUT 分段重新 arm。
89
91
  this.timers.set(entry.id, setTimeout(() => this.arm(entry), MAX_TIMEOUT));
90
92
  } else {
91
- this.timers.set(entry.id, setTimeout(() => this.fire(entry.id), delay));
93
+ this.timers.set(entry.id, setTimeout(() => this.fire(entry.id), Math.max(0, delay)));
92
94
  }
93
95
  }
94
96
 
@@ -123,7 +125,7 @@ export class TimerStore implements TimersApi {
123
125
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
124
126
  const tmp = this.file + '.tmp';
125
127
  writeFileSync(tmp, JSON.stringify(this.entries, null, 2), 'utf8');
126
- if (existsSync(this.file)) rmSync(this.file);
128
+ // 通过 rename 原子覆盖,不先删除,避免崩溃后定时器全部丢失。
127
129
  renameSync(tmp, this.file);
128
130
  }
129
131
  }