thinkpool-pair 0.7.311 → 0.7.312
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 +97 -0
- package/claude-session.mjs +35 -21
- package/cross-terminal.mjs +1 -1
- package/package.json +2 -1
- package/terminal-name.mjs +54 -0
package/bridge.mjs
CHANGED
|
@@ -55,6 +55,8 @@ import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThread
|
|
|
55
55
|
import { codexAccountUsageLine, codexCreditsReportLine, codexLimitReportLine } from './codex-commands.mjs'
|
|
56
56
|
import { withMcpSessionFactory } from './codex-mcp-http.mjs'
|
|
57
57
|
import { startStructuredSession } from './runtime-session.mjs'
|
|
58
|
+
import { claudeOneShot } from './claude-session.mjs'
|
|
59
|
+
import { fallbackTerminalName, suggestTerminalName } from './terminal-name.mjs'
|
|
58
60
|
import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredModeForSlice, structuredModeLocked, structuredModesForLane, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
59
61
|
import { commandCatalogForRuntime, commandHelpLine, reconcileCommandCatalog } from './command-catalog.mjs'
|
|
60
62
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
@@ -1160,6 +1162,9 @@ const replayPump = createLatestReplayPump({
|
|
|
1160
1162
|
// Persisted on the host so a rename is cross-device + survives a bridge restart;
|
|
1161
1163
|
// every announce carries them so a late-joining or second device sees them too.
|
|
1162
1164
|
const termNames = loadNames(room)
|
|
1165
|
+
const manualNameTouched = new Set()
|
|
1166
|
+
const autoNameAttempts = new Set()
|
|
1167
|
+
const autoNames = new Map()
|
|
1163
1168
|
// The SDK's supported-model LIST (value/displayName/description), captured from
|
|
1164
1169
|
// any session's `models` event. Announced room-level so EVERY device gets it on
|
|
1165
1170
|
// connect — not just the one that saw the one-shot event (the picker fell back to
|
|
@@ -1254,6 +1259,91 @@ const announce = () => {
|
|
|
1254
1259
|
],
|
|
1255
1260
|
}) }
|
|
1256
1261
|
|
|
1262
|
+
const uniqueTerminalName = (id, candidate) => {
|
|
1263
|
+
const used = new Set(Object.entries(termNames)
|
|
1264
|
+
.filter(([otherId]) => otherId !== id)
|
|
1265
|
+
.map(([, label]) => String(label).toLowerCase()))
|
|
1266
|
+
if (!used.has(candidate.toLowerCase())) return candidate
|
|
1267
|
+
for (let n = 2; n < 100; n++) {
|
|
1268
|
+
const suffix = ` ${n}`
|
|
1269
|
+
const next = `${candidate.slice(0, 48 - suffix.length).trim()}${suffix}`
|
|
1270
|
+
if (!used.has(next.toLowerCase())) return next
|
|
1271
|
+
}
|
|
1272
|
+
return candidate
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Best-effort row convergence: the bridge map updates the room immediately and
|
|
1276
|
+
// survives this host's restarts; the row carries the generated name to another host.
|
|
1277
|
+
const persistAutoTerminalName = async (id, label, previous, retry = true) => {
|
|
1278
|
+
if (!codeAuthToken || !id || !label) return
|
|
1279
|
+
try {
|
|
1280
|
+
// Compare-and-set: an in-flight generated write must never overwrite a
|
|
1281
|
+
// manual rename that won just before this request reached Postgres.
|
|
1282
|
+
const expected = previous
|
|
1283
|
+
? `&name=eq.${encodeURIComponent(previous)}`
|
|
1284
|
+
: `&or=${encodeURIComponent('(name.is.null,name.eq.Terminal)')}`
|
|
1285
|
+
const response = await fetch(`${SUPABASE_URL}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}${expected}&select=id`, {
|
|
1286
|
+
method: 'PATCH',
|
|
1287
|
+
headers: {
|
|
1288
|
+
apikey: SUPABASE_ANON,
|
|
1289
|
+
Authorization: `Bearer ${codeAuthToken}`,
|
|
1290
|
+
'Content-Type': 'application/json',
|
|
1291
|
+
Prefer: 'return=representation',
|
|
1292
|
+
},
|
|
1293
|
+
body: JSON.stringify({ name: label }),
|
|
1294
|
+
})
|
|
1295
|
+
const rows = response.ok ? await response.json().catch(() => []) : []
|
|
1296
|
+
// Bridge-created lanes can receive work before the browser inserts their row.
|
|
1297
|
+
if (retry && (!response.ok || !Array.isArray(rows) || rows.length === 0)) {
|
|
1298
|
+
const timer = setTimeout(() => { void persistAutoTerminalName(id, label, previous, false) }, 1500)
|
|
1299
|
+
timer.unref?.()
|
|
1300
|
+
}
|
|
1301
|
+
} catch {
|
|
1302
|
+
if (retry) {
|
|
1303
|
+
const timer = setTimeout(() => { void persistAutoTerminalName(id, label, previous, false) }, 1500)
|
|
1304
|
+
timer.unref?.()
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const applyAutoTerminalName = (id, candidate) => {
|
|
1310
|
+
if (!candidate || manualNameTouched.has(id)) return false
|
|
1311
|
+
const currentAuto = autoNames.get(id)
|
|
1312
|
+
if (termNames[id] && termNames[id] !== currentAuto) return false
|
|
1313
|
+
const previous = termNames[id] || null
|
|
1314
|
+
const label = uniqueTerminalName(id, candidate)
|
|
1315
|
+
if (termNames[id] === label) return true
|
|
1316
|
+
termNames[id] = label
|
|
1317
|
+
autoNames.set(id, label)
|
|
1318
|
+
saveNames(room, termNames)
|
|
1319
|
+
void persistAutoTerminalName(id, label, previous)
|
|
1320
|
+
announce()
|
|
1321
|
+
return true
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// First real task only. A zero-token title appears immediately. Built-in Claude
|
|
1325
|
+
// may refine it through the existing raw Haiku path; other runtimes stay local so
|
|
1326
|
+
// a Codex/Hermes/custom-provider prompt is never leaked across providers.
|
|
1327
|
+
const autoNameTerminal = (id, text) => {
|
|
1328
|
+
const entry = sessions.get(id)
|
|
1329
|
+
if (!entry || termNames[id] || manualNameTouched.has(id) || autoNameAttempts.has(id)) return
|
|
1330
|
+
if (entry.log?.some((event) => event?.kind === 'you')) return
|
|
1331
|
+
const fallback = fallbackTerminalName(text)
|
|
1332
|
+
if (!fallback) return
|
|
1333
|
+
autoNameAttempts.add(id)
|
|
1334
|
+
applyAutoTerminalName(id, fallback)
|
|
1335
|
+
const canUseHaiku = entry.runtime === 'claude'
|
|
1336
|
+
&& (!entry.provider || entry.provider === 'anthropic')
|
|
1337
|
+
&& hostMemoryAdmission('name this terminal').ok
|
|
1338
|
+
if (!canUseHaiku) return
|
|
1339
|
+
const context = `Repository: ${repoLabel}. Existing terminal names: ${Object.values(termNames).filter(Boolean).slice(-8).join(', ') || 'none'}.`
|
|
1340
|
+
void suggestTerminalName({
|
|
1341
|
+
text,
|
|
1342
|
+
context,
|
|
1343
|
+
generate: (prompt) => claudeOneShot({ prompt, cwd: entry.cwd || process.cwd(), env: process.env, timeoutMs: 8000 }),
|
|
1344
|
+
}).then((candidate) => applyAutoTerminalName(id, candidate))
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1257
1347
|
// (cross-person grantee yield removed 2026-07-06 — owner-only serving now; the owner's
|
|
1258
1348
|
// bridge never stands down. See docs/specs/2026-07-06-remove-cross-person-serve.md.)
|
|
1259
1349
|
|
|
@@ -1345,6 +1435,7 @@ async function receiveCrossRoomPost({ fromRoom, fromHost, fromTerminalName, text
|
|
|
1345
1435
|
te.roomHop = 1; te.hop = (te.hop || 0) + 1; te.peekCount = 0; te.postCount = 0; te.pairPeekCount = 0; te.crossRoomPostCount = 0
|
|
1346
1436
|
const msg = `[From: room ${fromLabel} — relayed via the ThinkPool cross-room Ensemble, approved by a person in this room]\n${body}`
|
|
1347
1437
|
const evt = { kind: 'you', text: msg, by: `room ${fromRoom}`, crosspost: true, relaySourceName: String(fromTerminalName || '').trim().slice(0, 80) || undefined }
|
|
1438
|
+
autoNameTerminal(targetId, body)
|
|
1348
1439
|
stampEvent(evt); pushLog(te, evt); bcast('code-event', { term: targetId, evt })
|
|
1349
1440
|
try { if (te.session.sendTurn(msg) === false) return { error: `Could not deliver to room ${room} — the lane refused the turn (check its visible host-memory/runtime error).` } } catch { return { error: `Could not deliver to room ${room} — the lane may have just closed.` } }
|
|
1350
1441
|
return { ok: true, ref: String(targetId).slice(0, 8) }
|
|
@@ -2577,6 +2668,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2577
2668
|
// Echo the injected prompt into the TARGET lane so both people see what
|
|
2578
2669
|
// arrived (rides the existing code-event 'you' path — no new topic).
|
|
2579
2670
|
const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
|
|
2671
|
+
autoNameTerminal(target.id, args.text)
|
|
2580
2672
|
stampEvent(evt)
|
|
2581
2673
|
pushLog(te, evt)
|
|
2582
2674
|
bcast('code-event', { term: target.id, evt })
|
|
@@ -2630,6 +2722,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2630
2722
|
announce()
|
|
2631
2723
|
const msg = `[Task from main terminal ${fromRef}'s agent — opened as an independent MAIN CASCADE CONDUCTOR terminal, not an Ensemble child]\n${String(args.task).trim()}`
|
|
2632
2724
|
const evt = { kind: 'you', text: msg, by: `main terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
|
|
2725
|
+
autoNameTerminal(newId, args.task)
|
|
2633
2726
|
stampEvent(evt); pushLog(conductor, evt); bcast('code-event', { term: newId, evt })
|
|
2634
2727
|
try { if (conductor.session.sendTurn(msg) === false) return okText(`Opened main conductor ${newRef}, but host pressure prevented its runtime from starting. Existing lanes remain connected; free memory and retry the task.`) }
|
|
2635
2728
|
catch { return okText(`Opened main conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — the initial task could not be delivered.`) }
|
|
@@ -2749,6 +2842,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2749
2842
|
: ''
|
|
2750
2843
|
const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool Ensemble; you are its WORKER SUB-TERMINAL, never a main terminal or Cascade conductor]\n${args.task}${reviewTarget}`
|
|
2751
2844
|
const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
|
|
2845
|
+
autoNameTerminal(newId, args.task)
|
|
2752
2846
|
stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
|
|
2753
2847
|
try { if (ne.session.sendTurn(msg) === false) return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but host pressure prevented its runtime from accepting the task. Existing lanes remain connected; free memory and retry.`) } catch { return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`) }
|
|
2754
2848
|
return okText(`Opened agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It runs in its own lane — check back with read_terminal, then close_terminal when done.`)
|
|
@@ -4241,6 +4335,7 @@ channel
|
|
|
4241
4335
|
// Sample the runtime before dispatch so a missed prior falling edge cannot
|
|
4242
4336
|
// make this genuinely new turn inherit the previous turn's revision.
|
|
4243
4337
|
syncStructuredTurn(s)
|
|
4338
|
+
autoNameTerminal(payload.term, payload.body != null ? String(payload.body) : text)
|
|
4244
4339
|
const accepted = s.session.sendTurn(sendText, Object.keys(turnOptions).length ? turnOptions : undefined)
|
|
4245
4340
|
if (accepted === false) syncStructuredTurn(s)
|
|
4246
4341
|
else beginStructuredTurn(s)
|
|
@@ -4410,6 +4505,8 @@ channel
|
|
|
4410
4505
|
// LATER (or a second machine) — those only ever see the announce.
|
|
4411
4506
|
.on('broadcast', { event: 'term-rename' }, ({ payload }) => {
|
|
4412
4507
|
if (!payload?.id) return
|
|
4508
|
+
manualNameTouched.add(payload.id)
|
|
4509
|
+
autoNames.delete(payload.id)
|
|
4413
4510
|
if (payload.name) termNames[payload.id] = String(payload.name).slice(0, 80)
|
|
4414
4511
|
else delete termNames[payload.id]
|
|
4415
4512
|
saveNames(room, termNames)
|
package/claude-session.mjs
CHANGED
|
@@ -27,10 +27,42 @@ import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
|
|
|
27
27
|
|
|
28
28
|
// The caret-pulled SDK's real version (^0.3.x auto-upgrades on restart). Resolved
|
|
29
29
|
// once at import by walking up from the package entry to its own package.json.
|
|
30
|
+
const req = createRequire(import.meta.url)
|
|
31
|
+
|
|
32
|
+
// Shared raw one-shot for tiny bridge-owned inference jobs. No settings, skills,
|
|
33
|
+
// MCP servers, or repo instructions are loaded; callers provide the model + prompt.
|
|
34
|
+
export async function claudeOneShot({ prompt, model = 'claude-haiku-4-5', cwd, env, timeoutMs = 8000 } = {}) {
|
|
35
|
+
const abortController = new AbortController()
|
|
36
|
+
const timer = setTimeout(() => { try { abortController.abort() } catch { /* noop */ } }, timeoutMs)
|
|
37
|
+
try {
|
|
38
|
+
const result = query({
|
|
39
|
+
prompt,
|
|
40
|
+
options: {
|
|
41
|
+
model,
|
|
42
|
+
...(cwd ? { cwd } : {}),
|
|
43
|
+
env,
|
|
44
|
+
maxTurns: 1,
|
|
45
|
+
permissionMode: 'bypassPermissions',
|
|
46
|
+
settingSources: [],
|
|
47
|
+
strictMcpConfig: true,
|
|
48
|
+
mcpServers: {},
|
|
49
|
+
abortController,
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
let out = ''
|
|
53
|
+
for await (const message of result) {
|
|
54
|
+
if (message.type === 'assistant') {
|
|
55
|
+
for (const block of (message.message?.content || [])) if (block.type === 'text') out += block.text
|
|
56
|
+
}
|
|
57
|
+
if (message.type === 'result') break
|
|
58
|
+
}
|
|
59
|
+
return out.trim()
|
|
60
|
+
} finally { clearTimeout(timer) }
|
|
61
|
+
}
|
|
62
|
+
|
|
30
63
|
// Named in the [SDK-REGRESSION] guard below so a silent gate-change is attributable.
|
|
31
64
|
const SDK_VERSION = (() => {
|
|
32
65
|
try {
|
|
33
|
-
const req = createRequire(import.meta.url)
|
|
34
66
|
let d = dirname(req.resolve('@anthropic-ai/claude-agent-sdk'))
|
|
35
67
|
for (let i = 0; i < 8; i++) {
|
|
36
68
|
try { const p = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')); if (p.name === '@anthropic-ai/claude-agent-sdk') return p.version } catch { /* keep walking */ }
|
|
@@ -788,35 +820,17 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
788
820
|
if (!suggest) return
|
|
789
821
|
const seed = (lastAssistantText || '').trim().slice(-1500)
|
|
790
822
|
if (!seed || closed) return
|
|
791
|
-
const ac2 = new AbortController()
|
|
792
|
-
const t = setTimeout(() => { try { ac2.abort() } catch { /* noop */ } }, 8000)
|
|
793
823
|
try {
|
|
794
|
-
|
|
824
|
+
let out = await claudeOneShot({
|
|
795
825
|
prompt: `You are predicting the user's NEXT chat message in a live coding session, to prefill their composer. Given the assistant's latest reply below, output the single most likely next user message — short and natural (often just "proceed", "go with option A", "yes do that", or a brief follow-up). One line, <=12 words, imperative, no preamble, no quotes, no markdown.\n\nAssistant's latest reply:\n"""\n${seed}\n"""\n\nNext user message:`,
|
|
796
|
-
|
|
797
|
-
model: 'claude-haiku-4-5',
|
|
798
|
-
...(cwd ? { cwd } : {}),
|
|
799
|
-
env: opts.env,
|
|
800
|
-
maxTurns: 1,
|
|
801
|
-
permissionMode: 'bypassPermissions',
|
|
802
|
-
settingSources: [], // no CLAUDE.md / commands / agents — raw call
|
|
803
|
-
strictMcpConfig: true,
|
|
804
|
-
mcpServers: {}, // no MCP servers — fast cold start
|
|
805
|
-
abortController: ac2,
|
|
806
|
-
},
|
|
826
|
+
model: 'claude-haiku-4-5', cwd, env: opts.env, timeoutMs: 8000,
|
|
807
827
|
})
|
|
808
|
-
let out = ''
|
|
809
|
-
for await (const mm of hq) {
|
|
810
|
-
if (mm.type === 'assistant') for (const b of (mm.message?.content || [])) if (b.type === 'text') out += b.text
|
|
811
|
-
if (mm.type === 'result') break
|
|
812
|
-
}
|
|
813
828
|
out = out.trim().split('\n')[0].replace(/^["'`]+|["'`]+$/g, '').trim().slice(0, 140)
|
|
814
829
|
if (out && !sawSuggestion && !closed && !/^(sure|of course|certainly|let me know|i can help|happy to)\b/i.test(out)) {
|
|
815
830
|
emit({ kind: 'suggestion', text: out, source: 'haiku' })
|
|
816
831
|
process.stderr.write(` ◆ [suggestion] haiku fallback: ${JSON.stringify(out)}\n`)
|
|
817
832
|
}
|
|
818
833
|
} catch { /* fallback failed (rate limit / abort / model error) — silent */ }
|
|
819
|
-
finally { clearTimeout(t) }
|
|
820
834
|
}
|
|
821
835
|
|
|
822
836
|
const admitColdStart = () => {
|
package/cross-terminal.mjs
CHANGED
|
@@ -266,7 +266,7 @@ export const nativeClaudeProviderAccessFailed = (events) => {
|
|
|
266
266
|
let text = ''
|
|
267
267
|
try { text = typeof events === 'string' ? events : JSON.stringify(events || '') }
|
|
268
268
|
catch { text = String(events || '') }
|
|
269
|
-
return /organization has disabled (?:claude )
|
|
269
|
+
return /organization has disabled (?:claude(?: subscription access)?|subscription access)|(?:claude )?subscription access (?:has been |is )?disabled|invalid anthropic (?:api )?key|invalid x-api-key|not logged in to claude|(?:run|use) \/login[^\n]{0,80}claude|no valid anthropic (?:api )?key/i.test(text)
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
const HERMES_CLAUDE_REVIEW_PREFERENCE = Object.freeze([
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.312",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"byok-detect.mjs",
|
|
19
19
|
"context-windows.mjs",
|
|
20
20
|
"claude-session.mjs",
|
|
21
|
+
"terminal-name.mjs",
|
|
21
22
|
"claude-command-catalog.mjs",
|
|
22
23
|
"codex-session.mjs",
|
|
23
24
|
"question-response.mjs",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { nativeClaudeProviderAccessFailed } from './cross-terminal.mjs'
|
|
2
|
+
|
|
3
|
+
const SKIP = new Set([
|
|
4
|
+
'a', 'an', 'and', 'are', 'at', 'be', 'can', 'could', 'for', 'from', 'how', 'i',
|
|
5
|
+
'in', 'is', 'it', 'just', 'like', 'maybe', 'me', 'my', 'of', 'on', 'or', 'our',
|
|
6
|
+
'please', 'something', 'that', 'the', 'this', 'to', 'we', 'with', 'would', 'you',
|
|
7
|
+
])
|
|
8
|
+
|
|
9
|
+
const GENERIC = /^(?:new )?(?:agent |coding )?(?:terminal|task|lane|session|work)$/i
|
|
10
|
+
|
|
11
|
+
export function cleanTerminalName(value) {
|
|
12
|
+
let name = String(value || '')
|
|
13
|
+
.trim()
|
|
14
|
+
.split(/\r?\n/, 1)[0]
|
|
15
|
+
.replace(/^\s*(?:[-*#>]+|title\s*:?)\s*/i, '')
|
|
16
|
+
.replace(/^["'`]+|["'`]+$/g, '')
|
|
17
|
+
.replace(/[^\p{L}\p{N}+#&.' -]+/gu, ' ')
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim()
|
|
20
|
+
if (!name || GENERIC.test(name) || nativeClaudeProviderAccessFailed(name)) return null
|
|
21
|
+
if (/^(?:error|failed|sorry|unable|i (?:cannot|can't)|rate limit|request failed)\b/i.test(name)) return null
|
|
22
|
+
if (name.length > 48) name = name.slice(0, 48).replace(/\s+\S*$/, '').trim()
|
|
23
|
+
return name || null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function fallbackTerminalName(text) {
|
|
27
|
+
const body = String(text || '')
|
|
28
|
+
.replace(/^\s*\[[^\]\n]{1,240}\]\s*/g, '')
|
|
29
|
+
.replace(/^\s*(?:hey|hi|okay|ok|so)\b[,:!]?\s*/i, '')
|
|
30
|
+
.replace(/^\s*(?:can|could|would|will)\s+you\s+/i, '')
|
|
31
|
+
.replace(/^\s*(?:how about|i (?:do not|don't) know|i guess)\s+/i, '')
|
|
32
|
+
.replace(/[`*_>#()[\]{}]/g, ' ')
|
|
33
|
+
.slice(0, 600)
|
|
34
|
+
const words = body.match(/[\p{L}\p{N}][\p{L}\p{N}+#.'-]*/gu) || []
|
|
35
|
+
const useful = words.filter((word) => !SKIP.has(word.toLowerCase()) && !/^https?$/i.test(word))
|
|
36
|
+
if (!useful.length) return null
|
|
37
|
+
const title = useful.slice(0, 5).map((word) => {
|
|
38
|
+
if (/^[A-Z\d+#.-]{2,}$/.test(word) || /\d/.test(word)) return word
|
|
39
|
+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
|
40
|
+
}).join(' ')
|
|
41
|
+
return cleanTerminalName(title)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function suggestTerminalName({ text, context = '', generate } = {}) {
|
|
45
|
+
const fallback = fallbackTerminalName(text)
|
|
46
|
+
if (!fallback || typeof generate !== 'function') return fallback
|
|
47
|
+
const prompt = [
|
|
48
|
+
'Name one terminal lane from its first task. Output only a distinctive 2-5 word title, title case, at most 40 characters. Describe the concrete work, not the person or model. Never output “Terminal”, “Task”, “Session”, or a numbered label.',
|
|
49
|
+
context ? `Room context: ${String(context).slice(0, 500)}` : '',
|
|
50
|
+
`First task:\n${String(text || '').slice(0, 1600)}`,
|
|
51
|
+
].filter(Boolean).join('\n\n')
|
|
52
|
+
try { return cleanTerminalName(await generate(prompt)) || fallback }
|
|
53
|
+
catch { return fallback }
|
|
54
|
+
}
|