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 +15 -1
- package/code-event-contract.mjs +1 -0
- package/codex-event-mapper.mjs +19 -5
- package/command-catalog.mjs +1 -0
- package/edit-diff.mjs +136 -0
- package/git-diff-report.mjs +5 -2
- package/package.json +3 -1
- package/repo-search.mjs +188 -0
- package/terminal-name.mjs +6 -6
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/codex-event-mapper.mjs
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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/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
|
+
}
|
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": {
|
|
@@ -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",
|
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
|
+
}
|
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 >
|
|
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,
|
|
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 '
|
|
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
|
|
293
|
-
if (verb === 'Snap' && /\bclicked terminal\b/i.test(signalText) && /\bview\b/i.test(signalText)) return 'Snap Clicked Terminal
|
|
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 ?
|
|
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
|
}
|