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
3 changes: 2 additions & 1 deletion docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ provider 是 `providers.<端点名>.options.`。写回的三道闸(服务端的
与 `spec`。这些值存在端点条目里,不经 `defaults()`,因此只核对段。

自描述检查:定义没写 `describe`、实例的工具名不在 `describe.tools` 里(bot 按 `persona.ownToolNames()`)、
package.json 没有 `cortico.describe`、它指的文件不在或与重新生成的内容不一致,都是失败。`cortico.describe`
package.json 没有 `cortico.describe`、它指的文件不在或与重新生成的内容不一致,都是失败。World 实例在默认配置下
改写了声明的 `description` 或 `parameters` 时只给 warning:扩展市场按声明显示工具数与工具定义的 token 数。`cortico.describe`
须是包内的 `.json`;`tags` 与 `screenshots` 须是字符串数组,`locales` 须是 `{ zh?, en? }` 且每项只含字符串
`displayName` 与 `description`。这几项不合格时只给 warning,包照常加载。

Expand Down
6 changes: 3 additions & 3 deletions scripts/dev-webui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1251,11 +1251,11 @@ const devCatalog: Record<string, Partial<ExtensionSearchHit>> = {
'@acme/cortico-world-discord': {
displayName: 'Discord', author: 'Acme Labs', icon: devSvg(64, 64, '#5865f2', 'D'),
screenshots: [devSvg(320, 180, '#3b4252', '频道列表'), devSvg(320, 180, '#434c5e', '消息投递')],
summary: { tools: 6, events: 9 },
summary: { tools: 6, events: 9, toolTokens: 1820, envPromptTokens: 640 },
changelog: { versions: ['0.3.2', '0.3.1', '0.3.0'], url: 'https://github.com/acme/cortico-world-discord/blob/HEAD/CHANGELOG.md' },
},
'cortico-world-matrix': { displayName: 'Matrix', author: 'Kyoka', os: ['!darwin'], summary: { tools: 4, events: 2 } },
'cortico-world-obs': { author: 'StreamKit', os: ['win32'], icon: devSvg(64, 64, '#302e31', 'OBS'), summary: { tools: 3, events: 0 } },
'cortico-world-matrix': { displayName: 'Matrix', author: 'Kyoka', os: ['!darwin'], summary: { tools: 14, events: 2, toolTokens: 3150, envPromptTokens: 1420 } },
'cortico-world-obs': { author: 'StreamKit', os: ['win32'], icon: devSvg(64, 64, '#302e31', 'OBS'), summary: { tools: 3, events: 0, toolTokens: 7230, envPromptTokens: 12600 } },
'cortico-provider-example': { summary: { protocols: ['openai-chat'], auth: ['api-key', 'oauth'], input: ['text', 'image'], local: false } },
'cortico-bot-example': { displayName: 'Example Bot', summary: { worlds: 3, memory: 'workspace' } },
};
Expand Down
22 changes: 20 additions & 2 deletions src/extensions/describe-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import { existsSync, readFileSync } from 'node:fs';
import { isDeepStrictEqual } from 'node:util';
import type { CoreConfig, PromptDocDecl } from '../core/types.ts';
import type { CoreConfig, PromptDocDecl, ToolSchema } from '../core/types.ts';
import type { BotDefinition } from '../bot.ts';
import type { WorldDefinition, WorldSection } from '../world.ts';
import type { ProviderModule } from '../providers/base.ts';
Expand All @@ -21,6 +21,8 @@ interface Exported {
file: ExtensionDescribeFile;
/** 构造出的实例实际给出的工具名;provider 与没有 `ownToolNames()` 的 Persona 为 null。 */
toolNames: string[] | null;
/** World 实例在默认配置下发给模型的工具定义;其他类别缺席。 */
liveTools?: ToolSchema[];
}

/** 模板文件不存在的项跳过。 */
Expand All @@ -45,9 +47,11 @@ async function exportFrom(kind: ExtensionKind, exported: unknown, opts: BotDryMo
if (!def.describe) return null;
const world = def.create(fakeWorldContext(def, opts));
const docs = (world.webUi?.('zh')?.promptDocs ?? []).filter((doc) => doc.role === 'envPrompt');
const liveTools = world.tools().map(({ name, description, parameters }) => ({ name, description, parameters }));
return {
file: withPrompts({ tools: [...def.describe.tools], events: [...def.describe.events] }, promptsOf(docs)),
toolNames: world.tools().map((tool) => tool.name),
toolNames: liveTools.map((tool) => tool.name),
liveTools,
};
}
if (kind === 'provider') {
Expand Down Expand Up @@ -133,6 +137,20 @@ export async function checkDescribe(
} else if (kind === 'bot') {
warnings.push('The Persona has no ownToolNames(), so its tools cannot be compared with describe.tools.');
}
if (result.liveTools) {
const byName = new Map((result.file.tools ?? []).map((tool) => [tool.name, tool]));
const changed = result.liveTools.flatMap((tool) => {
const decl = byName.get(tool.name);
if (!decl) return [];
const fields = [
...(decl.description === tool.description ? [] : ['description']),
...(isDeepStrictEqual(decl.parameters, tool.parameters) ? [] : ['parameters']),
];
return fields.length ? [`${tool.name} (${fields.join(', ')})`] : [];
});
if (changed.length) warnings.push(`With the default config, tools() returns a description or parameters that differ from describe.tools: ${changed.join(', ')}. The extension market shows the declared tools and their token count. Update the declaration if it is stale; leave it if the difference comes from config.`);
else ok.push('tools(): descriptions and parameters match describe.tools under the default config.');
}

if (describePath === undefined) {
failures.push(`package.json has no cortico.describe. ${rerun} It writes ${DEFAULT_DESCRIBE_FILE} and adds "describe": "${DEFAULT_DESCRIBE_FILE}" to the cortico block.`);
Expand Down
28 changes: 25 additions & 3 deletions src/web/client/features/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export interface ChangelogView {
export interface SummaryView {
tools?: number;
events?: number;
toolTokens?: number;
envPromptTokens?: number;
protocols?: string[];
auth?: Array<'api-key' | 'oauth' | 'device-code' | 'web-session'>;
input?: Array<'text' | 'image' | 'audio' | 'video'>;
Expand Down Expand Up @@ -411,6 +413,12 @@ export const iconUrl = (name: string, version: string | null | undefined): strin
const categories: ExtensionKindView[] = ['world', 'provider', 'bot'];
const TAB_ICONS = { world: 'boxes', provider: 'cpu', bot: 'bot' } as const;
const LINK_ICONS = { npm: 'package', repository: 'code', homepage: 'home', bugs: 'alert' } as const;
/** World 卡片能力摘要的黄 / 红两档:超过前一个数标黄,超过后一个数标红。token 数按 catalog 里的估算。 */
const SPEC_LEVELS = {
toolCount: [10, 20],
toolTokens: [2500, 6000],
envPromptTokens: [1000, 2500],
} as const satisfies Record<string, readonly [number, number]>;
const stateLabels = { loaded: S.disabled, failed: S.stateFailed, 'pending-restart': S.statePendingRestart, removed: S.stateRemoved, idle: S.template };
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);

Expand Down Expand Up @@ -699,20 +707,34 @@ export function mountExtensions(ctx: FeatureContext): void {
}, { signal });
more.addEventListener('keydown', event => event.stopPropagation(), { signal });
}
/** 能力摘要:World 是工具与事件数,供应商是协议、登录与输入,Bot 是 World 数与 Memory。 */
/**
* 能力摘要:World 是工具数、事件数与环境提示词 token 数,供应商是协议、登录与输入,Bot 是 World 数与 Memory。
* 工具数按 SPEC_LEVELS 取个数与工具定义 token 两档里较严的一档着色。
*/
function summaryLine(owner: ExtensionKindView, summary?: SummaryView) {
const line = ui.h('div', 'extension-card-spec');
if (!summary) return line;
const part = (name: Parameters<typeof icon>[1] | null, text: string) => { const el = ui.h('span', ''); if (name) el.append(icon(doc, name)); el.append(ui.h('span', '', text)); line.append(el); };
const part = (name: Parameters<typeof icon>[1] | null, text: string) => { const el = ui.h('span', ''); if (name) el.append(icon(doc, name)); el.append(ui.h('span', '', text)); line.append(el); return el; };
const level = (value: number | undefined, [caution, alert]: readonly [number, number]) => value === undefined ? 0 : value > alert ? 2 : value > caution ? 1 : 0;
const tint = (el: HTMLElement, rank: number) => { if (rank) el.classList.add(rank === 2 ? 'is-alert' : 'is-caution'); };
if (owner === 'provider') {
const text = [summary.protocols?.join(S.listSeparator), summary.auth?.map(a => S.authNames[a]).join(S.listSeparator), summary.input?.map(i => S.inputNames[i]).join(S.listSeparator), summary.local ? S.runsLocal : ''].filter(Boolean).join(S.metaSeparator);
if (text) part('cpu', text);
} else if (owner === 'bot') {
if (summary.worlds !== undefined) part('boxes', S.worldCount(summary.worlds));
if (summary.memory) part(null, S.labelled(S.memory, summary.memory));
} else {
if (summary.tools) part('wrench', S.toolCount(summary.tools));
if (summary.tools) {
const el = part('wrench', S.toolCount(summary.tools));
tint(el, Math.max(level(summary.tools, SPEC_LEVELS.toolCount), level(summary.toolTokens, SPEC_LEVELS.toolTokens)));
if (summary.toolTokens !== undefined) attachHint(el, S.toolsHint(summary.tools, summary.toolTokens));
}
if (summary.events) part('zap', S.eventCount(summary.events));
if (summary.envPromptTokens) {
const el = part('text', S.prefixSize(ui.fmt.count(summary.envPromptTokens)));
tint(el, level(summary.envPromptTokens, SPEC_LEVELS.envPromptTokens));
attachHint(el, S.prefixHint(summary.envPromptTokens));
}
}
return line;
}
Expand Down
6 changes: 6 additions & 0 deletions src/web/client/features/extensions/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ const zh = {
promptsTabBot: '提示词模板',
toolCount: (n: number) => `${n} 个工具`,
eventCount: (n: number) => `${n} 种事件`,
toolsHint: (n: number, tokens: number) => `包含 ${n} 个工具,工具定义占用了约 ${tokens} token`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

悬停文案的前半句是卡片上已经写着的工具数。

AGENTS.md §4 AI slop checklist:Obvious information(Remove common knowledge and instructions evident from the UI)。

  • src/web/client/features/extensions/strings.ts:包含 ${n} 个工具,工具定义占用了约 ${tokens} token
  • 同文件英文:Includes ${n} tools; their definitions take about ${tokens} tokens

按清单删除界面上已有的部分。

— Cortico PR Gate via Cursor Cloud Agent

prefixSize: (tokens: string) => `前缀 ≈${tokens}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

界面把环境提示词的 token 数标成了「前缀」。

AGENTS.md §4 AI slop checklist:Accuracy after editing。本仓库里 prefix 是 Persona 总装模板,envPrompt 是 World 环境模板。

  • src/web/client/features/extensions/strings.ts:前缀 ≈${tokens} / Prefix ≈${tokens}
  • PR 描述:「新增「前缀 ≈N」」「前缀 45 白 / 6 黄 / 3 红」;English:「a new "Prefix ≈N" item」
  • commit fbcd934:「新增「前缀 ≈N」显示环境提示词占用」

按清单核对准了边界再留。

— Cortico PR Gate via Cursor Cloud Agent

prefixHint: (tokens: number) => `环境提示词占用了约 ${tokens} token`,
worldCount: (n: number) => `${n} 个 World`,
sectionWorld: '工具与事件',
sectionProvider: '接入方式',
Expand Down Expand Up @@ -453,6 +456,9 @@ export const en: typeof zh = {
promptsTabBot: 'Prompt templates',
toolCount: (n: number) => `${n} tools`,
eventCount: (n: number) => `${n} event types`,
toolsHint: (n: number, tokens: number) => `Includes ${n} tools; their definitions take about ${tokens} tokens`,
prefixSize: (tokens: string) => `Prefix ≈${tokens}`,
prefixHint: (tokens: number) => `The environment prompt takes about ${tokens} tokens`,
worldCount: (n: number) => `${n} Worlds`,
sectionWorld: 'Tools and events',
sectionProvider: 'Connection',
Expand Down
2 changes: 2 additions & 0 deletions src/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2084,6 +2084,8 @@ button.extension-tag { cursor: pointer; }
.extension-card-spec > span { display: inline-flex; align-items: center; gap: 5px; min-width: 0; }
.extension-card-spec > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.extension-card-spec .icon { width: 13px; height: 13px; flex: none; }
.extension-card-spec .is-caution { color: var(--extension-caution); }
.extension-card-spec .is-alert { color: var(--extension-alert); }
.extension-describe { margin-top: 0; }
.extension-describe h5 { display: flex; align-items: baseline; gap: 8px; margin: 14px 0 8px; font-size: 12.5px; font-weight: 600; color: var(--ink-soft); }
.extension-describe-count { font-weight: 400; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
Expand Down
4 changes: 4 additions & 0 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,10 @@ export interface ExtensionSearchHit {
export interface ExtensionSummary {
tools?: number;
events?: number;
/** 全部声明工具的定义估算的 token 数。 */
toolTokens?: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

文档所有权未对齐。

AGENTS.md §8:「The commit that changes the owner changes the page.」docs/webui.md 与 src/web/README.md 的首行都将 src/web/server.ts 列为 owner。

本 PR 在 src/web/server.ts 第 258–261 行为 ExtensionSummary 增加了 toolTokens 与 envPromptTokens,这两页未改。请把变更收进规则,或把偏离的理由写进 PR 描述。

— Cortico PR Gate via Cursor Cloud Agent

/** 环境提示词模板原文估算的 token 数。 */
envPromptTokens?: number;
protocols?: string[];
auth?: ProviderAuth[];
input?: Array<ProviderDescribe['input'][number]>;
Expand Down
14 changes: 11 additions & 3 deletions tests/extensions/describe-export.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* describe.json 从定义的 describe 与干装载的提示词模板生成;check:extension 要求定义写了 describe、
* 实例工具名都已声明、包里的文件与重新生成的一致。
* 实例工具名都已声明、包里的文件与重新生成的一致;实例改写了声明的描述或参数时警告。
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
Expand All @@ -26,15 +26,15 @@ afterEach(() => {
const declared = (name: string): ToolDescribe => ({ name, description: name, parameters: { type: 'object', properties: {} }, tags: ['read'] });
const tool = (name: string): ToolDef => ({ ...declared(name), handler: async () => 'ok' });

function world(names: string[], withDescribe = true): WorldDefinition<WorldSection> {
function world(names: string[], withDescribe = true, rewrite: Partial<ToolDef> = {}): WorldDefinition<WorldSection> {
return {
id: 'sample',
label: 'Sample',
defaults: () => ({ enabled: false }),
create: (ctx) => ({
id: 'sample',
envPromptVars: () => ({}),
tools: () => names.map(tool),
tools: () => names.map((name) => ({ ...tool(name), ...rewrite })),
start: async () => {},
stop: async () => {},
webUi: () => ({
Expand Down Expand Up @@ -72,6 +72,14 @@ describe('World', () => {
expect((await checkDescribe('world', world(['sample_ping'], false), 'describe.json', opts)).failures.join('\n')).toContain('has no describe');
expect((await checkDescribe('world', world(['sample_ping', 'sample_extra']), undefined, opts)).failures.join('\n')).toMatch(/sample_extra[\s\S]*cortico\.describe/);
});

it('the check warns without failing when tools() under the default config rewrites a declared description or parameters', async () => {
const opts = { scratchDir, packageDir: pkgDir };
expect((await checkDescribe('world', world(['sample_ping']), undefined, opts)).warnings).toEqual([]);
const rewritten = await checkDescribe('world', world(['sample_ping'], true, { parameters: { type: 'object', properties: { n: { type: 'number' } } } }), undefined, opts);
expect(rewritten.warnings.join('\n')).toContain('sample_ping (parameters)');
expect(rewritten.failures.join('\n')).not.toContain('sample_ping');
});
});

it('a bot describe.json lists the declared Worlds, the Memory name and the Persona templates; Persona tools missing from describe.tools fail the check', async () => {
Expand Down
Loading