From 3e1b8b02f21413bb7c9b7336a86987d3b156fa7d Mon Sep 17 00:00:00 2001 From: ~latter-bolden Date: Mon, 15 Jun 2026 13:10:49 -0500 Subject: [PATCH] telemetry: capture tlon tool call summaries Layers in processing to capture context around which `tlon` commands were used in tool calls. Adds compact, filterable actions like `groups.invite`, `contacts.update-profile`, and `groups.add-channel`, while preserving the existing top-level reply telemetry. - shared tlon command parser/classifier mapping commands to privacy-safe summary keys - `summaryKey` on individual `toolCalls[]` entries to correlate action type with `durationMs` - top-level facets: `tlonToolCallCount`, `tlonToolSummaryKeys`, `tlonToolChannelKinds`, `tlonToolUpdateFields` Migrated from tloncorp/openclaw-tlon#124 (originally authored by ~latter-bolden, approved by @arthyn). Rebased onto current master and relocated to packages/openclaw under monorepo conventions. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/openclaw/index.ts | 113 +-- packages/openclaw/src/telemetry.test.ts | 117 +++ packages/openclaw/src/telemetry.ts | 58 ++ .../openclaw/src/tlon-tool-command.test.ts | 91 +++ packages/openclaw/src/tlon-tool-command.ts | 727 ++++++++++++++++++ 5 files changed, 1007 insertions(+), 99 deletions(-) create mode 100644 packages/openclaw/src/tlon-tool-command.test.ts create mode 100644 packages/openclaw/src/tlon-tool-command.ts diff --git a/packages/openclaw/index.ts b/packages/openclaw/index.ts index efb4398c5d..0a78797b7e 100644 --- a/packages/openclaw/index.ts +++ b/packages/openclaw/index.ts @@ -16,6 +16,12 @@ import { setTlonRuntime } from './src/runtime.js'; import { getSessionRole } from './src/session-roles.js'; import { recordToolCall } from './src/telemetry.js'; import { resolveTlonBinary } from './src/tlon-binary.js'; +import { + ALLOWED_TLON_COMMANDS, + findTlonSubcommandIndex, + shellSplitCommand, + summarizeTlonCommand, +} from './src/tlon-tool-command.js'; import { checkBlockedSendOperation } from './src/tlon-tool-guard.js'; import { formatToolTraceEvent, @@ -31,103 +37,6 @@ export { setTlonRuntime } from './src/runtime.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); -// Whitelist of allowed tlon subcommands -const ALLOWED_TLON_COMMANDS = new Set([ - 'activity', - 'channels', - 'contacts', - 'dms', - 'expose', - 'groups', - 'hooks', - 'messages', - 'notebook', - 'posts', - 'settings', - 'upload', - 'help', - 'version', -]); - -/** Credential flags that the tlon skill binary accepts before the subcommand. */ -const CREDENTIAL_FLAGS_WITH_VALUE = new Set([ - '--config', - '--url', - '--ship', - '--code', - '--cookie', -]); - -/** - * Find the first positional argument (subcommand) by skipping credential flags - * and their values. Returns the index into `args`, or -1 if none found. - */ -function findSubcommandIndex(args: string[]): number { - let i = 0; - while (i < args.length) { - const arg = args[i]; - // --flag=value form: skip one token - if (arg.startsWith('--') && arg.includes('=')) { - const flag = arg.slice(0, arg.indexOf('=')); - if (CREDENTIAL_FLAGS_WITH_VALUE.has(flag)) { - i += 1; - continue; - } - } - // --flag value form: skip two tokens - if (CREDENTIAL_FLAGS_WITH_VALUE.has(arg)) { - i += 2; - continue; - } - // Not a credential flag — this is the subcommand - return i; - } - return -1; -} - -/** - * Shell-like argument splitter that respects quotes - */ -function shellSplit(str: string): string[] { - const args: string[] = []; - let cur = ''; - let inDouble = false; - let inSingle = false; - let escape = false; - - for (const ch of str) { - if (escape) { - cur += ch; - escape = false; - continue; - } - if (ch === '\\' && !inSingle) { - escape = true; - continue; - } - if (ch === '"' && !inSingle) { - inDouble = !inDouble; - continue; - } - if (ch === "'" && !inDouble) { - inSingle = !inSingle; - continue; - } - if (/\s/.test(ch) && !inDouble && !inSingle) { - if (cur) { - args.push(cur); - cur = ''; - } - continue; - } - cur += ch; - } - if (cur) { - args.push(cur); - } - return args; -} - /** * Run the tlon command and return the result */ @@ -309,9 +218,11 @@ export default defineChannelPluginEntry({ }, async execute(_id: string, params: { command: string }) { try { - const args = shellSplit(params.command); + const args = shellSplitCommand(params.command); - const subIdx = findSubcommandIndex(args); + // Skip credential flags (--config, --url, --ship, --code, --cookie) + // to find the actual subcommand, matching what the skill binary does. + const subIdx = findTlonSubcommandIndex(args); const subcommand = subIdx >= 0 ? args[subIdx] : undefined; if (!subcommand || !ALLOWED_TLON_COMMANDS.has(subcommand)) { return { @@ -422,6 +333,10 @@ export default defineChannelPluginEntry({ toolName: event.toolName, durationMs: event.durationMs, error: event.error, + context: + event.toolName === 'tlon' && typeof event.params.command === 'string' + ? summarizeTlonCommand(event.params.command) + : undefined, }); }); diff --git a/packages/openclaw/src/telemetry.test.ts b/packages/openclaw/src/telemetry.test.ts index 168ceea411..0f7378bf54 100644 --- a/packages/openclaw/src/telemetry.test.ts +++ b/packages/openclaw/src/telemetry.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { _testing, createTlonTelemetry, recordToolCall } from './telemetry.js'; +import { summarizeTlonCommand } from './tlon-tool-command.js'; const postHogMocks = vi.hoisted(() => ({ identify: vi.fn(), @@ -129,13 +130,19 @@ describe('telemetry tool tracking', () => { toolName: 'web_search', durationMs: 125, error: null, + summaryKey: null, }, { toolName: 'read', durationMs: null, error: 'tool failed', + summaryKey: null, }, ], + tlonToolCallCount: 0, + tlonToolSummaryKeys: [], + tlonToolChannelKinds: [], + tlonToolUpdateFields: [], }), }); @@ -156,6 +163,10 @@ describe('telemetry tool tracking', () => { expect(capturedEvent?.properties.toolCount).toBe(0); expect(capturedEvent?.properties.toolTotalDurationMs).toBe(0); expect(capturedEvent?.properties.toolErrorCount).toBe(0); + expect(capturedEvent?.properties.tlonToolCallCount).toBe(0); + expect(capturedEvent?.properties.tlonToolSummaryKeys).toEqual([]); + expect(capturedEvent?.properties.tlonToolChannelKinds).toEqual([]); + expect(capturedEvent?.properties.tlonToolUpdateFields).toEqual([]); }); it('classifies reply outcomes', async () => { @@ -252,8 +263,13 @@ describe('telemetry tool tracking', () => { toolName: 'web_search', durationMs: 125, error: null, + summaryKey: null, }, ], + tlonToolCallCount: 0, + tlonToolSummaryKeys: [], + tlonToolChannelKinds: [], + tlonToolUpdateFields: [], }, }); @@ -440,4 +456,105 @@ describe('telemetry tool tracking', () => { expect(capturedEvent?.event).toBe('TlonBot Reply Handled'); expect(capturedEvent?.properties.outcome).toBe('responded'); }); + + it('captures privacy-safe tlon command summaries', async () => { + const telemetry = createEnabledTelemetry(); + const replyTelemetry = telemetry?.startReply({ + sessionKey: 'session-1', + ownerShip: '~zod', + botShip: '~nec', + chatType: 'dm', + isThreadReply: false, + senderRole: 'owner', + attachmentCount: 0, + }); + + recordToolCall({ + sessionKey: 'session-1', + toolName: 'tlon', + durationMs: 80, + context: summarizeTlonCommand( + 'groups invite ~zod/quiet-launch ~sampel-palnet ~marzod-marnec' + ), + }); + recordToolCall({ + sessionKey: 'session-1', + toolName: 'tlon', + durationMs: 40, + context: summarizeTlonCommand( + 'contacts update-profile --nickname "PM Bot" --avatar https://assets.example.com/private.png' + ), + }); + recordToolCall({ + sessionKey: 'session-1', + toolName: 'tlon', + durationMs: 30, + context: summarizeTlonCommand( + 'groups add-channel ~zod/quiet-launch "Photos" --kind heap' + ), + }); + + await replyTelemetry?.capture({ + deliveredMessageCount: 1, + replyCharCount: 42, + replyWordCount: 7, + replyMediaCount: 0, + dispatchDurationMs: 250, + queuedFinal: false, + queuedFinalCount: 1, + queuedBlockCount: 0, + provider: 'anthropic', + model: 'claude-test', + thinkLevel: null, + }); + + expect(postHogMocks.capture).toHaveBeenCalledWith({ + distinctId: '~zod', + event: 'TlonBot Reply Handled', + properties: expect.objectContaining({ + tlonToolCallCount: 3, + tlonToolSummaryKeys: [ + 'groups.invite', + 'contacts.update-profile', + 'groups.add-channel', + ], + tlonToolChannelKinds: ['heap'], + tlonToolUpdateFields: ['nickname', 'avatar'], + toolCalls: [ + { + toolName: 'tlon', + durationMs: 80, + error: null, + summaryKey: 'groups.invite', + }, + { + toolName: 'tlon', + durationMs: 40, + error: null, + summaryKey: 'contacts.update-profile', + }, + { + toolName: 'tlon', + durationMs: 30, + error: null, + summaryKey: 'groups.add-channel', + }, + ], + }), + }); + + const capturedEvent = postHogMocks.capture.mock.calls.at(-1)?.[0]; + expect(JSON.stringify(capturedEvent?.properties)).not.toContain( + '~zod/quiet-launch' + ); + expect(JSON.stringify(capturedEvent?.properties)).not.toContain( + '~sampel-palnet' + ); + expect(JSON.stringify(capturedEvent?.properties)).not.toContain('PM Bot'); + expect(JSON.stringify(capturedEvent?.properties)).not.toContain( + 'https://assets.example.com/private.png' + ); + + await telemetry?.close(); + }); }); diff --git a/packages/openclaw/src/telemetry.ts b/packages/openclaw/src/telemetry.ts index 24dc54abb4..2cab953d3d 100644 --- a/packages/openclaw/src/telemetry.ts +++ b/packages/openclaw/src/telemetry.ts @@ -2,12 +2,18 @@ import type { RuntimeEnv } from 'openclaw/plugin-sdk/runtime'; import { PostHog } from 'posthog-node'; import { sharedMap } from './shared-state.js'; +import type { + TlonChannelKind, + TlonProfileUpdateField, + TlonToolCallContext, +} from './tlon-tool-command.js'; import type { TlonTelemetryConfig } from './types.js'; type ToolCallRecord = { toolName: string; durationMs: number | null; error: string | null; + context: ToolCallContext | null; recordedAt: number; }; @@ -16,15 +22,22 @@ type ToolSessionTrace = { calls: ToolCallRecord[]; }; +export type ToolCallContext = TlonToolCallContext; + export type ToolUsageSummary = { calls: Array<{ toolName: string; durationMs: number | null; error: string | null; + summaryKey: string | null; }>; names: string[]; totalDurationMs: number; errorCount: number; + tlonToolCallCount: number; + tlonSummaryKeys: string[]; + tlonChannelKinds: TlonChannelKind[]; + tlonUpdateFields: TlonProfileUpdateField[]; }; export type TlonHeartbeatNudgeEvent = { @@ -146,6 +159,7 @@ export function recordToolCall(params: { toolName: string; durationMs?: number; error?: string; + context?: ToolCallContext; }): void { const sessionKey = params.sessionKey?.trim(); if (!sessionKey) { @@ -167,6 +181,7 @@ export function recordToolCall(params: { durationMs: typeof params.durationMs === 'number' ? params.durationMs : null, error: params.error ?? null, + context: params.context ?? null, recordedAt: now, }); @@ -196,7 +211,20 @@ function collectToolUsageSince( toolName: call.toolName, durationMs: call.durationMs, error: call.error, + summaryKey: + call.context?.kind === 'tlonCommand' ? call.context.summaryKey : null, })) ?? []; + const tlonContexts = + toolCallsBySession + .get(sessionKey) + ?.calls.slice(Math.max(0, cursor)) + .flatMap((call) => { + if (call.context?.kind !== 'tlonCommand') { + return []; + } + + return [call.context]; + }) ?? []; return { calls, @@ -206,9 +234,34 @@ function collectToolUsageSince( 0 ), errorCount: calls.filter((call) => call.error).length, + tlonToolCallCount: tlonContexts.length, + tlonSummaryKeys: uniqueInOrder(tlonContexts.map((call) => call.summaryKey)), + tlonChannelKinds: uniqueInOrder( + tlonContexts.flatMap((call) => + call.channelKind ? [call.channelKind] : [] + ) + ), + tlonUpdateFields: uniqueInOrder( + tlonContexts.flatMap((call) => call.updateFields ?? []) + ), }; } +function uniqueInOrder(values: T[]): T[] { + const seen = new Set(); + const unique: T[] = []; + + for (const value of values) { + if (seen.has(value)) { + continue; + } + seen.add(value); + unique.push(value); + } + + return unique; +} + function resolveReplyOutcome(params: { deliveredMessageCount: number; dispatchError?: unknown; @@ -316,7 +369,12 @@ class PostHogTlonTelemetry implements TlonTelemetryClient { toolName: call.toolName, durationMs: call.durationMs, error: call.error, + summaryKey: call.summaryKey, })), + tlonToolCallCount: event.toolUsage.tlonToolCallCount, + tlonToolSummaryKeys: event.toolUsage.tlonSummaryKeys, + tlonToolChannelKinds: event.toolUsage.tlonChannelKinds, + tlonToolUpdateFields: event.toolUsage.tlonUpdateFields, }, }); } diff --git a/packages/openclaw/src/tlon-tool-command.test.ts b/packages/openclaw/src/tlon-tool-command.test.ts new file mode 100644 index 0000000000..7ba225392b --- /dev/null +++ b/packages/openclaw/src/tlon-tool-command.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import { summarizeTlonCommand } from './tlon-tool-command.js'; + +describe('tlon tool telemetry summarizer', () => { + it('classifies group creation without leaking the group name', () => { + const summary = summarizeTlonCommand( + 'groups create "Launch Planning" --description "Highly confidential"' + ); + + expect(summary).toMatchObject({ + kind: 'tlonCommand', + summaryKey: 'groups.create', + subcommand: 'groups', + operation: 'create', + intent: 'write', + hasDescription: true, + }); + + const serialized = JSON.stringify(summary); + expect(serialized).not.toContain('Launch Planning'); + expect(serialized).not.toContain('Highly confidential'); + }); + + it('captures invite counts without leaking group flags or invitees', () => { + const summary = summarizeTlonCommand( + 'groups invite ~zod/quiet-launch ~sampel-palnet ~marzod-marnec' + ); + + expect(summary).toMatchObject({ + summaryKey: 'groups.invite', + intent: 'admin', + inviteeCount: 2, + }); + + const serialized = JSON.stringify(summary); + expect(serialized).not.toContain('~zod/quiet-launch'); + expect(serialized).not.toContain('~sampel-palnet'); + expect(serialized).not.toContain('~marzod-marnec'); + }); + + it('tracks profile fields updated without leaking field values or asset URLs', () => { + const summary = summarizeTlonCommand( + 'contacts update-profile --nickname "PM Bot" --avatar https://assets.example.com/private.png --bio "hello"' + ); + + expect(summary).toMatchObject({ + summaryKey: 'contacts.update-profile', + intent: 'write', + updateFields: ['nickname', 'bio', 'avatar'], + }); + + const serialized = JSON.stringify(summary); + expect(serialized).not.toContain('PM Bot'); + expect(serialized).not.toContain('https://assets.example.com/private.png'); + expect(serialized).not.toContain('hello'); + }); + + it('captures upload source without storing the original path', () => { + const summary = summarizeTlonCommand( + 'upload https://cdn.example.com/private-assets/avatar.png --type image/png' + ); + + expect(summary).toMatchObject({ + summaryKey: 'upload.upload', + intent: 'write', + uploadSource: 'url', + contentTypeProvided: true, + }); + + expect(JSON.stringify(summary)).not.toContain( + 'https://cdn.example.com/private-assets/avatar.png' + ); + }); + + it('marks wrong-path DM sends as blocked without storing the target ship', () => { + const summary = summarizeTlonCommand( + 'dms send ~sampel-palnet "hello there"' + ); + + expect(summary).toMatchObject({ + summaryKey: 'dms.send', + intent: 'write', + dmTargetKind: 'ship', + blockedSendOperation: true, + }); + + expect(JSON.stringify(summary)).not.toContain('~sampel-palnet'); + expect(JSON.stringify(summary)).not.toContain('hello there'); + }); +}); diff --git a/packages/openclaw/src/tlon-tool-command.ts b/packages/openclaw/src/tlon-tool-command.ts new file mode 100644 index 0000000000..f52beabe6c --- /dev/null +++ b/packages/openclaw/src/tlon-tool-command.ts @@ -0,0 +1,727 @@ +import { checkBlockedSendOperation } from './tlon-tool-guard.js'; + +export const ALLOWED_TLON_COMMANDS = new Set([ + 'activity', + 'channels', + 'contacts', + 'dms', + 'expose', + 'groups', + 'hooks', + 'messages', + 'notebook', + 'posts', + 'settings', + 'upload', + 'help', + 'version', +]); + +const CREDENTIAL_FLAGS_WITH_VALUE = new Set([ + '--config', + '--url', + '--ship', + '--code', + '--cookie', +]); + +const PROFILE_UPDATE_FIELDS = [ + { flag: '--nickname', field: 'nickname' }, + { flag: '--bio', field: 'bio' }, + { flag: '--status', field: 'status' }, + { flag: '--avatar', field: 'avatar' }, + { flag: '--cover', field: 'cover' }, +] as const; + +export type TlonProfileUpdateField = + (typeof PROFILE_UPDATE_FIELDS)[number]['field']; +export type TlonToolIntent = 'read' | 'write' | 'admin' | 'config' | 'utility'; +export type TlonChannelKind = 'chat' | 'diary' | 'heap'; +export type TlonDmTargetKind = 'ship' | 'club' | 'unknown'; +export type TlonUploadSource = 'url' | 'local' | 'stdin' | 'unknown'; + +export type TlonToolCallContext = { + kind: 'tlonCommand'; + summaryKey: string; + subcommand: string; + operation: string; + intent: TlonToolIntent; + isKnownSubcommand: boolean; + blockedSendOperation: boolean; + channelKind?: TlonChannelKind; + dmTargetKind?: TlonDmTargetKind; + uploadSource?: TlonUploadSource; + updateFields?: TlonProfileUpdateField[]; + inviteeCount?: number; + memberCount?: number; + contactCount?: number; + roleCount?: number; + hookCount?: number; + hasDescription?: boolean; + hasTitle?: boolean; + hasContent?: boolean; + hasImage?: boolean; + hasNameChange?: boolean; + hasSourceChange?: boolean; + hasNest?: boolean; + privacySetting?: 'public' | 'private' | 'secret'; + limit?: number; + resolveCites?: boolean; + scopedToChannel?: boolean; + contentTypeProvided?: boolean; +}; + +/** + * Shell-like argument splitter that respects quotes. + */ +export function shellSplitCommand(command: string): string[] { + const args: string[] = []; + let cur = ''; + let inDouble = false; + let inSingle = false; + let escape = false; + + for (const ch of command) { + if (escape) { + cur += ch; + escape = false; + continue; + } + if (ch === '\\' && !inSingle) { + escape = true; + continue; + } + if (ch === '"' && !inSingle) { + inDouble = !inDouble; + continue; + } + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + continue; + } + if (/\s/.test(ch) && !inDouble && !inSingle) { + if (cur) { + args.push(cur); + cur = ''; + } + continue; + } + cur += ch; + } + if (cur) { + args.push(cur); + } + return args; +} + +/** + * Find the first positional argument (subcommand) by skipping credential flags + * and their values. Returns the index into `args`, or -1 if none found. + */ +export function findTlonSubcommandIndex(args: string[]): number { + let i = 0; + while (i < args.length) { + const arg = args[i]; + if (arg.startsWith('--') && arg.includes('=')) { + const flag = arg.slice(0, arg.indexOf('=')); + if (CREDENTIAL_FLAGS_WITH_VALUE.has(flag)) { + i += 1; + continue; + } + } + if (CREDENTIAL_FLAGS_WITH_VALUE.has(arg)) { + i += 2; + continue; + } + return i; + } + return -1; +} + +export function summarizeTlonCommand(command: string): TlonToolCallContext { + const args = shellSplitCommand(command); + const subIdx = findTlonSubcommandIndex(args); + const subcommand = args[subIdx]?.toLowerCase() ?? 'unknown'; + const commandArgs = subIdx >= 0 ? args.slice(subIdx) : []; + const blockedSendOperation = + commandArgs.length > 0 && checkBlockedSendOperation(commandArgs) !== null; + + if (!ALLOWED_TLON_COMMANDS.has(subcommand)) { + return { + kind: 'tlonCommand', + summaryKey: `${subcommand}.invalid`, + subcommand, + operation: 'invalid', + intent: 'utility', + isKnownSubcommand: false, + blockedSendOperation, + }; + } + + return summarizeKnownTlonCommand(commandArgs, blockedSendOperation); +} + +function summarizeKnownTlonCommand( + commandArgs: string[], + blockedSendOperation: boolean +): TlonToolCallContext { + const subcommand = commandArgs[0]?.toLowerCase() ?? 'unknown'; + const actionSubcommands = new Set([ + 'activity', + 'channels', + 'contacts', + 'groups', + 'hooks', + 'messages', + 'dms', + 'expose', + 'posts', + 'settings', + ]); + const hasAction = actionSubcommands.has(subcommand); + const operation = hasAction + ? commandArgs[1]?.toLowerCase() ?? defaultOperationForSubcommand(subcommand) + : defaultOperationForSubcommand(subcommand); + const remainder = commandArgs.slice(hasAction ? 2 : 1); + + const build = ( + intent: TlonToolIntent, + extra: Omit< + Partial, + | 'kind' + | 'summaryKey' + | 'subcommand' + | 'operation' + | 'intent' + | 'isKnownSubcommand' + | 'blockedSendOperation' + > = {} + ): TlonToolCallContext => ({ + kind: 'tlonCommand', + summaryKey: `${subcommand}.${operation}`, + subcommand, + operation, + intent, + isKnownSubcommand: true, + blockedSendOperation, + ...extra, + }); + + switch (subcommand) { + case 'activity': + return build('read'); + case 'channels': + return summarizeChannelsOperation(operation, remainder, build); + case 'contacts': + return summarizeContactsOperation(operation, remainder, build); + case 'groups': + return summarizeGroupsOperation(operation, remainder, build); + case 'hooks': + return summarizeHooksOperation(operation, remainder, build); + case 'messages': + return summarizeMessagesOperation(operation, remainder, build); + case 'dms': + return summarizeDmsOperation(operation, remainder, build); + case 'expose': + return summarizeExposeOperation(operation, remainder, build); + case 'posts': + return summarizePostsOperation(operation, remainder, build); + case 'notebook': + return build('write', { + channelKind: 'diary', + hasContent: hasFlag(remainder, '--content'), + hasImage: hasFlag(remainder, '--image'), + }); + case 'upload': + return build('write', { + uploadSource: detectUploadSource(remainder), + contentTypeProvided: hasFlag(remainder, '-t', '--type'), + }); + case 'settings': + return build('config'); + case 'help': + case 'version': + return build('utility'); + default: + return build('utility'); + } +} + +function summarizeChannelsOperation( + operation: string, + args: string[], + build: ( + intent: TlonToolIntent, + extra?: Omit< + Partial, + | 'kind' + | 'summaryKey' + | 'subcommand' + | 'operation' + | 'intent' + | 'isKnownSubcommand' + | 'blockedSendOperation' + > + ) => TlonToolCallContext +): TlonToolCallContext { + const positionals = collectPositionals(args); + switch (operation) { + case 'update': + return build('write', { + channelKind: detectChannelKind(positionals[0]), + hasTitle: hasFlag(args, '--title'), + }); + case 'delete': + return build('admin', { + channelKind: detectChannelKind(positionals[0]), + }); + case 'add-writers': + case 'del-writers': + return build('admin', { + channelKind: detectChannelKind(positionals[0]), + roleCount: Math.max(0, positionals.length - 1), + }); + case 'add-readers': + case 'del-readers': + return build('admin', { + channelKind: detectChannelKind(positionals[1]), + roleCount: Math.max(0, positionals.length - 2), + }); + default: + return build('read', { + channelKind: detectChannelKind(positionals[0]), + }); + } +} + +function summarizeContactsOperation( + operation: string, + args: string[], + build: ( + intent: TlonToolIntent, + extra?: Omit< + Partial, + | 'kind' + | 'summaryKey' + | 'subcommand' + | 'operation' + | 'intent' + | 'isKnownSubcommand' + | 'blockedSendOperation' + > + ) => TlonToolCallContext +): TlonToolCallContext { + const positionals = collectPositionals(args); + switch (operation) { + case 'update-profile': + return build('write', { + updateFields: PROFILE_UPDATE_FIELDS.filter(({ flag }) => + hasFlag(args, flag) + ).map(({ field }) => field), + }); + case 'add': + case 'remove': + return build('write', { + contactCount: positionals.length, + }); + case 'sync': + return build('read', { + contactCount: positionals.length, + }); + default: + return build('read'); + } +} + +function summarizeGroupsOperation( + operation: string, + args: string[], + build: ( + intent: TlonToolIntent, + extra?: Omit< + Partial, + | 'kind' + | 'summaryKey' + | 'subcommand' + | 'operation' + | 'intent' + | 'isKnownSubcommand' + | 'blockedSendOperation' + > + ) => TlonToolCallContext +): TlonToolCallContext { + const positionals = collectPositionals( + args, + new Set(['--description', '--title', '--kind']) + ); + switch (operation) { + case 'create': + return build('write', { + hasDescription: hasFlag(args, '--description'), + }); + case 'join': + case 'leave': + return build('write'); + case 'delete': + return build('admin'); + case 'update': + return build('admin', { + hasTitle: hasFlag(args, '--title'), + hasDescription: hasFlag(args, '--description'), + }); + case 'invite': + return build('admin', { + inviteeCount: Math.max(0, positionals.length - 1), + }); + case 'kick': + case 'ban': + case 'unban': + case 'accept-join': + case 'reject-join': + case 'promote': + case 'demote': + return build('admin', { + memberCount: Math.max(0, positionals.length - 1), + }); + case 'set-privacy': + return build('admin', { + privacySetting: parsePrivacySetting(positionals[1]), + }); + case 'add-role': + case 'delete-role': + case 'update-role': + return build('admin', { + hasTitle: hasFlag(args, '--title'), + }); + case 'assign-role': + case 'remove-role': + return build('admin', { + memberCount: Math.max(0, positionals.length - 2), + }); + case 'add-channel': + return build('admin', { + channelKind: parseChannelKind(getOptionValue(args, ['--kind'])), + }); + default: + return build( + operation === 'list' || operation === 'info' ? 'read' : 'admin' + ); + } +} + +function summarizeHooksOperation( + operation: string, + args: string[], + build: ( + intent: TlonToolIntent, + extra?: Omit< + Partial, + | 'kind' + | 'summaryKey' + | 'subcommand' + | 'operation' + | 'intent' + | 'isKnownSubcommand' + | 'blockedSendOperation' + > + ) => TlonToolCallContext +): TlonToolCallContext { + const positionals = collectPositionals( + args, + new Set(['--type', '--name', '--src', '--nest']) + ); + switch (operation) { + case 'list': + case 'get': + return build('read'); + case 'edit': + return build('admin', { + hasNameChange: hasFlag(args, '--name'), + hasSourceChange: hasFlag(args, '--src'), + }); + case 'order': + return build('admin', { + hookCount: Math.max(0, positionals.length - 1), + channelKind: detectChannelKind(positionals[0]), + }); + case 'cron': + return build('config', { + hasNest: hasFlag(args, '--nest'), + }); + default: + return build('admin'); + } +} + +function summarizeMessagesOperation( + operation: string, + args: string[], + build: ( + intent: TlonToolIntent, + extra?: Omit< + Partial, + | 'kind' + | 'summaryKey' + | 'subcommand' + | 'operation' + | 'intent' + | 'isKnownSubcommand' + | 'blockedSendOperation' + > + ) => TlonToolCallContext +): TlonToolCallContext { + const positionals = collectPositionals( + args, + new Set(['--limit', '--channel', '--author']), + new Set(['--resolve-cites']) + ); + const baseExtra = { + limit: getNumericOption(args, ['--limit']), + resolveCites: hasFlag(args, '--resolve-cites'), + }; + switch (operation) { + case 'dm': + return build('read', { + ...baseExtra, + dmTargetKind: detectDmTargetKind(positionals[0]), + }); + case 'channel': + case 'context': + case 'post': + return build('read', { + ...baseExtra, + channelKind: detectChannelKind(positionals[0]), + }); + case 'search': + return build('read', { + ...baseExtra, + scopedToChannel: hasFlag(args, '--channel'), + channelKind: detectChannelKind(getOptionValue(args, ['--channel'])), + }); + default: + return build('read', baseExtra); + } +} + +function summarizeDmsOperation( + operation: string, + args: string[], + build: ( + intent: TlonToolIntent, + extra?: Omit< + Partial, + | 'kind' + | 'summaryKey' + | 'subcommand' + | 'operation' + | 'intent' + | 'isKnownSubcommand' + | 'blockedSendOperation' + > + ) => TlonToolCallContext +): TlonToolCallContext { + const positionals = collectPositionals(args); + return build( + operation === 'accept' || operation === 'decline' ? 'admin' : 'write', + { + dmTargetKind: detectDmTargetKind(positionals[0]), + } + ); +} + +function summarizeExposeOperation( + operation: string, + args: string[], + build: ( + intent: TlonToolIntent, + extra?: Omit< + Partial, + | 'kind' + | 'summaryKey' + | 'subcommand' + | 'operation' + | 'intent' + | 'isKnownSubcommand' + | 'blockedSendOperation' + > + ) => TlonToolCallContext +): TlonToolCallContext { + const positionals = collectPositionals(args); + const intent = + operation === 'show' || operation === 'hide' ? 'admin' : 'read'; + return build(intent, { + channelKind: detectChannelKind(positionals[0]), + }); +} + +function summarizePostsOperation( + operation: string, + args: string[], + build: ( + intent: TlonToolIntent, + extra?: Omit< + Partial, + | 'kind' + | 'summaryKey' + | 'subcommand' + | 'operation' + | 'intent' + | 'isKnownSubcommand' + | 'blockedSendOperation' + > + ) => TlonToolCallContext +): TlonToolCallContext { + const positionals = collectPositionals( + args, + new Set(['--title', '--image', '--content']) + ); + const channelKind = detectChannelKind(positionals[0]); + switch (operation) { + case 'edit': + return build('write', { + channelKind, + hasTitle: hasFlag(args, '--title'), + hasImage: hasFlag(args, '--image'), + hasContent: hasFlag(args, '--content'), + }); + default: + return build('write', { channelKind }); + } +} + +function defaultOperationForSubcommand(subcommand: string): string { + switch (subcommand) { + case 'upload': + return 'upload'; + case 'notebook': + return 'publish'; + case 'help': + case 'version': + return 'show'; + case 'settings': + return 'get'; + default: + return 'list'; + } +} + +function collectPositionals( + args: string[], + flagsWithValue = new Set(), + booleanFlags = new Set() +): string[] { + const positionals: string[] = []; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if ((arg.startsWith('--') || arg.startsWith('-')) && arg.includes('=')) { + const flag = arg.slice(0, arg.indexOf('=')); + if (flagsWithValue.has(flag) || booleanFlags.has(flag)) { + continue; + } + } + + if (flagsWithValue.has(arg)) { + i += 1; + continue; + } + + if (booleanFlags.has(arg)) { + continue; + } + + positionals.push(arg); + } + + return positionals; +} + +function hasFlag(args: string[], ...flags: string[]): boolean { + return args.some((arg) => + flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)) + ); +} + +function getOptionValue(args: string[], flags: string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + for (const flag of flags) { + if (arg === flag) { + return args[i + 1]; + } + if (arg.startsWith(`${flag}=`)) { + return arg.slice(flag.length + 1); + } + } + } + return undefined; +} + +function getNumericOption(args: string[], flags: string[]): number | undefined { + const value = getOptionValue(args, flags); + if (!value) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function detectChannelKind( + value: string | undefined +): TlonChannelKind | undefined { + return parseChannelKind(value); +} + +function parseChannelKind( + value: string | undefined +): TlonChannelKind | undefined { + if (!value) { + return undefined; + } + const kind = value.split('/', 1)[0]; + return kind === 'chat' || kind === 'diary' || kind === 'heap' + ? kind + : undefined; +} + +function detectDmTargetKind(value: string | undefined): TlonDmTargetKind { + if (!value) { + return 'unknown'; + } + if (value.startsWith('0v')) { + return 'club'; + } + if (value.startsWith('~')) { + return 'ship'; + } + return 'unknown'; +} + +function detectUploadSource(args: string[]): TlonUploadSource { + if (hasFlag(args, '--stdin')) { + return 'stdin'; + } + + const positionals = collectPositionals( + args, + new Set(['-t', '--type']), + new Set(['--stdin']) + ); + const source = positionals[0]; + if (!source) { + return 'unknown'; + } + if (source.startsWith('http://') || source.startsWith('https://')) { + return 'url'; + } + return 'local'; +} + +function parsePrivacySetting( + value: string | undefined +): TlonToolCallContext['privacySetting'] | undefined { + if (value === 'public' || value === 'private' || value === 'secret') { + return value; + } + return undefined; +}