typeclaw 0.37.1 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.37.1",
3
+ "version": "0.37.2",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -37,6 +37,7 @@
37
37
  "format:check": "oxfmt --check .",
38
38
  "check": "bun run typecheck && bun run lint && bun run format:check",
39
39
  "test": "bun test --parallel",
40
+ "dev:docs": "cd docs && bun run dev",
40
41
  "generate:schema": "bun run scripts/generate-schema.ts",
41
42
  "debug:prompt": "bun run scripts/dump-system-prompt.ts",
42
43
  "postinstall": "bun run scripts/generate-schema.ts"
@@ -15,7 +15,7 @@ import { loadMemory } from '@/bundled-plugins/memory/load-memory'
15
15
  import type { ChannelRouter } from '@/channels/router'
16
16
  import type { ReactionRef } from '@/channels/types'
17
17
  import { getConfig, resolveModel, resolveProfile } from '@/config'
18
- import { defaultThinkingLevelForRef, providerForModelRef, type ModelRef } from '@/config/providers'
18
+ import { defaultThinkingLevelForRef, isOpenAiFamilyRef, providerForModelRef, type ModelRef } from '@/config/providers'
19
19
  import { renderMcpCatalog } from '@/mcp/catalog'
20
20
  import type { McpManager } from '@/mcp/manager'
21
21
  import { createMcpDispatcherTools, MCP_DISPATCHER_TOOL_NAMES } from '@/mcp/tools'
@@ -47,6 +47,7 @@ import {
47
47
  wrapSystemTool,
48
48
  zodToToolParameters,
49
49
  } from './plugin-tools'
50
+ import { PROACTIVE_NEXT_STEP_NUDGE } from './proactive-next-step-nudge'
50
51
  import { createReloadTool } from './reload-tool'
51
52
  import type { RestartHandoffOrigin } from './restart-handoff'
52
53
  import type { SubagentBashPolicy } from './reviewer-bash-policy'
@@ -277,6 +278,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
277
278
  ...(options.mcpManager !== undefined ? { mcpManager: options.mcpManager } : {}),
278
279
  ...(options.subagentRegistry !== undefined ? { subagentRegistry: options.subagentRegistry } : {}),
279
280
  ...(options.suppressSystemMemory !== undefined ? { suppressSystemMemory: options.suppressSystemMemory } : {}),
281
+ ...(isOpenAiFamilyRef(activeRef) ? { proactiveNextStepNudge: true } : {}),
280
282
  })
281
283
 
282
284
  const getOrigin: () => SessionOrigin | undefined =
@@ -957,6 +959,7 @@ export type CreateResourceLoaderOptions = {
957
959
  // from `memory.vector.enabled` — vector is restart-required, so the boot
958
960
  // snapshot is coherent with the per-turn injection decision.
959
961
  suppressSystemMemory?: boolean
962
+ proactiveNextStepNudge?: boolean
960
963
  }
961
964
 
962
965
  // Origins where the operator-facing DEFAULT_SYSTEM_PROMPT, git-nudge, and the
@@ -1020,6 +1023,7 @@ export type SystemPromptComposition = {
1020
1023
  roleContext?: SessionRoleContext
1021
1024
  mcpCatalog?: string
1022
1025
  gitNudge: string
1026
+ proactiveNextStepNudge?: string
1023
1027
  memorySection: string
1024
1028
  }
1025
1029
 
@@ -1065,6 +1069,9 @@ export function composeSystemPrompt(parts: SystemPromptComposition): string {
1065
1069
  if (parts.gitNudge !== '') {
1066
1070
  prompt = `${prompt}\n\n${parts.gitNudge}`
1067
1071
  }
1072
+ if (parts.proactiveNextStepNudge !== undefined && parts.proactiveNextStepNudge !== '') {
1073
+ prompt = `${prompt}\n\n${parts.proactiveNextStepNudge}`
1074
+ }
1068
1075
  if (parts.memorySection !== '') {
1069
1076
  prompt = `${prompt}\n\n${parts.memorySection}`
1070
1077
  }
@@ -1164,6 +1171,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
1164
1171
  ? { mcpCatalog: renderMcpCatalog(options.mcpManager.listServers()) }
1165
1172
  : {}),
1166
1173
  gitNudge,
1174
+ ...(options.proactiveNextStepNudge === true ? { proactiveNextStepNudge: PROACTIVE_NEXT_STEP_NUDGE } : {}),
1167
1175
  memorySection,
1168
1176
  })
1169
1177
 
@@ -0,0 +1,11 @@
1
+ export const PROACTIVE_NEXT_STEP_NUDGE_TITLE = '## Proactive and requested next-step guidance'
2
+
3
+ // GPT-only prompt text is intentionally absent from scripts/dump-system-prompt.ts
4
+ // token accounting because the dump tooling renders with non-GPT placeholders.
5
+ export const PROACTIVE_NEXT_STEP_NUDGE = [
6
+ PROACTIVE_NEXT_STEP_NUDGE_TITLE,
7
+ '',
8
+ 'GPT/OpenAI-family behavior nudge: when the user asks for work and a reasonable or necessary next step is obvious, do not ask for permission or confirmation before doing it. Do the next step when it makes sense, especially when it is necessary to complete the task well. Avoid empty optional follow-up CTAs such as “if you want, I can also …”; either take the useful next action or end with the completed result.',
9
+ '',
10
+ 'When the user explicitly asks for suggestions, options, alternatives, or what to do next, answer that request directly with concrete next-step suggestions instead of treating suggestions as an unwanted follow-up CTA.',
11
+ ].join('\n')
@@ -384,6 +384,27 @@ function renderChannelOrigin(
384
384
  )
385
385
  }
386
386
 
387
+ // Discord renders no GFM tables — a raw `| a | b |` block shows as literal
388
+ // pipes. The discord-bot adapter rewrites BARE pipe tables into aligned
389
+ // inline-code rows for readability, but it skips any table inside a ``` /
390
+ // ~~~ fence (a fenced table is literal text by CommonMark). Models that have
391
+ // "learned" Discord mangles tables defensively wrap them in a fence, which is
392
+ // exactly what disables the auto-conversion — so the table renders ragged
393
+ // anyway. Tell the model to emit tables bare and let the adapter format them.
394
+ if (origin.adapter === 'discord-bot') {
395
+ lines.push(
396
+ '',
397
+ '**Emit Markdown tables as bare `| a | b |` blocks — never inside a code',
398
+ 'fence.** Discord does not render Markdown tables, so this session',
399
+ 'auto-reformats a bare pipe table (a `|`-row followed by a `|---|`',
400
+ 'alignment row) into aligned, readable columns before it sends. That',
401
+ 'reformatting only fires on raw Markdown: the moment you wrap the table in',
402
+ 'a ``` or ~~~ fence it is treated as literal text and lands as ragged pipes.',
403
+ 'So write the table directly in your reply with no surrounding fence. Use',
404
+ 'fences only for actual code or output you want shown verbatim.',
405
+ )
406
+ }
407
+
387
408
  const conversationLine = renderConversationLine(origin)
388
409
  if (conversationLine !== null) lines.push('', conversationLine)
389
410
 
@@ -3358,23 +3358,6 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3358
3358
  live.skippedTurn = null
3359
3359
  logger.info(`[channels] ${live.keyId} skip_contested_by_send recovering reply`)
3360
3360
  }
3361
- // A send landed this turn, but the model may have posted a `continue: true`
3362
- // progress reply, kept working, then ENDED with its final answer as plain
3363
- // prose — never calling a channel tool again. The terminal-reply abort fires
3364
- // only for a `channel_reply` WITHOUT `continue: true`, so that `stopReason:
3365
- // 'stop'` text leaf is left undelivered and unguarded (the false-receipt
3366
- // guard is github-only). The discriminator is leaf IDENTITY: only when the
3367
- // turn-end `stop` leaf is a DIFFERENT entry than the one in place at the last
3368
- // send did the model produce fresh post-reply prose. A leaf unchanged since
3369
- // the send is narration the model emitted with/before the reply that already
3370
- // landed — suppress it, as before.
3371
- if (live.successfulChannelSends > successfulSendsBeforePrompt) {
3372
- maybeNudgeContinuationWillingness(live)
3373
- const trailing = recoverableAssistantText(live.session)
3374
- if (trailing === null || trailing.source !== 'leaf') return
3375
- if (live.session.sessionManager.getLeafEntry()?.id === live.lastSendLeafId) return
3376
- }
3377
-
3378
3361
  const postEmptyTurnFallback = async (cause: string): Promise<void> => {
3379
3362
  logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
3380
3363
  const result = await send(
@@ -3392,6 +3375,49 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3392
3375
  }
3393
3376
  }
3394
3377
 
3378
+ // A send landed this turn, but the model may have posted a `continue: true`
3379
+ // progress reply, kept working, then ENDED with its final answer as plain
3380
+ // prose — never calling a channel tool again. The terminal-reply abort fires
3381
+ // only for a `channel_reply` WITHOUT `continue: true`, so that `stopReason:
3382
+ // 'stop'` text leaf is left undelivered and unguarded (the false-receipt
3383
+ // guard is github-only). The discriminator is leaf IDENTITY: only when the
3384
+ // turn-end `stop` leaf is a DIFFERENT entry than the one in place at the last
3385
+ // send did the model produce fresh post-reply prose. A leaf unchanged since
3386
+ // the send is narration the model emitted with/before the reply that already
3387
+ // landed — suppress it, as before.
3388
+ if (live.successfulChannelSends > successfulSendsBeforePrompt) {
3389
+ maybeNudgeContinuationWillingness(live)
3390
+ const trailing = recoverableAssistantText(live.session)
3391
+ if (trailing === null || trailing.source !== 'leaf') {
3392
+ // A `continue: true` status reply landed, then the turn stranded on an
3393
+ // unanswered `toolUse` (the post-tool follow-up never produced an
3394
+ // assistant message — aborted loop / cancelled stream). The promised
3395
+ // work never finished, so the user is left with a bare "checking now…"
3396
+ // and nothing after it. Re-prompt the same logical turn so the model
3397
+ // completes its investigation and actually replies, instead of ending
3398
+ // in silence. On retry-exhaustion post the fallback rather than
3399
+ // returning silently — a retry turn that re-sends a status and re-strands
3400
+ // on the same no-prose shape must not deadair the user. Any postable
3401
+ // pre-tool/mid-turn prose is suppressed here as before (it was narration
3402
+ // that accompanied the already-landed reply); only the no-prose strand
3403
+ // gets a retry-or-fallback.
3404
+ if (leafIsStrandedToolUse(live.session) && live.currentTurnAuthorId !== null) {
3405
+ if (live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
3406
+ live.emptyTurnRetries++
3407
+ logger.warn(
3408
+ `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES} ` +
3409
+ `cause=stranded_toolUse_after_send`,
3410
+ )
3411
+ live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
3412
+ } else {
3413
+ await postEmptyTurnFallback('stranded_toolUse_retries_exhausted')
3414
+ }
3415
+ }
3416
+ return
3417
+ }
3418
+ if (live.session.sessionManager.getLeafEntry()?.id === live.lastSendLeafId) return
3419
+ }
3420
+
3395
3421
  let candidate = recoverableAssistantText(live.session)
3396
3422
  // A `length` leaf is recovered ONLY when stripping leaked `<think>…</think>`
3397
3423
  // spans actually removed something AND leaves a postable reply. The removal
@@ -4961,6 +4987,40 @@ function assistantLeafStopReason(session: AgentSession): 'length' | 'error' | 'a
4961
4987
  return undefined
4962
4988
  }
4963
4989
 
4990
+ // True when the branch ends on an UNANSWERED `toolUse` that left NO postable
4991
+ // prose — the model called a tool and the upstream pi-agent-core post-tool
4992
+ // follow-up never produced an assistant message (the loop was aborted, or the
4993
+ // follow-up stream cancelled). Two leaf shapes carry this signature: the leaf
4994
+ // IS a `toolUse` assistant, or the leaf is a `toolResult` whose nearest
4995
+ // assistant ancestor (reached before any user message) is `toolUse`. The
4996
+ // no-prose requirement is the discriminator from a model that narrated a reply
4997
+ // alongside its tool call and DID land a real send this turn (that trailing
4998
+ // `toolUse` is delivered narration, not a stranded promise — leave it alone).
4999
+ // Keys on the model having INTENDED to keep working with nothing yet said; used
5000
+ // to re-prompt a turn that strands mid-work after a `continue: true` status
5001
+ // reply instead of ending in silence.
5002
+ function leafIsStrandedToolUse(session: AgentSession): boolean {
5003
+ const leaf = session.sessionManager.getLeafEntry()
5004
+ if (!leaf || leaf.type !== 'message') return false
5005
+ if (leaf.message.role === 'assistant') {
5006
+ return leaf.message.stopReason === 'toolUse' && visibleAssistantText(leaf.message).trim() === ''
5007
+ }
5008
+ if (leaf.message.role !== 'toolResult') return false
5009
+ let cursor: { parentId: string | null } | undefined = leaf
5010
+ for (let depth = 0; depth < 32 && cursor?.parentId; depth++) {
5011
+ const parent = session.sessionManager.getEntry(cursor.parentId)
5012
+ if (!parent) return false
5013
+ if (parent.type === 'message') {
5014
+ if (parent.message.role === 'assistant') {
5015
+ return parent.message.stopReason === 'toolUse' && visibleAssistantText(parent.message).trim() === ''
5016
+ }
5017
+ if (parent.message.role === 'user') return false
5018
+ }
5019
+ cursor = parent
5020
+ }
5021
+ return false
5022
+ }
5023
+
4964
5024
  function visibleAssistantText(message: AssistantMessage): string {
4965
5025
  return message.content
4966
5026
  .filter((block) => block.type === 'text')
@@ -0,0 +1,32 @@
1
+ import type { Option } from '@clack/prompts'
2
+
3
+ type FuzzyFilter<Value> = (search: string, option: Option<Value>) => boolean
4
+
5
+ function optionHaystack<Value>(option: Option<Value>): string {
6
+ const label = option.label ?? String(option.value)
7
+ const hint = option.hint ?? ''
8
+ return `${label} ${String(option.value)} ${hint}`.toLowerCase()
9
+ }
10
+
11
+ function isSubsequence(query: string, haystack: string): boolean {
12
+ let i = 0
13
+ for (let j = 0; j < haystack.length && i < query.length; j++) {
14
+ if (haystack[j] === query[i]) i++
15
+ }
16
+ return i === query.length
17
+ }
18
+
19
+ // Splitting the query on whitespace lets "gpt 5.5" match "GPT-5.5 Turbo": each
20
+ // token is matched independently as a subsequence, so the "-" inside "GPT-5.5"
21
+ // no longer breaks the search the way a plain substring "gpt 5.5" would. Tokens
22
+ // are order-independent so "turbo gpt" finds "GPT Turbo" too.
23
+ export function fuzzyMatch<Value>(search: string, option: Option<Value>): boolean {
24
+ const tokens = search.toLowerCase().split(/\s+/).filter(Boolean)
25
+ if (tokens.length === 0) return true
26
+ const haystack = optionHaystack(option)
27
+ return tokens.every((token) => isSubsequence(token, haystack))
28
+ }
29
+
30
+ export const fuzzyFilter: FuzzyFilter<unknown> = fuzzyMatch
31
+
32
+ export type { FuzzyFilter }
package/src/cli/init.ts CHANGED
@@ -66,6 +66,7 @@ import {
66
66
  type KeyValidationResult,
67
67
  } from '@/init/validate-api-key'
68
68
 
69
+ import { fuzzyMatch } from './fuzzy-filter'
69
70
  import { buildOAuthCallbacks } from './oauth-callbacks'
70
71
  import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
71
72
  import {
@@ -1098,6 +1099,7 @@ async function pickVendor(
1098
1099
  const choice = await autocomplete({
1099
1100
  message: 'Pick an LLM provider',
1100
1101
  placeholder: 'Type to search…',
1102
+ filter: fuzzyMatch,
1101
1103
  options: vendors.map((id) => ({
1102
1104
  value: id,
1103
1105
  label: KNOWN_PROVIDER_VENDORS[id].name,
@@ -1120,6 +1122,7 @@ async function pickProviderVariant(
1120
1122
  const choice = await autocomplete<KnownProviderId>({
1121
1123
  message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
1122
1124
  placeholder: 'Type to search…',
1125
+ filter: fuzzyMatch,
1123
1126
  options: variants.map((id) => {
1124
1127
  const hint = variantHint(vendorId, id)
1125
1128
  return hint !== undefined
@@ -1145,6 +1148,7 @@ async function pickModelForProvider(
1145
1148
  const choice = await autocomplete<string>({
1146
1149
  message: `Pick a ${KNOWN_PROVIDERS[providerId].name} model`,
1147
1150
  placeholder: 'Type to search…',
1151
+ filter: fuzzyMatch,
1148
1152
  options: candidates.map((o) => ({
1149
1153
  value: o.ref,
1150
1154
  label: formatModelLabel(o),
@@ -1191,6 +1195,7 @@ async function pickVisionVendor(
1191
1195
  const choice = await autocomplete<KnownProviderVendorId | 'skip'>({
1192
1196
  message: 'Your model is text-only. Pick a provider for the `vision` profile (used for image input)',
1193
1197
  placeholder: 'Type to search…',
1198
+ filter: fuzzyMatch,
1194
1199
  options: [
1195
1200
  ...vendors.map((id) => ({
1196
1201
  value: id as KnownProviderVendorId | 'skip',
@@ -1223,6 +1228,7 @@ async function pickVisionModel(
1223
1228
  const choice = await autocomplete<string>({
1224
1229
  message: `Pick a vision-capable ${KNOWN_PROVIDERS[providerId].name} model`,
1225
1230
  placeholder: 'Type to search…',
1231
+ filter: fuzzyMatch,
1226
1232
  options: candidates.map((o) => ({
1227
1233
  value: o.ref,
1228
1234
  label: formatModelLabel(o),
package/src/cli/model.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import { findAgentDir, isInitialized } from '@/init'
20
20
  import { customModelMetaFromOption, fetchModelOptions, type ModelOption } from '@/init/models-dev'
21
21
 
22
+ import { fuzzyMatch } from './fuzzy-filter'
22
23
  import { runProviderAddFlow } from './provider'
23
24
  import { c, done, errorLine } from './ui'
24
25
 
@@ -251,6 +252,7 @@ async function pickModelRef(cwd: string): Promise<PickedModelRef> {
251
252
  const choice = await autocomplete<string>({
252
253
  message: 'Pick a model',
253
254
  placeholder: 'Type to search…',
255
+ filter: fuzzyMatch,
254
256
  options: [
255
257
  ...modelOptions.map((option) => ({
256
258
  value: option.ref,
@@ -23,6 +23,7 @@ import {
23
23
  import { findAgentDir, isInitialized } from '@/init'
24
24
  import { makeOAuthLoginRunner } from '@/init/oauth-login'
25
25
 
26
+ import { fuzzyMatch } from './fuzzy-filter'
26
27
  import { buildOAuthCallbacks } from './oauth-callbacks'
27
28
  import { c, done, errorLine } from './ui'
28
29
 
@@ -285,6 +286,7 @@ async function pickVendorToAdd(): Promise<KnownProviderVendorId> {
285
286
  const choice = await autocomplete<KnownProviderVendorId>({
286
287
  message: 'Pick a provider to add',
287
288
  placeholder: 'Type to search…',
289
+ filter: fuzzyMatch,
288
290
  options: vendorIds.map((id) => ({
289
291
  value: id,
290
292
  label: KNOWN_PROVIDER_VENDORS[id].name,
@@ -305,6 +307,7 @@ async function pickVariantToAdd(vendorId: KnownProviderVendorId): Promise<KnownP
305
307
  const choice = await autocomplete<KnownProviderId>({
306
308
  message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
307
309
  placeholder: 'Type to search…',
310
+ filter: fuzzyMatch,
308
311
  options: variants.map((id) => {
309
312
  const hint = variantHint(vendorId, id)
310
313
  return hint !== undefined
@@ -991,9 +991,12 @@ function knownProviderForModelRef(ref: string): KnownProviderId | null {
991
991
  //
992
992
  // Anthropic, GLM, and Kimi don't share the padding behavior, so they keep the
993
993
  // SDK default.
994
+ export function isOpenAiFamilyRef(ref: KnownModelRef | ModelRef | string): boolean {
995
+ return vendorForProviderId(providerForModelRef(ref)) === 'openai'
996
+ }
997
+
994
998
  export function defaultThinkingLevelForRef(ref: KnownModelRef | ModelRef | string): 'low' | undefined {
995
- const providerId = providerForModelRef(ref)
996
- if (providerId === 'openai' || providerId === 'openai-codex') return 'low'
999
+ if (isOpenAiFamilyRef(ref)) return 'low'
997
1000
  return undefined
998
1001
  }
999
1002