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 +15 -1
- package/code-event-contract.mjs +1 -0
- package/command-catalog.mjs +1 -0
- package/git-diff-report.mjs +5 -2
- package/package.json +2 -1
- package/repo-search.mjs +188 -0
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')
|
package/code-event-contract.mjs
CHANGED
package/command-catalog.mjs
CHANGED
|
@@ -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/git-diff-report.mjs
CHANGED
|
@@ -16,8 +16,11 @@ function defaultRunGit(args, cwd) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function cleanLine(value, max = 240) {
|
|
19
|
-
return String(value || '')
|
|
20
|
-
.
|
|
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.
|
|
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",
|
package/repo-search.mjs
ADDED
|
@@ -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
|
+
}
|