Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions bots/cormini/persona/blobs.ts
Original file line number Diff line number Diff line change
@@ -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<ToolDef, 'handler'> = {
Expand All @@ -11,7 +12,7 @@ export const SAVE_BLOB_DECL: Omit<ToolDef, 'handler'> = {
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'],
Expand All @@ -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}`;
Expand Down
28 changes: 26 additions & 2 deletions src/core/usage-log.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
Expand Down Expand Up @@ -45,7 +69,7 @@ export class UsageLog {
const out: UsageRecord[] = [];
const seen = new Set<string>();
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; }
Expand Down
3 changes: 2 additions & 1 deletion src/web/client/features/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
1 change: 1 addition & 0 deletions src/web/client/features/extensions/strings.de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const S: Partial<typeof en> = {
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',
Expand Down
1 change: 1 addition & 0 deletions src/web/client/features/extensions/strings.es-419.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const S: Partial<typeof en> = {
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',
Expand Down
1 change: 1 addition & 0 deletions src/web/client/features/extensions/strings.fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const S: Partial<typeof en> = {
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',
Expand Down
1 change: 1 addition & 0 deletions src/web/client/features/extensions/strings.it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const S: Partial<typeof en> = {
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',
Expand Down
1 change: 1 addition & 0 deletions src/web/client/features/extensions/strings.ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const S: Partial<typeof en> = {
progress: (action: string, name: string) => `${action}中:${name}…`,
completed: (action: string, name: string) => `${action}が完了しました:${name}。`,
restartNeeded: 'プロセスの再起動後に反映されます。',
worldActivateHint: '有効になっていない World は World 一覧で有効化してください。',
syncedFailed: '操作は完了しましたが、一覧が同期されていません。更新してください。',

filterPlaceholder: '絞り込み:パッケージ名、説明、キーワード',
Expand Down
1 change: 1 addition & 0 deletions src/web/client/features/extensions/strings.ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const S: Partial<typeof en> = {
progress: (action: string, name: string) => `${action} 중: ${name}…`,
completed: (action: string, name: string) => `${action} 완료: ${name}.`,
restartNeeded: '프로세스를 재시작한 뒤 적용됩니다.',
worldActivateHint: '활성화되지 않은 World는 World 개요에서 활성화하세요.',
syncedFailed: '작업은 끝났지만 목록이 동기화되지 않았습니다. 새로 고치세요.',

filterPlaceholder: '필터: 패키지 이름, 설명, 키워드',
Expand Down
1 change: 1 addition & 0 deletions src/web/client/features/extensions/strings.pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const S: Partial<typeof en> = {
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',
Expand Down
1 change: 1 addition & 0 deletions src/web/client/features/extensions/strings.ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const S: Partial<typeof en> = {
progress: (action: string, name: string) => `${action} ${name}…`,
completed: (action: string, name: string) => `${action} ${name}: готово.`,
restartNeeded: 'Вступит в силу после перезапуска процесса.',
worldActivateHint: 'Если World не активен, активируйте его на странице «Обзор World».',
syncedFailed: 'Операция выполнена, но список не синхронизирован. Обновите, чтобы повторить.',

filterPlaceholder: 'Фильтр: имя, описание, ключевые слова',
Expand Down
2 changes: 2 additions & 0 deletions src/web/client/features/extensions/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '筛选:包名、描述、关键字',
Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/web/client/features/extensions/strings.zh-Hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const S: Partial<typeof en> = {
progress: (action: string, name: string) => `正在${action}:${name}…`,
completed: (action: string, name: string) => `${action}成功:${name}。`,
restartNeeded: '重新啟動處理程序後生效。',
worldActivateHint: '未啟用的 World 請到 World 總覽啟用。',
syncedFailed: '操作已完成,但清單未同步;請重新整理。',

filterPlaceholder: '篩選:套件名稱、描述、關鍵字',
Expand Down
12 changes: 12 additions & 0 deletions tests/core/usage-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
1 change: 1 addition & 0 deletions tests/cormini/blobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
});
Expand Down
Loading