thinkpool-pair 0.7.353 → 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,
@@ -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'),
@@ -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.353",
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": {
@@ -49,6 +49,7 @@
49
49
  "hermes-delegation-guard.mjs",
50
50
  "runtime-registry.mjs",
51
51
  "command-catalog.mjs",
52
+ "repo-search.mjs",
52
53
  "git-diff-report.mjs",
53
54
  "runtime-session.mjs",
54
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
+ }