From f50dda924fb435ef701755331b97145effbd76b1 Mon Sep 17 00:00:00 2001 From: ~latter-bolden Date: Wed, 8 Apr 2026 21:53:00 -0500 Subject: [PATCH] telemetry: capture tlon tool call summaries --- index.ts | 109 +----- src/telemetry.test.ts | 117 +++++++ src/telemetry.ts | 55 ++- src/tlon-tool-command.test.ts | 88 +++++ src/tlon-tool-command.ts | 641 ++++++++++++++++++++++++++++++++++ 5 files changed, 912 insertions(+), 98 deletions(-) create mode 100644 src/tlon-tool-command.test.ts create mode 100644 src/tlon-tool-command.ts diff --git a/index.ts b/index.ts index 7e1fe13b..261e25d7 100644 --- a/index.ts +++ b/index.ts @@ -15,65 +15,17 @@ import { resolveBridgeForCommand } from "./src/monitor/command-auth.js"; import { setTlonRuntime } from "./src/runtime.js"; import { getSessionRole } from "./src/session-roles.js"; import { recordToolCall } from "./src/telemetry.js"; +import { + ALLOWED_TLON_COMMANDS, + findTlonSubcommandIndex, + shellSplitCommand, + summarizeTlonCommand, +} from "./src/tlon-tool-command.js"; import { checkBlockedSendOperation } from "./src/tlon-tool-guard.js"; import { resolveTlonAccount } from "./src/types.js"; const __dirname = dirname(fileURLToPath(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; -} - /** * Find the tlon binary from the skill package */ @@ -100,47 +52,6 @@ function findTlonBinary(): string { return "tlon"; } -/** - * 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 */ @@ -245,11 +156,11 @@ const plugin = { }, async execute(_id: string, params: { command: string }) { try { - const args = shellSplit(params.command); + const args = shellSplitCommand(params.command); // Skip credential flags (--config, --url, --ship, --code, --cookie) // to find the actual subcommand, matching what the skill binary does. - const subIdx = findSubcommandIndex(args); + const subIdx = findTlonSubcommandIndex(args); const subcommand = subIdx >= 0 ? args[subIdx] : undefined; if (!subcommand || !ALLOWED_TLON_COMMANDS.has(subcommand)) { return { @@ -321,6 +232,10 @@ const plugin = { 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/src/telemetry.test.ts b/src/telemetry.test.ts index 2956457e..0f201883 100644 --- a/src/telemetry.test.ts +++ b/src/telemetry.test.ts @@ -22,6 +22,7 @@ import { createTlonTelemetry, recordToolCall, } from "./telemetry.js"; +import { summarizeTlonCommand } from "./tlon-tool-command.js"; describe("telemetry tool tracking", () => { beforeEach(() => { @@ -133,13 +134,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: [], }), }); @@ -160,6 +167,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 () => { @@ -250,8 +261,13 @@ describe("telemetry tool tracking", () => { toolName: "web_search", durationMs: 125, error: null, + summaryKey: null, }, ], + tlonToolCallCount: 0, + tlonToolSummaryKeys: [], + tlonToolChannelKinds: [], + tlonToolUpdateFields: [], }, }); @@ -265,4 +281,105 @@ describe("telemetry tool tracking", () => { expect(postHogMocks.flush).toHaveBeenCalledTimes(1); expect(postHogMocks.shutdown).toHaveBeenCalledTimes(1); }); + + 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/src/telemetry.ts b/src/telemetry.ts index 13e4d860..b2d271ed 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -1,11 +1,17 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk"; import { PostHog } from "posthog-node"; +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; }; @@ -14,15 +20,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 TlonReplyOutcome = "responded" | "no_reply" | "error"; @@ -102,6 +115,7 @@ export function recordToolCall(params: { toolName: string; durationMs?: number; error?: string; + context?: ToolCallContext; }): void { const sessionKey = params.sessionKey?.trim(); if (!sessionKey) { @@ -122,6 +136,7 @@ export function recordToolCall(params: { toolName: params.toolName, durationMs: typeof params.durationMs === "number" ? params.durationMs : null, error: params.error ?? null, + context: params.context ?? null, recordedAt: now, }); @@ -148,16 +163,49 @@ function collectToolUsageSince(sessionKey: string, cursor: number): ToolUsageSum 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, names: calls.map((call) => call.toolName), totalDurationMs: calls.reduce((total, call) => total + (call.durationMs ?? 0), 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; @@ -278,7 +326,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, }, }); } @@ -291,7 +344,7 @@ class PostHogTlonTelemetry implements TlonTelemetryClient { } try { - this.client.shutdown(); + await this.client.shutdown(); } catch (error) { this.runtime?.error?.(`[tlon] Telemetry shutdown failed: ${String(error)}`); } diff --git a/src/tlon-tool-command.test.ts b/src/tlon-tool-command.test.ts new file mode 100644 index 00000000..48692f68 --- /dev/null +++ b/src/tlon-tool-command.test.ts @@ -0,0 +1,88 @@ +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/src/tlon-tool-command.ts b/src/tlon-tool-command.ts new file mode 100644 index 00000000..e732d7e2 --- /dev/null +++ b/src/tlon-tool-command.ts @@ -0,0 +1,641 @@ +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; +}