cortico 0.1.1 → 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 (85) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/boot.ts +18 -0
  4. package/src/bot.ts +40 -5
  5. package/src/core/README.md +4 -2
  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 +18 -2
  14. package/src/core/util.ts +15 -0
  15. package/src/deploy.ts +5 -4
  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 -7
  23. package/src/providers/base.ts +2 -0
  24. package/src/providers/console/config.ts +21 -0
  25. package/src/providers/console/hub.ts +270 -0
  26. package/src/providers/console/settings.ts +11 -19
  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 +38 -0
  30. package/src/providers/llamacpp/console/runtime-panel.ts +36 -73
  31. package/src/providers/llamacpp/console/server.ts +23 -2
  32. package/src/providers/llamacpp/index.ts +5 -1
  33. package/src/providers/llamacpp/native.ts +10 -5
  34. package/src/providers/llamacpp/options.ts +2 -0
  35. package/src/providers/name.ts +8 -0
  36. package/src/providers/openai-responses-compat/config.ts +33 -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 +40 -12
  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 +12 -3
  46. package/src/web/auth.ts +80 -0
  47. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +42 -35
  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 +2 -4
  50. package/src/web/client/console-pages/host.ts +22 -5
  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/extensions/index.ts +276 -41
  54. package/src/web/client/features/extensions/strings.ts +84 -10
  55. package/src/web/client/features/feature.ts +1 -1
  56. package/src/web/client/features/live/diagnostics.ts +32 -0
  57. package/src/web/client/features/live/index.ts +27 -11
  58. package/src/web/client/features/live/protocol.ts +1 -0
  59. package/src/web/client/features/live/strings.ts +9 -3
  60. package/src/web/client/features/providers/detail.ts +262 -0
  61. package/src/web/client/features/providers/drafts.ts +23 -0
  62. package/src/web/client/features/providers/index.ts +173 -87
  63. package/src/web/client/features/providers/strings.ts +44 -17
  64. package/src/web/client/features/providers/types.ts +15 -0
  65. package/src/web/client/features/settings/general.ts +12 -0
  66. package/src/web/client/features/settings/strings.ts +6 -0
  67. package/src/web/client/main.ts +4 -0
  68. package/src/web/client/shell/index.ts +1 -1
  69. package/src/web/client/ui/icons.ts +9 -1
  70. package/src/web/client/ui/prompt-input.tsx +17 -5
  71. package/src/web/client/ui/strings.ts +0 -2
  72. package/src/web/diagnostics.ts +133 -0
  73. package/src/web/public/login.html +67 -0
  74. package/src/web/public/styles.css +122 -18
  75. package/src/web/server.ts +227 -21
  76. package/src/web/shared/client-panel.ts +3 -0
  77. package/src/web/shared/console-protocol.ts +3 -1
  78. package/src/worlds/bilibili/README.md +1 -1
  79. package/src/worlds/bilibili/overlay/server.ts +4 -2
  80. package/src/worlds/minecraft/ADAPT.md +66 -0
  81. package/src/worlds/minecraft/README.md +8 -0
  82. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  83. package/src/worlds/qq/normalize.ts +14 -0
  84. package/src/worlds/qq/world.ts +53 -7
  85. package/src/worlds/terminal/world.ts +3 -4
package/README.md CHANGED
@@ -23,9 +23,9 @@ checker and the test runner. It has no entry point and starts nothing.
23
23
  pnpm add -D cortico
24
24
  ```
25
25
 
26
- The extension contract is API version 4; a package declares it as `"cortico": { "kind": ..., "api": 4 }`
27
- in its own `package.json`. This package's minor version tracks the framework, so pin the range
28
- 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.
29
29
 
30
30
  `templates/extension/` in the repository holds a ready package for each kind, and
31
31
  [docs/extensions.md](https://github.com/Pal-AI-Lab/Cortico/blob/main/docs/extensions.md) states
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico",
3
- "version": "0.1.1",
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",
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
 
@@ -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 秒;上下文超限、抢占、关机或轮数
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
  }
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>;
@@ -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
@@ -385,3 +385,18 @@ export function readTextFile(file: string): string {
385
385
  }
386
386
  return bytes.toString('utf8');
387
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
+ }