From 348729c8b89da2ea38bb481b1c633b8d26acb430 Mon Sep 17 00:00:00 2001 From: Phant Date: Tue, 20 Oct 2026 19:04:11 -0500 Subject: [PATCH 1/3] =?UTF-8?q?fix(core):=20=E7=94=A8=E9=87=8F=E8=B4=A6?= =?UTF-8?q?=E6=9C=AC=E6=8C=89=204MB=20=E5=9D=97=E9=80=90=E8=A1=8C=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=95=B4=E4=BB=BD=E8=BD=AC?= =?UTF-8?q?=E6=88=90=E5=AD=97=E7=AC=A6=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:readDisk 把整份 usage.jsonl 读成一个字符串再切行,账本超过 V8 字符串上限(约 512MB)时抛 ERR_STRING_TOO_LONG,用量页显示账目存储异常 - 在换行字节处切分,UTF-8 多字节字符内不会出现这个字节;新测试用跨多个读取块、块边界落在汉字中间的账本挡这条回归 - 本机 239MB 的真实账本新旧读法结果一致,1980ms→1162ms Co-Authored-By: Claude Opus 5.5 --- src/core/usage-log.ts | 28 ++++++++++++++++++++++++++-- tests/core/usage-log.test.ts | 12 ++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/core/usage-log.ts b/src/core/usage-log.ts index 4ad1da16b..96446c182 100644 --- a/src/core/usage-log.ts +++ b/src/core/usage-log.ts @@ -1,8 +1,32 @@ /** Append-only usage ledger. Failed writes remain queued and are reported to the operator. */ -import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import type { UsageRecord } from './types.ts'; +/** The ledger is read in chunks so its size is not bounded by V8's maximum string length. */ +const READ_CHUNK_BYTES = 4 * 1024 * 1024; + +/** Lines split at the newline byte, which never occurs inside a UTF-8 multibyte sequence. */ +function* fileLines(file: string): Generator { + const fd = openSync(file, 'r'); + try { + const chunk = Buffer.alloc(READ_CHUNK_BYTES); + let rest = Buffer.alloc(0); + for (let n = readSync(fd, chunk); n > 0; n = readSync(fd, chunk)) { + const buf = rest.length ? Buffer.concat([rest, chunk.subarray(0, n)]) : chunk.subarray(0, n); + let start = 0; + for (let end = buf.indexOf(10); end !== -1; end = buf.indexOf(10, start)) { + yield buf.toString('utf8', start, end); + start = end + 1; + } + rest = Buffer.from(buf.subarray(start)); + } + if (rest.length) yield rest.toString('utf8'); + } finally { + closeSync(fd); + } +} + export class UsageLog { private pending: UsageRecord[] = []; private error: string | null = null; @@ -45,7 +69,7 @@ export class UsageLog { const out: UsageRecord[] = []; const seen = new Set(); let damaged = 0; - for (const line of readFileSync(this.file, 'utf8').split(/\r?\n/)) { + for (const line of fileLines(this.file)) { if (!line.trim()) continue; let record: UsageRecord; try { record = JSON.parse(line) as UsageRecord; } catch { damaged++; continue; } diff --git a/tests/core/usage-log.test.ts b/tests/core/usage-log.test.ts index cfebf5690..80b62a2c4 100644 --- a/tests/core/usage-log.test.ts +++ b/tests/core/usage-log.test.ts @@ -44,6 +44,18 @@ describe('UsageLog', () => { expect(readFileSync(file, 'utf8')).toBe(raw); }); + it('账本跨多个读取块时每行完整读出,多字节字符落在块边界上也不损坏', () => { + const label = '主意识'.repeat(700); + const lines = Array.from({ length: 1400 }, (_, i) => JSON.stringify({ ...rec(1), version: 2, recordId: `r${i}`, label })); + writeFileSync(file, lines.join('\r\n') + '\n'); + expect(readFileSync(file).byteLength).toBeGreaterThan(8 * 1024 * 1024); + const errors: string[] = []; + const all = new UsageLog(file, error => errors.push(error)).readAll(); + expect(all.map(r => r.recordId)).toEqual(lines.map((_, i) => `r${i}`)); + expect(all.every(r => r.label === label)).toBe(true); + expect(errors).toEqual([]); + }); + it('坏行跳过,不影响其它行', () => { const log = new UsageLog(file); log.append(rec(1)); From 5e682cdcf1a3d96c77fed31317da4a737d7903e4 Mon Sep 17 00:00:00 2001 From: Phant Date: Tue, 20 Oct 2026 19:05:19 -0500 Subject: [PATCH 2/3] =?UTF-8?q?fix(bots/cormini):=20save=5Fblob=20?= =?UTF-8?q?=E6=94=B6=E5=88=B0=E4=B8=8D=E5=B8=A6=20log:=20=E7=9A=84?= =?UTF-8?q?=E5=93=88=E5=B8=8C=E6=97=B6=E6=8C=89=20log:=20=E5=8F=A5?= =?UTF-8?q?=E6=9F=84=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:参数说明写「8 位以上十六进制前缀就够」,有的模型据此只传哈希;LogBlobStore 只认带 log: 的句柄,于是报没有这份二进制 - 说明改成带 log: 的写法并给示例;测试加了只传哈希也能存的用例 Co-Authored-By: Claude Opus 5.5 --- bots/cormini/persona/blobs.ts | 9 ++++++--- tests/cormini/blobs.test.ts | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bots/cormini/persona/blobs.ts b/bots/cormini/persona/blobs.ts index c5648626d..86a9faaad 100644 --- a/bots/cormini/persona/blobs.ts +++ b/bots/cormini/persona/blobs.ts @@ -1,4 +1,5 @@ /** save_blob 工具的参数、权限检查与回执;附件存储由 WorkspaceBlobStore 提供。 */ +import { LOG_SCHEME, blobScheme } from 'cortico/core/blobs.ts'; import type { BlobStore, CoreApi, ToolDef } from 'cortico/core/types.ts'; export const SAVE_BLOB_DECL: Omit = { @@ -11,7 +12,7 @@ export const SAVE_BLOB_DECL: Omit = { parameters: { type: 'object', properties: { - handle: { type: 'string', description: 'The log: handle (a unique prefix of 8+ hex digits is enough).' }, + handle: { type: 'string', description: 'The log: handle as written in the [blob ...] line, e.g. log:1a2b3c4d (a unique prefix of 8+ hex digits after log: is enough).' }, path: { type: 'string', description: 'Where to keep it, relative to your workspace, with an extension.' }, }, required: ['handle', 'path'], @@ -28,9 +29,11 @@ export function saveBlobTool(deps: { return { ...SAVE_BLOB_DECL, handler: async (args, ctx) => { - const handle = String(args.handle ?? '').trim(); + const given = String(args.handle ?? '').trim(); const path = String(args.path ?? '').trim(); - if (!handle) return '[save failed] handle 不能为空'; + if (!given) return '[save failed] handle 不能为空'; + // 只给了哈希时按 log: 句柄解析 + const handle = blobScheme(given) ? given : `${LOG_SCHEME}${given}`; if (!path) return '[save failed] path 不能为空'; const denied = deps.guard?.(path, ctx.role) ?? null; if (denied) return `[save failed] ${denied}`; diff --git a/tests/cormini/blobs.test.ts b/tests/cormini/blobs.test.ts index ffef1ad41..9d4c6eaab 100644 --- a/tests/cormini/blobs.test.ts +++ b/tests/cormini/blobs.test.ts @@ -64,6 +64,7 @@ describe('save_blob 与 read_file 的二进制回执', () => { const out = await tool.handler({ handle: 'log:abcdef01', path: 'blobs/stickers/cat.png' }, ctx); expect(out).toBe(`[saved] mem:blobs/stickers/cat.png image/png ${PNG.length} 字节`); expect(readFileSync(join(dir, 'blobs', 'stickers', 'cat.png')).equals(PNG)).toBe(true); + expect(await tool.handler({ handle: 'abcdef01', path: 'blobs/bare.png' }, ctx)).toBe(`[saved] mem:blobs/bare.png image/png ${PNG.length} 字节`); expect(await tool.handler({ handle: 'log:nope', path: 'blobs/x.png' }, ctx)).toContain('[save failed] 没有 log:nope'); expect(await tool.handler({ handle: 'log:abcdef01', path: '../x.png' }, ctx)).toContain('[save failed]'); }); From b9fc251a233cc6d51bd5bf304cf6c22a5fa681c1 Mon Sep 17 00:00:00 2001 From: Phant Date: Tue, 20 Oct 2026 19:09:21 -0500 Subject: [PATCH 3/3] =?UTF-8?q?feat(webui):=20=E6=96=B0=E8=A3=85=20World?= =?UTF-8?q?=20=E6=89=A9=E5=B1=95=E5=90=8E=E6=8F=90=E7=A4=BA=E5=88=B0=20Wor?= =?UTF-8?q?ld=20=E6=80=BB=E8=A7=88=E6=BF=80=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展模板的 World 默认 enabled:false,装完重启后不会自己挂载;提示只在首次安装、包类别为 world 时出现,更新不出 - 11 种语言,用词对齐各语言的 World 总览页名与「激活」 Co-Authored-By: Claude Opus 5.5 --- src/web/client/features/extensions/index.ts | 3 ++- src/web/client/features/extensions/strings.de.ts | 1 + src/web/client/features/extensions/strings.es-419.ts | 1 + src/web/client/features/extensions/strings.fr.ts | 1 + src/web/client/features/extensions/strings.it.ts | 1 + src/web/client/features/extensions/strings.ja.ts | 1 + src/web/client/features/extensions/strings.ko.ts | 1 + src/web/client/features/extensions/strings.pt-BR.ts | 1 + src/web/client/features/extensions/strings.ru.ts | 1 + src/web/client/features/extensions/strings.ts | 2 ++ src/web/client/features/extensions/strings.zh-Hant.ts | 1 + 11 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/web/client/features/extensions/index.ts b/src/web/client/features/extensions/index.ts index 5b8e0b860..78c24c7b3 100644 --- a/src/web/client/features/extensions/index.ts +++ b/src/web/client/features/extensions/index.ts @@ -1283,7 +1283,8 @@ export function mountExtensions(ctx: FeatureContext): void { } if (signal.aborted) return; const restartHint = action === 'delete' || resultKind !== 'bot' ? ' ' + S.restartNeeded : ''; - message(owner, area, `${S.completed(actionLabel, name)}${version ? ' ' + S.version(version) : ''}${restartHint}`); + const activateHint = actionLabel === S.installedAction && resultKind === 'world' ? ' ' + S.worldActivateHint : ''; + message(owner, area, `${S.completed(actionLabel, name)}${version ? ' ' + S.version(version) : ''}${restartHint}${activateHint}`); updates.delete(name); if (!await load()) message(owner, area, S.completed(actionLabel, name) + ' ' + S.syncedFailed, true); } catch (error) { if (!signal.aborted) message(owner, area, errorText(error), true); } diff --git a/src/web/client/features/extensions/strings.de.ts b/src/web/client/features/extensions/strings.de.ts index 6bdb60d70..bf36aee23 100644 --- a/src/web/client/features/extensions/strings.de.ts +++ b/src/web/client/features/extensions/strings.de.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} von ${name} läuft…`, completed: (action: string, name: string) => `${action} von ${name} abgeschlossen.`, restartNeeded: 'Gilt nach dem Neustart des Prozesses.', + worldActivateHint: 'Ist die World nicht aktiv, aktiviere sie in der World-Übersicht.', syncedFailed: 'Vorgang abgeschlossen, aber die Liste ist nicht synchron. Aktualisiere, um es erneut zu versuchen.', filterPlaceholder: 'Filter: Name, Beschreibung, Stichwörter', diff --git a/src/web/client/features/extensions/strings.es-419.ts b/src/web/client/features/extensions/strings.es-419.ts index 33da4e23e..df7f7fe2c 100644 --- a/src/web/client/features/extensions/strings.es-419.ts +++ b/src/web/client/features/extensions/strings.es-419.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} en curso: ${name}…`, completed: (action: string, name: string) => `${action} completada: ${name}.`, restartNeeded: 'Surte efecto después de reiniciar el proceso.', + worldActivateHint: 'Si el World no está activo, actívalo en Resumen de Worlds.', syncedFailed: 'La operación se completó, pero la lista no está sincronizada. Actualiza para reintentar.', filterPlaceholder: 'Filtrar: nombre, descripción, palabras clave', diff --git a/src/web/client/features/extensions/strings.fr.ts b/src/web/client/features/extensions/strings.fr.ts index 10323fdd5..7521fd29d 100644 --- a/src/web/client/features/extensions/strings.fr.ts +++ b/src/web/client/features/extensions/strings.fr.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} de ${name} en cours…`, completed: (action: string, name: string) => `${action} de ${name} réussie.`, restartNeeded: 'Prend effet après le redémarrage du processus.', + worldActivateHint: "Si le World n'est pas actif, activez-le dans Vue d'ensemble des World.", syncedFailed: "Opération terminée, mais la liste n'est pas synchronisée. Actualisez pour réessayer.", filterPlaceholder: 'Filtrer : nom, description, mots-clés', diff --git a/src/web/client/features/extensions/strings.it.ts b/src/web/client/features/extensions/strings.it.ts index 51d1fbae0..184328c5a 100644 --- a/src/web/client/features/extensions/strings.it.ts +++ b/src/web/client/features/extensions/strings.it.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} di ${name} in corso…`, completed: (action: string, name: string) => `Operazione completata: ${action.toLowerCase()} di ${name}.`, restartNeeded: 'Ha effetto dopo il riavvio del processo.', + worldActivateHint: 'Se il World non è attivo, attivalo in Panoramica dei World.', syncedFailed: "Operazione completata, ma l'elenco non è sincronizzato. Aggiorna per riprovare.", filterPlaceholder: 'Filtra: nome, descrizione, parole chiave', diff --git a/src/web/client/features/extensions/strings.ja.ts b/src/web/client/features/extensions/strings.ja.ts index 96a2ace8f..986135dd1 100644 --- a/src/web/client/features/extensions/strings.ja.ts +++ b/src/web/client/features/extensions/strings.ja.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action}中:${name}…`, completed: (action: string, name: string) => `${action}が完了しました:${name}。`, restartNeeded: 'プロセスの再起動後に反映されます。', + worldActivateHint: '有効になっていない World は World 一覧で有効化してください。', syncedFailed: '操作は完了しましたが、一覧が同期されていません。更新してください。', filterPlaceholder: '絞り込み:パッケージ名、説明、キーワード', diff --git a/src/web/client/features/extensions/strings.ko.ts b/src/web/client/features/extensions/strings.ko.ts index b99840659..4c533925b 100644 --- a/src/web/client/features/extensions/strings.ko.ts +++ b/src/web/client/features/extensions/strings.ko.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} 중: ${name}…`, completed: (action: string, name: string) => `${action} 완료: ${name}.`, restartNeeded: '프로세스를 재시작한 뒤 적용됩니다.', + worldActivateHint: '활성화되지 않은 World는 World 개요에서 활성화하세요.', syncedFailed: '작업은 끝났지만 목록이 동기화되지 않았습니다. 새로 고치세요.', filterPlaceholder: '필터: 패키지 이름, 설명, 키워드', diff --git a/src/web/client/features/extensions/strings.pt-BR.ts b/src/web/client/features/extensions/strings.pt-BR.ts index 8d61909ed..47ae11d62 100644 --- a/src/web/client/features/extensions/strings.pt-BR.ts +++ b/src/web/client/features/extensions/strings.pt-BR.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} em andamento: ${name}…`, completed: (action: string, name: string) => `${action} concluída: ${name}.`, restartNeeded: 'Tem efeito após reiniciar o processo.', + worldActivateHint: 'Se o World não estiver ativo, ative-o em Visão geral dos Worlds.', syncedFailed: 'Operação concluída, mas a lista está dessincronizada. Atualize para tentar de novo.', filterPlaceholder: 'Filtrar: nome, descrição, palavras-chave', diff --git a/src/web/client/features/extensions/strings.ru.ts b/src/web/client/features/extensions/strings.ru.ts index ae93feb56..b034d0465 100644 --- a/src/web/client/features/extensions/strings.ru.ts +++ b/src/web/client/features/extensions/strings.ru.ts @@ -66,6 +66,7 @@ export const S: Partial = { progress: (action: string, name: string) => `${action} ${name}…`, completed: (action: string, name: string) => `${action} ${name}: готово.`, restartNeeded: 'Вступит в силу после перезапуска процесса.', + worldActivateHint: 'Если World не активен, активируйте его на странице «Обзор World».', syncedFailed: 'Операция выполнена, но список не синхронизирован. Обновите, чтобы повторить.', filterPlaceholder: 'Фильтр: имя, описание, ключевые слова', diff --git a/src/web/client/features/extensions/strings.ts b/src/web/client/features/extensions/strings.ts index 8f51c36e7..452410628 100644 --- a/src/web/client/features/extensions/strings.ts +++ b/src/web/client/features/extensions/strings.ts @@ -70,6 +70,7 @@ const zh = { progress: (action: string, name: string) => `正在${action}:${name}…`, completed: (action: string, name: string) => `${action}成功:${name}。`, restartNeeded: '重启进程后生效。', + worldActivateHint: '未激活的 World 要到 World 总览里激活。', syncedFailed: '操作已完成,但列表未同步;请刷新。', filterPlaceholder: '筛选:包名、描述、关键字', @@ -308,6 +309,7 @@ export const en: typeof zh = { progress: (action: string, name: string) => `${action}: ${name}…`, completed: (action: string, name: string) => `${action} completed: ${name}.`, restartNeeded: 'Takes effect after the process restarts.', + worldActivateHint: 'If the World is not active, activate it in World overview.', syncedFailed: 'Operation completed, but the list is out of sync. Refresh to retry.', filterPlaceholder: 'Filter: name, description, keywords', diff --git a/src/web/client/features/extensions/strings.zh-Hant.ts b/src/web/client/features/extensions/strings.zh-Hant.ts index 3e9006bcb..40ee052b9 100644 --- a/src/web/client/features/extensions/strings.zh-Hant.ts +++ b/src/web/client/features/extensions/strings.zh-Hant.ts @@ -60,6 +60,7 @@ export const S: Partial = { progress: (action: string, name: string) => `正在${action}:${name}…`, completed: (action: string, name: string) => `${action}成功:${name}。`, restartNeeded: '重新啟動處理程序後生效。', + worldActivateHint: '未啟用的 World 請到 World 總覽啟用。', syncedFailed: '操作已完成,但清單未同步;請重新整理。', filterPlaceholder: '篩選:套件名稱、描述、關鍵字',