thinkpool-pair 0.7.352 → 0.7.354

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/bridge.mjs CHANGED
@@ -59,6 +59,7 @@ import { cleanTerminalName, modelTerminalNameInput } from './terminal-name.mjs'
59
59
  import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredModeForSlice, structuredModeLocked, structuredModesForLane, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
60
60
  import { commandCatalogForRuntime, commandHelpLine, reconcileCommandCatalog } from './command-catalog.mjs'
61
61
  import { gitDiffReport } from './git-diff-report.mjs'
62
+ import { repoSearch } from './repo-search.mjs'
62
63
  import { probeHermesRuntime } from './hermes-probe.mjs'
63
64
  import { hermesRequiredMcpTools, hermesRoleFor } from './hermes-policy.mjs'
64
65
  import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
@@ -4539,7 +4540,7 @@ channel
4539
4540
  // Display the clean body (web sends `body` when the agent `text` carries host
4540
4541
  // file paths) + the uploaded attachments, so the partner shows the image — not
4541
4542
  // a raw /var path. The agent already got the full `text` via sendTurn above.
4542
- const evt = { kind: 'you', text: payload.body != null ? String(payload.body) : text, cid: payload.cid, by: payload.by, ...(payload.authorId ? { authorId: payload.authorId } : {}), ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}), ...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}) }
4543
+ const evt = { kind: 'you', text: payload.body != null ? String(payload.body) : text, cid: payload.cid, by: payload.by, ...(payload.authorId ? { authorId: payload.authorId } : {}), ...(payload.steered ? { steered: true } : {}), ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}), ...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}) }
4543
4544
  pushLog(s, evt)
4544
4545
  bcast('code-event', { term: payload.term, evt })
4545
4546
  }
@@ -4577,6 +4578,19 @@ channel
4577
4578
  } catch { ctlLine('Working-tree diff unavailable outside a readable Git checkout') }
4578
4579
  return
4579
4580
  }
4581
+ const findMatch = text.match(/^\/find(?:\s+([\s\S]+))?$/i)
4582
+ if (findMatch) {
4583
+ const search = repoSearch({ cwd: s.cwd || process.cwd(), query: findMatch[1] || '' })
4584
+ const evt = {
4585
+ kind: 'repo-search',
4586
+ ...search,
4587
+ by: payload.by,
4588
+ cid: payload.cid,
4589
+ }
4590
+ pushLog(s, evt)
4591
+ bcast('code-event', { term: payload.term, evt })
4592
+ return
4593
+ }
4580
4594
  if (/^\/credits\s*$/.test(text) && s.runtime !== 'hermes') {
4581
4595
  if (s.runtime !== 'codex' || typeof s.session?.accountUsage !== 'function') {
4582
4596
  ctlLine('Credit balance is unavailable for this runtime; use /usage for provider limits')
@@ -47,6 +47,7 @@ export const CODE_EVENT_REGISTRY = Object.freeze({
47
47
  'side-handback': P,
48
48
  answer: C,
49
49
  control: C,
50
+ 'repo-search': C,
50
51
  clear: C,
51
52
  'queue-add': C,
52
53
  'queue-cancel': C,
@@ -30,9 +30,11 @@
30
30
  // path routes command/file approval requests through the shared room cards.
31
31
  import fs from 'node:fs'
32
32
  import { CODEX_COMMAND_CATALOG } from './codex-commands.mjs'
33
+ import { normalizeEditKind, summarizeTextDiff } from './edit-diff.mjs'
33
34
 
34
35
  const safeMcpPart = (value) => String(value || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_')
35
36
  const mcpToolName = (item) => `mcp__${safeMcpPart(item?.server)}__${safeMcpPart(item?.tool)}`
37
+ const webSearchQuery = (item) => item?.query || item?.action?.query || item?.action?.url || ''
36
38
  const resultText = (value) => {
37
39
  if (typeof value === 'string') return value
38
40
  if (value == null) return ''
@@ -99,15 +101,16 @@ export class CodexEventMapper {
99
101
  this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: 'Bash', input: { command: it.command || '' } }], parentToolUseId: null })
100
102
  } else if (it.type === 'file_change') {
101
103
  const before = (it.changes || []).map((c) => {
104
+ const kind = normalizeEditKind(c.kind)
102
105
  let content = ''
103
- if (c.kind !== 'add' && c.path) {
106
+ if (kind !== 'add' && c.path) {
104
107
  try { content = this._fs.readFileSync(c.path, 'utf8') } catch { /* missing/unreadable → empty */ }
105
108
  }
106
- return { path: c.path || '', kind: c.kind || 'update', content }
109
+ return { path: c.path || '', kind, content }
107
110
  })
108
111
  this._fileBefore.set(it.id, before)
109
112
  } else if (it.type === 'web_search') {
110
- this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: 'WebSearch', input: { query: it.query || '' } }], parentToolUseId: null })
113
+ this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: 'WebSearch', input: { query: webSearchQuery(it) } }], parentToolUseId: null })
111
114
  } else if (it.type === 'mcp_tool_call') {
112
115
  this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name: mcpToolName(it), input: it.arguments || {} }], parentToolUseId: null })
113
116
  }
@@ -137,7 +140,17 @@ export class CodexEventMapper {
137
140
  if (c.kind !== 'delete' && c.path) {
138
141
  try { next = this._fs.readFileSync(c.path, 'utf8') } catch { /* deleted/unreadable → empty */ }
139
142
  }
140
- return { file_path: c.path, path: c.path, old_string: c.content, new_string: next, kind: c.kind }
143
+ const summary = summarizeTextDiff(c.content, next, { context: 2, maxLines: 60 })
144
+ return {
145
+ file_path: c.path,
146
+ path: c.path,
147
+ kind: c.kind,
148
+ added: summary.added,
149
+ removed: summary.removed,
150
+ diff_preview: summary.preview,
151
+ old_string: c.content,
152
+ new_string: next,
153
+ }
141
154
  })
142
155
  const first = edits[0] || { file_path: '', path: '', old_string: '', new_string: '', kind: 'update' }
143
156
  const name = edits.length > 1 ? 'MultiEdit' : first.kind === 'add' ? 'Write' : 'Edit'
@@ -149,7 +162,8 @@ export class CodexEventMapper {
149
162
  this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: it.id, name, input }], parentToolUseId: null })
150
163
  this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: `${name === 'Write' ? 'wrote' : 'edited'} ${first.path}` }], isError: false, durationMs, parentToolUseId: null })
151
164
  } else if (it.type === 'web_search') {
152
- this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: it.query || 'search completed' }], isError: false, durationMs, parentToolUseId: null })
165
+ const query = webSearchQuery(it)
166
+ this._emit({ kind: 'tool_result', toolUseId: it.id, toolInput: { query }, content: [{ type: 'text', text: query || 'search completed' }], isError: false, durationMs, parentToolUseId: null })
153
167
  } else if (it.type === 'mcp_tool_call') {
154
168
  this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: it.error?.message || resultText(it.result) }], isError: it.status === 'failed' || !!it.error, durationMs, parentToolUseId: null })
155
169
  }
@@ -19,6 +19,7 @@ export const CODE_ROOM_COMMANDS = Object.freeze([
19
19
  command('/usage', 'session usage and provider limits', 'control'),
20
20
  command('/context', 'current context-window usage', 'control'),
21
21
  command('/diff', 'Git changes and branch status', 'control'),
22
+ command('/find', 'find relevant files in this repository', 'control', 'query'),
22
23
  command('/compact', 'compact context', 'runtime'),
23
24
  command('/clear', 'clear context · confirms', 'clear'),
24
25
  command('/model', 'pick model — opens selector', 'model'),
package/edit-diff.mjs ADDED
@@ -0,0 +1,136 @@
1
+ // Bridge-local diff primitives. Keep this module self-contained: the npm
2
+ // package cannot import the web app's ../src tree.
3
+ export const normalizeEditKind = (value) => {
4
+ const kind = typeof value === 'object' ? value?.type : value
5
+ if (kind === 'add' || kind === 'delete' || kind === 'rename') return kind
6
+ return 'update'
7
+ }
8
+
9
+ const splitLines = (value) => {
10
+ if (!value) return []
11
+ const lines = String(value).replace(/\r\n?/g, '\n').split('\n')
12
+ if (lines.at(-1) === '') lines.pop()
13
+ return lines
14
+ }
15
+
16
+ const fallbackDiff = (before, after) => {
17
+ let start = 0
18
+ while (start < before.length && start < after.length && before[start] === after[start]) start++
19
+ let oldEnd = before.length
20
+ let newEnd = after.length
21
+ while (oldEnd > start && newEnd > start && before[oldEnd - 1] === after[newEnd - 1]) {
22
+ oldEnd--
23
+ newEnd--
24
+ }
25
+ return [
26
+ ...before.slice(0, start).map((text) => ({ type: 'context', text })),
27
+ ...before.slice(start, oldEnd).map((text) => ({ type: 'remove', text })),
28
+ ...after.slice(start, newEnd).map((text) => ({ type: 'add', text })),
29
+ ...before.slice(oldEnd).map((text) => ({ type: 'context', text })),
30
+ ]
31
+ }
32
+
33
+ function lineDiff(before, after, maxDistance = 600) {
34
+ if (!before.length) return after.map((text) => ({ type: 'add', text }))
35
+ if (!after.length) return before.map((text) => ({ type: 'remove', text }))
36
+ const max = Math.min(before.length + after.length, maxDistance)
37
+ const frontier = new Map([[1, 0]])
38
+ const trace = []
39
+ for (let distance = 0; distance <= max; distance++) {
40
+ trace.push(new Map(frontier))
41
+ for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
42
+ const down = frontier.get(diagonal + 1) ?? -1
43
+ const right = frontier.get(diagonal - 1) ?? -1
44
+ let oldIndex = diagonal === -distance || (diagonal !== distance && right < down)
45
+ ? Math.max(0, down)
46
+ : Math.max(0, right + 1)
47
+ let newIndex = oldIndex - diagonal
48
+ while (oldIndex < before.length && newIndex < after.length && before[oldIndex] === after[newIndex]) {
49
+ oldIndex++
50
+ newIndex++
51
+ }
52
+ frontier.set(diagonal, oldIndex)
53
+ if (oldIndex < before.length || newIndex < after.length) continue
54
+
55
+ const operations = []
56
+ let x = before.length
57
+ let y = after.length
58
+ for (let d = distance; d >= 0; d--) {
59
+ const snapshot = trace[d]
60
+ const k = x - y
61
+ const snapshotDown = snapshot.get(k + 1) ?? -1
62
+ const snapshotRight = snapshot.get(k - 1) ?? -1
63
+ const previousK = k === -d || (k !== d && snapshotRight < snapshotDown) ? k + 1 : k - 1
64
+ const previousX = Math.max(0, snapshot.get(previousK) ?? 0)
65
+ const previousY = previousX - previousK
66
+ while (x > previousX && y > previousY) {
67
+ operations.push({ type: 'context', text: before[x - 1] })
68
+ x--
69
+ y--
70
+ }
71
+ if (d === 0) break
72
+ if (x === previousX) {
73
+ operations.push({ type: 'add', text: after[y - 1] })
74
+ y--
75
+ } else {
76
+ operations.push({ type: 'remove', text: before[x - 1] })
77
+ x--
78
+ }
79
+ }
80
+ return operations.reverse()
81
+ }
82
+ }
83
+ return fallbackDiff(before, after)
84
+ }
85
+
86
+ const previewLine = (type, text = '') => {
87
+ if (type === 'add') return `+${text}`
88
+ if (type === 'remove') return `-${text}`
89
+ if (type === 'context') return ` ${text}`
90
+ return `…${text}`
91
+ }
92
+
93
+ function compactOperations(operations, context = 2, maxLines = 80) {
94
+ const changed = []
95
+ operations.forEach((operation, index) => {
96
+ if (operation.type !== 'context') changed.push(index)
97
+ })
98
+ if (!changed.length) return []
99
+
100
+ const ranges = []
101
+ for (const index of changed) {
102
+ const start = Math.max(0, index - context)
103
+ const end = Math.min(operations.length, index + context + 1)
104
+ const previous = ranges.at(-1)
105
+ if (previous && start <= previous.end) previous.end = Math.max(previous.end, end)
106
+ else ranges.push({ start, end })
107
+ }
108
+
109
+ const compacted = []
110
+ ranges.forEach((range, index) => {
111
+ if (index > 0) compacted.push({ type: 'omit', text: `${range.start - ranges[index - 1].end} unchanged lines` })
112
+ compacted.push(...operations.slice(range.start, range.end))
113
+ })
114
+ if (compacted.length <= maxLines) return compacted
115
+
116
+ const head = Math.floor((maxLines - 1) / 2)
117
+ const tail = maxLines - head - 1
118
+ return [
119
+ ...compacted.slice(0, head),
120
+ { type: 'omit', text: `${compacted.length - head - tail} preview lines hidden` },
121
+ ...compacted.slice(-tail),
122
+ ]
123
+ }
124
+
125
+ export function summarizeTextDiff(oldText, newText, options = {}) {
126
+ const operations = lineDiff(splitLines(oldText), splitLines(newText), options.maxDistance)
127
+ const added = operations.reduce((total, operation) => total + (operation.type === 'add' ? 1 : 0), 0)
128
+ const removed = operations.reduce((total, operation) => total + (operation.type === 'remove' ? 1 : 0), 0)
129
+ const lines = compactOperations(operations, options.context ?? 2, options.maxLines ?? 80)
130
+ return {
131
+ added,
132
+ removed,
133
+ lines,
134
+ preview: lines.map((line) => previewLine(line.type, line.text)).join('\n'),
135
+ }
136
+ }
@@ -16,8 +16,11 @@ function defaultRunGit(args, cwd) {
16
16
  }
17
17
 
18
18
  function cleanLine(value, max = 240) {
19
- return String(value || '')
20
- .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
19
+ return Array.from(String(value || ''), (character) => {
20
+ const code = character.charCodeAt(0)
21
+ const removableControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a && code !== 0x0d) || code === 0x7f
22
+ return removableControl ? '' : character
23
+ }).join('')
21
24
  .replace(/\s+/g, ' ')
22
25
  .trim()
23
26
  .slice(0, max)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.352",
3
+ "version": "0.7.354",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  "codex-mcp-http.mjs",
35
35
  "lane-worktree.mjs",
36
36
  "codex-event-mapper.mjs",
37
+ "edit-diff.mjs",
37
38
  "cumulative-event-relay.mjs",
38
39
  "codex-commands.mjs",
39
40
  "acp-client.mjs",
@@ -48,6 +49,7 @@
48
49
  "hermes-delegation-guard.mjs",
49
50
  "runtime-registry.mjs",
50
51
  "command-catalog.mjs",
52
+ "repo-search.mjs",
51
53
  "git-diff-report.mjs",
52
54
  "runtime-session.mjs",
53
55
  "turn-stall.mjs",
@@ -0,0 +1,188 @@
1
+ import { execFileSync } from 'node:child_process'
2
+
3
+ const MAX_QUERY_CHARS = 160
4
+ const DEFAULT_MAX_RESULTS = 8
5
+ const MAX_FILES_BUFFER = 16 * 1024 * 1024
6
+
7
+ const STOP_WORDS = new Set([
8
+ 'a', 'an', 'and', 'every', 'file', 'files', 'find', 'for', 'from', 'in',
9
+ 'inside', 'is', 'locate', 'me', 'of', 'please', 'show', 'the', 'to', 'under',
10
+ 'where', 'with',
11
+ ])
12
+
13
+ const EXPANSIONS = Object.freeze({
14
+ api: ['endpoint', 'route', 'server'],
15
+ auth: ['login', 'session', 'oauth'],
16
+ component: ['card', 'panel', 'view', 'widget'],
17
+ config: ['configuration', 'settings'],
18
+ database: ['db', 'sql', 'schema'],
19
+ image: ['asset', 'gallery', 'media', 'photo', 'picture', 'thumbnail'],
20
+ library: ['catalog', 'collection', 'index', 'lib'],
21
+ login: ['auth', 'oauth', 'session'],
22
+ session: ['auth', 'login'],
23
+ style: ['css', 'theme'],
24
+ styles: ['css', 'theme'],
25
+ test: ['spec'],
26
+ tests: ['spec'],
27
+ })
28
+
29
+ const cleanQuery = (value) => String(value || '')
30
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
31
+ .replace(/\s+/g, ' ')
32
+ .trim()
33
+
34
+ const unique = (values) => [...new Set(values.filter(Boolean))]
35
+ const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
36
+ const normalizePath = (value) => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '')
37
+ const wordish = (value) => String(value || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
38
+ const pathContainsSegment = (path, segment) => `/${normalizePath(path).toLowerCase()}/`.includes(`/${segment.toLowerCase().replace(/^\/+|\/+$/g, '')}/`)
39
+
40
+ function queryTokens(query) {
41
+ return unique((query.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_-]*/gu) || [])
42
+ .map((token) => token.replace(/^[-_]+|[-_]+$/g, ''))
43
+ .filter((token) => token && !STOP_WORDS.has(token)))
44
+ }
45
+
46
+ function locationHints(query) {
47
+ return unique([...query.toLowerCase().matchAll(/\b(?:from|in|inside|under)\s+([\p{L}\p{N}_.\/-]+)/gu)]
48
+ .map((match) => match[1].replace(/^\/+|\/+$/g, ''))
49
+ .filter(Boolean))
50
+ }
51
+
52
+ function defaultRun(command, args, cwd) {
53
+ return execFileSync(command, args, {
54
+ cwd,
55
+ encoding: 'utf8',
56
+ timeout: command === 'rg' ? 1400 : 3000,
57
+ maxBuffer: MAX_FILES_BUFFER,
58
+ stdio: ['ignore', 'pipe', 'pipe'],
59
+ })
60
+ }
61
+
62
+ function listedFiles(run, cwd) {
63
+ const raw = String(run('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], cwd) || '')
64
+ return unique(raw.split('\0').map(normalizePath).filter(Boolean))
65
+ }
66
+
67
+ function contentMatches(run, cwd, terms, knownFiles) {
68
+ const matches = new Map()
69
+ if (!terms.length) return matches
70
+ const pattern = terms.slice().sort((a, b) => b.length - a.length).map(escapeRegex).join('|')
71
+ let raw = ''
72
+ try {
73
+ raw = String(run('rg', [
74
+ '--json',
75
+ '--ignore-case',
76
+ '--max-count', '4',
77
+ '--no-messages',
78
+ '--regexp', pattern,
79
+ '.',
80
+ ], cwd) || '')
81
+ } catch {
82
+ // `rg` is the fast path. A host without it still gets filename/path search;
83
+ // repository search must never block the room or fall through to an LLM turn.
84
+ return matches
85
+ }
86
+ for (const line of raw.split(/\r?\n/)) {
87
+ if (!line) continue
88
+ let record
89
+ try { record = JSON.parse(line) } catch { continue }
90
+ if (record?.type !== 'match') continue
91
+ const path = normalizePath(record.data?.path?.text)
92
+ if (!knownFiles.has(path)) continue
93
+ const lineNumber = Number(record.data?.line_number)
94
+ const text = String(record.data?.lines?.text || '').toLowerCase()
95
+ const prior = matches.get(path)
96
+ if (!prior) matches.set(path, { line: Number.isFinite(lineNumber) ? lineNumber : null, text })
97
+ else prior.text += ` ${text}`
98
+ }
99
+ return matches
100
+ }
101
+
102
+ function candidateScore(path, original, expanded, content) {
103
+ const lowerPath = path.toLowerCase()
104
+ const base = lowerPath.split('/').at(-1) || lowerPath
105
+ const pathWords = ` ${wordish(lowerPath)} `
106
+ const contentText = content?.text || ''
107
+ let score = 0
108
+
109
+ const phrase = original.join(' ')
110
+ if (phrase && pathWords.includes(` ${phrase} `)) score += 100
111
+ for (const token of original) {
112
+ if (base.includes(token)) score += 30
113
+ if (lowerPath.includes(token)) score += 16
114
+ if (pathWords.includes(` ${token} `)) score += 8
115
+ if (contentText.includes(token)) score += 8
116
+ }
117
+ for (const token of expanded) {
118
+ if (base.includes(token)) score += 8
119
+ else if (lowerPath.includes(token)) score += 5
120
+ if (contentText.includes(token)) score += 2
121
+ }
122
+
123
+ const rootMatches = (haystack) => original.filter((token) =>
124
+ haystack.includes(token) || (EXPANSIONS[token] || []).some((alias) => haystack.includes(alias)))
125
+ const pathCoverage = rootMatches(lowerPath).length
126
+ const coverage = rootMatches(`${lowerPath} ${contentText}`).length
127
+ score += (coverage ** 2) * 18 + (pathCoverage ** 2) * 22
128
+
129
+ const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')) : ''
130
+ const componentFile = ['.jsx', '.tsx', '.vue', '.svelte'].includes(ext)
131
+ if (original.includes('component') && componentFile) score += 60
132
+ if (original.some((token) => token === 'test' || token === 'tests') && /(?:^|\/)(?:test|tests|spec|specs)(?:\/|$)|\.(?:test|spec)\./.test(lowerPath)) score += 10
133
+ if (original.some((token) => token === 'asset' || token === 'assets') && /(?:^|\/)assets?(?:\/|$)/.test(lowerPath)) score += 10
134
+ const testPath = /(?:^|\/)(?:test|tests|spec|specs)(?:\/|$)|\.(?:test|spec)\./.test(lowerPath)
135
+ if (testPath && !original.some((token) => token === 'test' || token === 'tests')) score -= 25
136
+ if (/^(?:docs|marketing|archive|\.claude)\//.test(lowerPath)) score -= 35
137
+ if (/^(?:src|app|lib|api|bridge|server|client|components|packages)\//.test(lowerPath)) score += 12
138
+ return { score, coverage, pathCoverage, componentFile }
139
+ }
140
+
141
+ export function repoSearch({
142
+ cwd = process.cwd(),
143
+ query,
144
+ maxResults = DEFAULT_MAX_RESULTS,
145
+ run = defaultRun,
146
+ now = () => Date.now(),
147
+ } = {}) {
148
+ const startedAt = now()
149
+ const cleaned = cleanQuery(query)
150
+ const finish = (result) => ({ ...result, durationMs: Math.max(0, Math.round(now() - startedAt)) })
151
+
152
+ if (!cleaned) return finish({ query: '', results: [], total: 0, truncated: false, error: 'Use /find followed by a filename, symbol, or code concept.' })
153
+ if (cleaned.length > MAX_QUERY_CHARS) return finish({ query: cleaned.slice(0, MAX_QUERY_CHARS), results: [], total: 0, truncated: false, error: `Keep /find queries under ${MAX_QUERY_CHARS} characters.` })
154
+
155
+ const original = queryTokens(cleaned)
156
+ if (!original.length) return finish({ query: cleaned, results: [], total: 0, truncated: false, error: 'Try a filename, symbol, or code concept.' })
157
+ const expanded = unique(original.flatMap((token) => EXPANSIONS[token] || []))
158
+
159
+ let files
160
+ try { files = listedFiles(run, cwd) } catch {
161
+ return finish({ query: cleaned, results: [], total: 0, truncated: false, error: 'Repository search is unavailable outside a readable Git checkout.' })
162
+ }
163
+ const activeLocations = locationHints(cleaned).filter((hint) => files.some((path) => pathContainsSegment(path, hint)))
164
+ const eligibleFiles = activeLocations.length
165
+ ? files.filter((path) => activeLocations.every((hint) => pathContainsSegment(path, hint)))
166
+ : files
167
+ const knownFiles = new Set(eligibleFiles)
168
+ const matches = contentMatches(run, cwd, unique([...original, ...expanded]), knownFiles)
169
+ const minimumCoverage = Math.max(1, Math.ceil(original.length * 0.6))
170
+ const ranked = eligibleFiles
171
+ .map((path) => {
172
+ const rank = candidateScore(path, original, expanded, matches.get(path))
173
+ return { path, line: matches.get(path)?.line || null, ...rank }
174
+ })
175
+ .filter((item) => item.score > 0 && (
176
+ item.coverage >= minimumCoverage
177
+ || (original.includes('component') && item.componentFile && item.coverage >= 2)
178
+ ))
179
+ .sort((a, b) => b.score - a.score || b.pathCoverage - a.pathCoverage || a.path.length - b.path.length || a.path.localeCompare(b.path))
180
+
181
+ const limit = Math.max(1, Math.min(20, Number(maxResults) || DEFAULT_MAX_RESULTS))
182
+ return finish({
183
+ query: cleaned,
184
+ results: ranked.slice(0, limit).map(({ path, line }) => ({ path, ...(line ? { line } : {}) })),
185
+ total: ranked.length,
186
+ truncated: ranked.length > limit,
187
+ })
188
+ }
package/terminal-name.mjs CHANGED
@@ -224,7 +224,7 @@ export function cleanTerminalName(value) {
224
224
  const words = name.split(/\s+/).filter(Boolean)
225
225
  const compactCjk = words.length === 1
226
226
  && /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(name)
227
- if ((words.length < 2 && !compactCjk) || words.length > 4) return null
227
+ if ((words.length < 2 && !compactCjk) || words.length > 3) return null
228
228
  return name || null
229
229
  }
230
230
 
@@ -268,7 +268,7 @@ const taskTitle = (value) => {
268
268
  if (!body) return null
269
269
  const grammar = analyzeGrammar(body)
270
270
  if (grammar.negativePreference) {
271
- const subject = contentWords(negativePreferenceSubject(grammar)).slice(0, 3)
271
+ const subject = contentWords(negativePreferenceSubject(grammar)).slice(0, 2)
272
272
  return subject.length ? cleanTerminalName(['Avoid', ...subject.map(titleWord)].join(' ')) : null
273
273
  }
274
274
  const informationRequest = grammar.informationRequest
@@ -286,11 +286,11 @@ const taskTitle = (value) => {
286
286
  if (!verb && /\bopen(?:s|ed|ing)?\b/i.test(body)) verb = 'Open'
287
287
 
288
288
  if (/\bautomatic(?:ally)?\b[\s\S]{0,24}\bnam(?:e|ing)\b/i.test(signalText) && /\bterminals?\b/i.test(signalText)) {
289
- if (/\bcontext\b[\s\S]{0,24}\broom\b|\broom\b[\s\S]{0,24}\bcontext\b/i.test(signalText)) return 'Auto-Name Terminals From Context'
289
+ if (/\bcontext\b[\s\S]{0,24}\broom\b|\broom\b[\s\S]{0,24}\bcontext\b/i.test(signalText)) return 'Contextual Terminal Names'
290
290
  verb = 'Auto-Name'
291
291
  }
292
- if (verb === 'Snap' && /\bterminal row\b/i.test(signalText) && /\bview\b/i.test(signalText)) return 'Snap Terminal Row View'
293
- if (verb === 'Snap' && /\bclicked terminal\b/i.test(signalText) && /\bview\b/i.test(signalText)) return 'Snap Clicked Terminal View'
292
+ if (verb === 'Snap' && /\bterminal row\b/i.test(signalText) && /\bview\b/i.test(signalText)) return 'Snap Terminal Row'
293
+ if (verb === 'Snap' && /\bclicked terminal\b/i.test(signalText) && /\bview\b/i.test(signalText)) return 'Snap Clicked Terminal'
294
294
 
295
295
  let domain = []
296
296
  let objectText = body
@@ -311,7 +311,7 @@ const taskTitle = (value) => {
311
311
  }
312
312
  const picked = [...domain, ...object]
313
313
  .filter((word, index, all) => all.findIndex((other) => other.toLowerCase() === word.toLowerCase()) === index)
314
- .slice(0, verb ? 3 : 4)
314
+ .slice(0, verb ? 2 : 3)
315
315
  if (!picked.length) return null
316
316
  return cleanTerminalName([verb, ...picked.map(titleWord)].filter(Boolean).join(' '))
317
317
  }