cortico 0.1.1 → 0.1.3

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 (100) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/boot.ts +29 -0
  4. package/src/bot.ts +40 -5
  5. package/src/core/README.md +12 -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 +18 -6
  10. package/src/core/generation.ts +66 -9
  11. package/src/core/instance-lock.ts +6 -0
  12. package/src/core/loop.ts +62 -32
  13. package/src/core/secrets.ts +2 -4
  14. package/src/core/session.ts +3 -2
  15. package/src/core/timers.ts +8 -6
  16. package/src/core/types.ts +23 -4
  17. package/src/core/util.ts +15 -0
  18. package/src/deploy.ts +5 -4
  19. package/src/extensions/README.md +6 -4
  20. package/src/extensions/dry-mount.ts +34 -6
  21. package/src/extensions/manifest.ts +21 -11
  22. package/src/extensions.ts +147 -23
  23. package/src/launcher.ts +30 -22
  24. package/src/protocol/open-responses/context-helpers.ts +3 -1
  25. package/src/protocol/open-responses/context-log.ts +37 -9
  26. package/src/providers/README.md +44 -12
  27. package/src/providers/base.ts +2 -0
  28. package/src/providers/console/config.ts +36 -0
  29. package/src/providers/console/hub.ts +307 -0
  30. package/src/providers/console/settings.ts +30 -26
  31. package/src/providers/console/strings.ts +14 -0
  32. package/src/providers/console/types.ts +5 -0
  33. package/src/providers/hub-api.ts +3 -0
  34. package/src/providers/llamacpp/config.ts +38 -0
  35. package/src/providers/llamacpp/console/models-panel.ts +87 -4
  36. package/src/providers/llamacpp/console/runtime-panel.ts +36 -73
  37. package/src/providers/llamacpp/console/server.ts +47 -4
  38. package/src/providers/llamacpp/huggingface.ts +93 -0
  39. package/src/providers/llamacpp/index.ts +5 -1
  40. package/src/providers/llamacpp/native.ts +10 -5
  41. package/src/providers/llamacpp/options.ts +2 -0
  42. package/src/providers/llamacpp/strings.ts +42 -0
  43. package/src/providers/name.ts +13 -0
  44. package/src/providers/openai-responses-compat/config.ts +33 -0
  45. package/src/providers/openai-responses-compat/console/client.ts +5 -0
  46. package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
  47. package/src/providers/openai-responses-compat/console/server.ts +135 -0
  48. package/src/providers/openai-responses-compat/index.ts +40 -12
  49. package/src/providers/openai-responses-compat/native.ts +15 -5
  50. package/src/providers/openai-responses-compat/strings.ts +62 -1
  51. package/src/providers/pricebook.ts +5 -5
  52. package/src/providers/registry.ts +24 -10
  53. package/src/providers/strings.ts +2 -0
  54. package/src/providers/transport/errors.ts +9 -0
  55. package/src/providers/transport/response-http.ts +7 -3
  56. package/src/providers/transport/responses-input.ts +59 -10
  57. package/src/web/README.md +15 -3
  58. package/src/web/auth.ts +80 -0
  59. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +42 -35
  60. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
  61. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +2 -4
  62. package/src/web/client/console-pages/host.ts +22 -5
  63. package/src/web/client/core/api.ts +5 -1
  64. package/src/web/client/core/router.ts +15 -0
  65. package/src/web/client/features/extensions/index.ts +276 -41
  66. package/src/web/client/features/extensions/strings.ts +84 -10
  67. package/src/web/client/features/feature.ts +1 -1
  68. package/src/web/client/features/live/diagnostics.ts +32 -0
  69. package/src/web/client/features/live/index.ts +27 -11
  70. package/src/web/client/features/live/protocol.ts +1 -0
  71. package/src/web/client/features/live/strings.ts +9 -3
  72. package/src/web/client/features/providers/detail.ts +309 -0
  73. package/src/web/client/features/providers/drafts.ts +23 -0
  74. package/src/web/client/features/providers/index.ts +179 -87
  75. package/src/web/client/features/providers/strings.ts +48 -17
  76. package/src/web/client/features/providers/types.ts +15 -0
  77. package/src/web/client/features/settings/general.ts +12 -0
  78. package/src/web/client/features/settings/strings.ts +6 -0
  79. package/src/web/client/main.ts +4 -0
  80. package/src/web/client/shell/index.ts +1 -1
  81. package/src/web/client/ui/fields.ts +3 -1
  82. package/src/web/client/ui/icons.ts +9 -1
  83. package/src/web/client/ui/prompt-input.tsx +17 -5
  84. package/src/web/client/ui/strings.ts +0 -2
  85. package/src/web/diagnostics.ts +133 -0
  86. package/src/web/public/login.html +67 -0
  87. package/src/web/public/styles.css +130 -18
  88. package/src/web/server.ts +229 -21
  89. package/src/web/shared/client-panel.ts +3 -0
  90. package/src/web/shared/console-protocol.ts +16 -1
  91. package/src/worlds/bilibili/README.md +1 -1
  92. package/src/worlds/bilibili/overlay/server.ts +4 -2
  93. package/src/worlds/minecraft/ADAPT.md +66 -0
  94. package/src/worlds/minecraft/README.md +8 -0
  95. package/src/worlds/minecraft/client-launch.ts +11 -2
  96. package/src/worlds/minecraft/client.ts +4 -0
  97. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  98. package/src/worlds/qq/normalize.ts +14 -0
  99. package/src/worlds/qq/world.ts +56 -10
  100. 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.3",
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
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { existsSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
+ import type { CoreConfig, LLMProviderEntry } from './core/types.ts';
7
8
 
8
9
  export const RESTART_FLAG_FILE = '.restart-request';
9
10
 
@@ -19,6 +20,14 @@ export function isSupervised(env: NodeJS.ProcessEnv = process.env): boolean {
19
20
  return env[SUPERVISED_ENV] === '1' || env[SUPERVISED_ENV] === 'true';
20
21
  }
21
22
 
23
+ /**
24
+ * 启动时是否暂停事件投递。每一轮受监管的启动拿到同一份环境和同一串参数,
25
+ * 所以这个值也是重启后的状态。
26
+ */
27
+ export function startsPaused(env: NodeJS.ProcessEnv = process.env, argv: readonly string[] = process.argv): boolean {
28
+ return env.CORTICO_START_PAUSED === '1' || env.CORTICO_START_PAUSED === 'true' || argv.includes('--paused');
29
+ }
30
+
22
31
  function notifyLauncher(message: { type: string; dataDir?: string }, env = process.env): void {
23
32
  if (!isSupervised(env)) return;
24
33
  process.send?.(message);
@@ -44,3 +53,23 @@ export function consumeBootFlags(dataDir: string): void {
44
53
  }
45
54
  }
46
55
  }
56
+
57
+ export function listensOnEveryInterface(bound: string | null): boolean {
58
+ return bound === '0.0.0.0' || bound === '::';
59
+ }
60
+
61
+ /** 本机打得开的控制台地址:监听所有网卡时给回环地址,IPv6 加方括号。 */
62
+ export function consoleUrlOf(bound: string | null, port: number): string {
63
+ const host = bound === null || listensOnEveryInterface(bound) ? '127.0.0.1' : bound.includes(':') ? `[${bound}]` : bound;
64
+ return `http://${host}:${port}/`;
65
+ }
66
+
67
+ /** 空选择允许进入控制台完成配置;非空名称必须引用已有连接。 */
68
+ export function providerAtBoot(config: Pick<CoreConfig, 'activeProvider' | 'providers'>): LLMProviderEntry | undefined {
69
+ if (!config.activeProvider) return undefined;
70
+ const entry = config.providers[config.activeProvider];
71
+ if (!entry) {
72
+ throw new Error(`activeProvider="${config.activeProvider}" 在 providers 段里不存在(现有: ${Object.keys(config.providers).join(' / ') || '无'})`);
73
+ }
74
+ return entry;
75
+ }
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 };
@@ -34,6 +34,9 @@ Core 管理 session、事件流与模型调用的生命周期,包括事件投
34
34
  `sessionDecls`;`spawnFork`、`resolveBlob` / `internBlobs`、`setWorldVisible`、`activeSpec` /
35
35
  `activeProviderEntry`、`mountWorld` / `unmountWorld`、`start` / `stop`。
36
36
 
37
+ `WorldHost.modelFacts` 每次调用按当前端点读取。`activeProvider` 为空或端点没有模型时 `accepts`
38
+ 返回 false,`activeProvider` 指向不存在的端点时抛错。
39
+
37
40
  Persona 通过 `CoreApi` 访问:`injectInternal` / `injectDeferred` / `injectExternal`、
38
41
  `requestContextHandoff`、`spawnFork`、`sessionInfo`、`llm`、`timers`、`deliveryGate`、
39
42
  `personaState` / `savePersonaState`、`toolsTagged`、`blob`、`log`。
@@ -51,14 +54,19 @@ piggyback 只入队,随后续唤醒一起投递。
51
54
  更新后的值在下一次入队时参与计算。
52
55
 
53
56
  投递水位 `lastDeliveredCursor` 持久化,队列不持久化。重启时补投水位之后的外部事件;
54
- 已被候选处理结果引用的原始归档不重复投递。内部事件仅在当次运行投递。
57
+ 已被候选处理结果引用的原始归档不重复投递。内部事件仅在当次运行投递。清空分片或跳过损坏行
58
+ 留下的游标空位没有可投递内容,不阻止水位推进。
55
59
 
56
60
  ## 主循环
57
61
 
58
62
  每次唤醒处理一批事件,可进行多轮模型调用。`SessionDecl.rounds()` 提供
59
63
  `{ soft, hard, softHint? }`:到 soft 轮时将 `softHint` 追加到最后一条工具回执;到 hard 轮时
60
64
  记录 warn 并结束本批。`endsTurn` 工具和自然结束共用结束处理;本批结束时尚未处理的事件
61
- 退回总线,进入下一批。
65
+ 退回总线,进入下一批。控制台的前缀重载与清空 session 在批次边界执行:正在处理批次时等该批
66
+ 结束,空闲时立即;并发请求复用同一事务。
67
+
68
+ 一批正文归档后,Core 调用 `Persona.onDelivery` 并等待它返回的 Promise,不设期限。完成前
69
+ `injectInternal` 的内容排在这批的内部行末尾、外部正文之前;完成后的注入进入总线。
62
70
 
63
71
  状态 0、429 或 5xx 的模型调用失败,可保留已记录的输出和工具回执,按 `ResubmitPolicy` 重试。
64
72
  默认允许连续重试 2 次、每批最多 4 次,退避为 2 秒、10 秒;上下文超限、抢占、关机或轮数
@@ -86,3 +94,5 @@ handler 异常转为失败回执。流式生成时 `EagerDispatch` 可提前执
86
94
  warn;`onHandoff` 异常记录 error 并使用默认交接策略。其他钩子的异常由调用方处理。
87
95
  读取 `console()` 失败时省略该 World 的环境段,继续构建前缀。主循环异常退出时记录 error
88
96
  并停止定时器。启动器处理未捕获的进程异常并执行关机流程。
97
+
98
+ `instanceIsRunning(dataDir)` 只读检查实例锁对应进程是否存活,沿用锁接管时的陈旧记录判定,不获取或更改锁。
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
 
@@ -464,8 +468,10 @@ export class Core<C extends CoreConfig = CoreConfig> {
464
468
  return {
465
469
  model: () => spec().model,
466
470
  accepts: (mime) => {
471
+ if (!this.loaded.config.activeProvider) return false;
467
472
  const {entry} = this.activeProviderEntry();
468
- return providerModule(entry.kind).accepts?.(entry,spec(),mime) ?? (entry.multimodal === true && mime.startsWith('image/'));
473
+ if (!entry.spec) return false;
474
+ return providerModule(entry.kind).accepts?.(entry,entry.spec,mime) ?? (entry.multimodal === true && mime.startsWith('image/'));
469
475
  },
470
476
  contextWindow: () => this.contextWindowOf(spec()),
471
477
  };
@@ -587,7 +593,13 @@ export class Core<C extends CoreConfig = CoreConfig> {
587
593
  const dataDir = this.loaded.dataDir;
588
594
  if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
589
595
  // 构造期已加载状态;此处再次 load 会覆盖装配后、启动前的修改。
590
- this.session.load();
596
+ const repair = this.session.load();
597
+ if (repair) {
598
+ this.log.warn(
599
+ repair.kind === 'torn-tail' ? 'session 末行未写完,已截掉' : 'session 末行缺换行,已补上',
600
+ { ...repair, file: 'session-main.jsonl' },
601
+ );
602
+ }
591
603
  for (const mod of this.worlds) {
592
604
  try {
593
605
  await mod.start(this.makeHost(mod));
@@ -16,13 +16,26 @@ export interface TokenMeters {
16
16
  }
17
17
  export type Meter = Exclude<keyof TokenMeters, 'native' | 'details'> | `detail:${string}`;
18
18
  export interface PriceRule { meter: Meter; perMillion: number; unit?: string; }
19
- export interface PriceSnapshot {
19
+ export interface PriceSchedule { rules: PriceRule[]; inputBands?: Array<{ from: number; rules: PriceRule[] }>; }
20
+ export interface PriceTable extends PriceSchedule { serviceTiers?: Record<string, PriceSchedule>; }
21
+ /**
22
+ * `from` and `to` are `HH:MM` on the clock of `timezone`, half-open; `to` at or before `from` spans midnight.
23
+ * A span belongs to the local date it starts on. `weekdays` (ISO, 1 = Monday to 7 = Sunday) limits the dates
24
+ * that open it; `exceptDates` (`YYYY-MM-DD`) removes single dates.
25
+ */
26
+ export interface PriceWindow extends PriceTable {
27
+ from: string;
28
+ to: string;
29
+ timezone: string;
30
+ weekdays?: number[];
31
+ exceptDates?: string[];
32
+ }
33
+ export interface PriceSnapshot extends PriceTable {
20
34
  id: string;
21
35
  currency: string;
22
36
  basis: 'marginal' | 'equivalent';
23
- rules: PriceRule[];
24
- inputBands?: Array<{ from: number; rules: PriceRule[] }>;
25
- serviceTiers?: Record<string, { rules: PriceRule[]; inputBands?: Array<{ from: number; rules: PriceRule[] }> }>;
37
+ /** The first window holding `capturedAt` replaces the table above it; outside every window that table applies. */
38
+ timeWindows?: PriceWindow[];
26
39
  source: string;
27
40
  capturedAt: string;
28
41
  }
@@ -30,7 +43,7 @@ export interface Charge {
30
43
  quote: PriceSnapshot;
31
44
  amount: number | null;
32
45
  knownAmount: number;
33
- missing: Array<Meter | 'serviceTier'>;
46
+ missing: Array<Meter | 'serviceTier' | 'timeWindow'>;
34
47
  lines: Array<{ meter: Meter; unit: string; quantity: number | null; perMillion: number; amount: number | null }>;
35
48
  }
36
49
  export interface ProviderAttempt {
@@ -89,13 +102,57 @@ export function standardUsage(meters: TokenMeters): Usage | null {
89
102
  return { input_tokens: input, output_tokens: output, total_tokens: total,
90
103
  input_tokens_details: { cached_tokens: cachedInput }, output_tokens_details: { reasoning_tokens: reasoning } };
91
104
  }
105
+ /** Minutes since midnight for an `HH:MM` bound, 24:00 included; null when the text is not one. */
106
+ function clockMinutes(value: string): number | null {
107
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value);
108
+ if (!match) return null;
109
+ const minutes = Number(match[1]) * 60 + Number(match[2]);
110
+ return Number(match[2]) > 59 || minutes > 24 * 60 ? null : minutes;
111
+ }
112
+ /** Whether the text is a real calendar date written `YYYY-MM-DD`. */
113
+ function isCalendarDate(value: string): boolean {
114
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
115
+ if (!match) return false;
116
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
117
+ const date = new Date(Date.UTC(year, month - 1, day));
118
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
119
+ }
120
+ /** Whether the instant falls in the window on the window's own clock and date; null when a field is unreadable. */
121
+ function holdsTime(window: PriceWindow, at: string): boolean | null {
122
+ const instant = new Date(at);
123
+ const [from, to] = [clockMinutes(window.from), clockMinutes(window.to)];
124
+ if (from === null || to === null || Number.isNaN(instant.getTime())) return null;
125
+ if (window.weekdays?.some(day => !Number.isInteger(day) || day < 1 || day > 7)) return null;
126
+ if (window.exceptDates?.some(date => !isCalendarDate(date))) return null;
127
+ let year: number, month: number, day: number, local: number;
128
+ try {
129
+ // Intl throws on a zone name it does not know.
130
+ const parts = new Intl.DateTimeFormat('en-GB', { timeZone: window.timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(instant);
131
+ const read = (type: string): number => Number(parts.find(part => part.type === type)!.value);
132
+ [year, month, day] = [read('year'), read('month'), read('day')];
133
+ local = read('hour') * 60 + read('minute');
134
+ } catch { return null; }
135
+ const spansMidnight = to <= from;
136
+ if (spansMidnight ? local < from && local >= to : local < from || local >= to) return false;
137
+ // After midnight the span still belongs to the previous date, the one it started on.
138
+ const start = new Date(Date.UTC(year, month - 1, spansMidnight && local < to ? day - 1 : day));
139
+ if (window.weekdays && !window.weekdays.includes(start.getUTCDay() || 7)) return false;
140
+ return !window.exceptDates?.includes(start.toISOString().slice(0, 10));
141
+ }
92
142
  export function priceUsage(meters: TokenMeters, quotes: readonly PriceSnapshot[], serviceTier: string | null = null): Charge[] {
93
143
  return quotes.map(quote => {
94
144
  const missing: Charge['missing'] = [];
95
- let schedule: Pick<PriceSnapshot, 'rules' | 'inputBands'> = quote;
96
- if (quote.serviceTiers) {
97
- if (serviceTier === null || !quote.serviceTiers[serviceTier]) missing.push('serviceTier');
98
- else schedule = quote.serviceTiers[serviceTier];
145
+ let table: PriceTable = quote;
146
+ for (const window of quote.timeWindows ?? []) {
147
+ const holds = holdsTime(window, quote.capturedAt);
148
+ // An unreadable window may be the one holding the request; a later table cannot stand in for it.
149
+ if (holds === null) { missing.push('timeWindow'); break; }
150
+ if (holds) { table = window; break; }
151
+ }
152
+ let schedule: PriceSchedule = table;
153
+ if (table.serviceTiers) {
154
+ if (serviceTier === null || !table.serviceTiers[serviceTier]) missing.push('serviceTier');
155
+ else schedule = table.serviceTiers[serviceTier];
99
156
  }
100
157
  let rules = schedule.rules;
101
158
  if (schedule.inputBands?.length) {
@@ -72,6 +72,12 @@ function readPayload(file: string): LockPayload | null {
72
72
  }
73
73
  }
74
74
 
75
+ /** 只读检查数据目录的锁是否仍对应存活进程。 */
76
+ export function instanceIsRunning(dataDir: string): boolean {
77
+ const payload = readPayload(join(dataDir, INSTANCE_LOCK_FILE));
78
+ return payload !== null && ownerStillRunning(payload);
79
+ }
80
+
75
81
  /** 排他创建;仅 EEXIST 返回 false,其他错误抛出。 */
76
82
  function createExclusive(file: string, payload: LockPayload): boolean {
77
83
  let fd: number;