thinkpool-pair 0.7.334 → 0.7.336
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/account.mjs +17 -1
- package/bridge.mjs +7 -2
- package/hermes-model-cache.mjs +54 -0
- package/package.json +3 -1
- package/terminal-name.mjs +74 -19
package/account.mjs
CHANGED
|
@@ -526,6 +526,11 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
526
526
|
// never impersonate a completed restart.
|
|
527
527
|
const BRIDGE_ID = randomUUID()
|
|
528
528
|
let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
|
|
529
|
+
// Account-control handlers are registered before the discovery loop is
|
|
530
|
+
// initialized. Route early requests through a harmless placeholder, then
|
|
531
|
+
// replace it once `tick` exists; this avoids a temporal-dead-zone crash during
|
|
532
|
+
// supervisor bootstrap while still letting a live web room request discovery.
|
|
533
|
+
let requestDiscoveryTick = () => {}
|
|
529
534
|
// Web "attach this session" card → bind an unbound/new room to a local dir (and
|
|
530
535
|
// optionally set it as the account default) so it serves WITHOUT a terminal command.
|
|
531
536
|
// Rides the private, owner-gated control topic; the dir must exist on THIS machine.
|
|
@@ -538,7 +543,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
538
543
|
bindDir(code, dir)
|
|
539
544
|
if (payload?.setDefault) { saveDefaultDir(dir); process.stderr.write(`\n ◆ default project dir set → ${dir}\n`) }
|
|
540
545
|
process.stderr.write(`\n ◆ ${code} bound → ${dir} (from the web). Serving…\n`)
|
|
541
|
-
|
|
546
|
+
requestDiscoveryTick()
|
|
547
|
+
})
|
|
548
|
+
// A newly-created web room already knows its owner and has an authenticated,
|
|
549
|
+
// owner-private account-control channel. Nudge discovery immediately instead
|
|
550
|
+
// of waiting for the supervisor's 15-second reconciliation interval. `tick`
|
|
551
|
+
// still re-queries owner-owned active rooms, so the browser cannot make this
|
|
552
|
+
// host serve an arbitrary code.
|
|
553
|
+
acctControl.on('broadcast', { event: 'serve-room' }, ({ payload }) => {
|
|
554
|
+
const code = String(payload?.code || '').toUpperCase().trim()
|
|
555
|
+
if (!/^[A-Z0-9]{4,12}$/.test(code)) return
|
|
556
|
+
requestDiscoveryTick()
|
|
542
557
|
})
|
|
543
558
|
// Dashboard "Disconnect" → broadcast shutdown → clean exit (presence leaves, bar flips live).
|
|
544
559
|
// If we're the auto-restarting background service (launchd KeepAlive / systemd
|
|
@@ -906,6 +921,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
906
921
|
tickingSince = now
|
|
907
922
|
try { await tickBody() } finally { if (tickingSince === now) tickingSince = 0 }
|
|
908
923
|
}
|
|
924
|
+
requestDiscoveryTick = () => { void tick().catch(() => {}) }
|
|
909
925
|
// Expose for the watchdog to force-unwedge the guard after a reconnect.
|
|
910
926
|
const clearTickGuard = () => { tickingSince = 0 }
|
|
911
927
|
const tickBody = async () => {
|
package/bridge.mjs
CHANGED
|
@@ -130,6 +130,7 @@ import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
|
130
130
|
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, dispatchSideContexts, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
131
131
|
import { planMeterLine } from './plan-meters.mjs'
|
|
132
132
|
import { priceForModel } from './model-prices.mjs'
|
|
133
|
+
import { loadHermesModelCache, saveHermesModelCache } from './hermes-model-cache.mjs'
|
|
133
134
|
import { makeThrottledTrack, recoveryBackoffMs } from './presence.mjs'
|
|
134
135
|
import { MockupDeliveryQueue, completeMockupManifest, isMockupDeliveryBoundary } from './mockup-delivery.mjs'
|
|
135
136
|
import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
|
|
@@ -140,6 +141,10 @@ import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE, THINKPOOL
|
|
|
140
141
|
const SUPABASE_URL = process.env.TP_SUPABASE_URL || DEFAULT_SUPABASE_URL
|
|
141
142
|
const WEB_BASE = process.env.TP_WEB_BASE || 'https://thinkpool.io'
|
|
142
143
|
const IMAGE_QUEUE_CONFIG = imageQueueConfig()
|
|
144
|
+
// Shared by every Hermes lane in this room child and persisted across room
|
|
145
|
+
// children. Without this, each new room must boot ACP (up to 30s) merely to
|
|
146
|
+
// discover the same public model list before its first real turn.
|
|
147
|
+
let hostHermesModels = loadHermesModelCache()
|
|
143
148
|
|
|
144
149
|
// The anon key is RESOLVED, not baked. It used to be a string literal here, and
|
|
145
150
|
// that literal now sits in 224 published tarballs on 224 users' laptops: disable
|
|
@@ -2139,7 +2144,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2139
2144
|
? spawnDepth
|
|
2140
2145
|
: (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
|
|
2141
2146
|
const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
|
|
2142
|
-
const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes'
|
|
2147
|
+
const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
|
|
2143
2148
|
// model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
|
|
2144
2149
|
// chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
|
|
2145
2150
|
// registered provider the SDK id is impersonated (see the onEvent guard below), so
|
|
@@ -3311,7 +3316,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3311
3316
|
// A custom Anthropic-compatible endpoint may expose its own catalog through the
|
|
3312
3317
|
// same SDK method. Never publish that as the ROOM's Claude catalog: its models
|
|
3313
3318
|
// already render in the provider group and otherwise leak under ANTHROPIC.
|
|
3314
|
-
if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = evt.models; announce() }
|
|
3319
|
+
if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = saveHermesModelCache(evt.models); hostHermesModels = entry.models; announce() }
|
|
3315
3320
|
else if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
|
|
3316
3321
|
// Hermes model changes are asynchronous ACP requests. Keep the previous model
|
|
3317
3322
|
// authoritative until Hermes acknowledges session/set_model; then settle the
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Hermes exposes its model catalog only after `session/new`, which may take up
|
|
2
|
+
// to 30 seconds while the ACP process boots. The catalog is public metadata and
|
|
3
|
+
// identical for every room served by this Hermes profile, so keep the last
|
|
4
|
+
// validated copy at the host level. A live Hermes session always replaces it.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
|
|
10
|
+
const MAX_MODELS = 1000
|
|
11
|
+
const MAX_BYTES = 512 * 1024
|
|
12
|
+
const MAX_FIELD = 512
|
|
13
|
+
|
|
14
|
+
const cachePath = () => process.env.TP_HERMES_MODEL_CACHE
|
|
15
|
+
|| path.join(process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair'), 'hermes-models.json')
|
|
16
|
+
|
|
17
|
+
export function normalizeHermesModelCache(models) {
|
|
18
|
+
if (!Array.isArray(models)) return []
|
|
19
|
+
const seen = new Set()
|
|
20
|
+
const normalized = []
|
|
21
|
+
for (const raw of models.slice(0, MAX_MODELS)) {
|
|
22
|
+
const value = String(raw?.value || '').trim().slice(0, MAX_FIELD)
|
|
23
|
+
if (!value || seen.has(value)) continue
|
|
24
|
+
seen.add(value)
|
|
25
|
+
normalized.push({
|
|
26
|
+
value,
|
|
27
|
+
displayName: String(raw?.displayName || value).trim().slice(0, MAX_FIELD) || value,
|
|
28
|
+
description: String(raw?.description || '').trim().slice(0, MAX_FIELD),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
return normalized
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadHermesModelCache() {
|
|
35
|
+
try {
|
|
36
|
+
const file = cachePath()
|
|
37
|
+
if ((Number(fs.statSync(file).size) || 0) > MAX_BYTES) return []
|
|
38
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
39
|
+
return normalizeHermesModelCache(parsed?.models)
|
|
40
|
+
} catch { return [] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function saveHermesModelCache(models) {
|
|
44
|
+
const normalized = normalizeHermesModelCache(models)
|
|
45
|
+
if (!normalized.length) return []
|
|
46
|
+
try {
|
|
47
|
+
const file = cachePath()
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
49
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
50
|
+
fs.writeFileSync(temp, JSON.stringify({ savedAt: Date.now(), models: normalized }), { mode: 0o600 })
|
|
51
|
+
fs.renameSync(temp, file)
|
|
52
|
+
} catch { /* cache is opportunistic; live discovery remains the fallback */ }
|
|
53
|
+
return normalized
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.336",
|
|
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": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"codex-commands.mjs",
|
|
37
37
|
"acp-client.mjs",
|
|
38
38
|
"hermes-session.mjs",
|
|
39
|
+
"hermes-model-cache.mjs",
|
|
39
40
|
"hermes-policy.mjs",
|
|
40
41
|
"hermes-acp-bootstrap.py",
|
|
41
42
|
"hermes-event-mapper.mjs",
|
|
@@ -113,6 +114,7 @@
|
|
|
113
114
|
"@anthropic-ai/claude-agent-sdk": "^0.3.198",
|
|
114
115
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
115
116
|
"@supabase/supabase-js": "^2.45.0",
|
|
117
|
+
"compromise": "14.16.0",
|
|
116
118
|
"yaml": "2.9.0",
|
|
117
119
|
"zod": "^4.4.3"
|
|
118
120
|
},
|
package/terminal-name.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { nativeClaudeProviderAccessFailed } from './cross-terminal.mjs'
|
|
2
|
+
import nlp from 'compromise/two'
|
|
2
3
|
|
|
3
4
|
const SKIP = new Set([
|
|
4
5
|
'a', 'an', 'and', 'are', 'at', 'be', 'can', 'could', 'for', 'from', 'how', 'i',
|
|
@@ -18,12 +19,66 @@ const NOISE = /^(?:context|background|for reference|here(?:'s| is)|note|current(
|
|
|
18
19
|
const EXPLANATION = /^(?:because|cause|since|so that|this is because)\b/i
|
|
19
20
|
const CONSTRAINT = /^(?:users? can still|keep|must|never|should|without)\b/i
|
|
20
21
|
const DEPENDENT_FRAGMENT = /^(?:after|before|by|during|for|from|in|instead(?: of)?|on|through|until|with|without)\b/i
|
|
21
|
-
const INFORMATION_REQUEST = /^(?:which|what|who|where|when|why|how)\b/i
|
|
22
|
-
const NEGATIVE_PREFERENCE = /^(?:(?:i|we)\s+)?(?:do not|don't|dont|would not|wouldn't|won't|wont)\s+(?:(?:want|wanna|need)(?:\s+to)?|use|include|choose)\b|^(?:(?:i|we)\s+)?(?:want|wanna|need)(?:\s+to)?\s+avoid\b/i
|
|
23
22
|
const ISSUE = /\b(?:broken|buggy|crash(?:es|ed|ing)?|duplicate|error|fail(?:s|ed|ing|ure)?|flash(?:es|ed|ing)?|missing|no animation|not working|out of (?:scrollable )?view|stuck|wrong)\b/i
|
|
24
23
|
const REQUEST = /^(?:please\s+)?(?:can|could|would|will)\s+(?:we|you)\b|^(?:please\s+)?(?:how about|let's|let us|we need to|i want you to)\b/i
|
|
25
24
|
const SECRET = /(?<![\p{L}\p{N}])(?:sk-(?:proj-)?[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|xox[baprs]-[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9_-]{8,}|glpat-[a-z0-9_-]{8,}|npm_[a-z0-9]{24,}|(?:sk|rk)_(?:live|test)_[a-z0-9]{8,}|whsec_[a-z0-9]{8,}|AIza[a-z0-9_-]{8,}|AKIA[A-Z0-9]{12,}|bearer\s+[a-z0-9._-]{8,}|eyJ[a-z0-9_-]{8,}\.[a-z0-9_-]{8,}\.[a-z0-9_-]{8,})(?![\p{L}\p{N}])/giu
|
|
26
25
|
const HANDOFF_MARKER = /^---\s*(?:the person's next message|current person request(?:\s*\([^\n]*\))?|side task)\s*---\s*$/gim
|
|
26
|
+
const QUESTION_WORDS = new Set(['how', 'what', 'when', 'where', 'which', 'who', 'whom', 'whose', 'why'])
|
|
27
|
+
const PREFERENCE_WORDS = new Set(['avoid', 'choose', 'include', 'need', 'use', 'want'])
|
|
28
|
+
const PREFERENCE_OBJECT_WORDS = new Set(['avoid', 'choose', 'include', 'use'])
|
|
29
|
+
|
|
30
|
+
const conceptsForTerm = (term) => new Set(
|
|
31
|
+
[term?.text, term?.normal, term?.machine, term?.implicit]
|
|
32
|
+
.map((value) => String(value || '').trim().toLowerCase())
|
|
33
|
+
.filter(Boolean),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const analyzeGrammar = (value, suppliedTerms = null) => {
|
|
37
|
+
const text = String(value || '').trim()
|
|
38
|
+
const terms = suppliedTerms || nlp(text).json({ terms: true }).flatMap((sentence) => sentence.terms || [])
|
|
39
|
+
const visible = terms
|
|
40
|
+
.filter((term) => String(term?.text || '').trim())
|
|
41
|
+
.map((term) => ({ text: term.text, concepts: conceptsForTerm(term), tags: new Set(term.tags || []) }))
|
|
42
|
+
const hasQuestionWord = visible.some((term) => [...term.concepts].some((word) => QUESTION_WORDS.has(word)))
|
|
43
|
+
const taggedQuestionWord = visible.some((term) => term.tags.has('QuestionWord'))
|
|
44
|
+
const negative = terms.some((term) => Array.isArray(term?.tags) && term.tags.includes('Negative'))
|
|
45
|
+
const preference = terms.some((term) => [...conceptsForTerm(term)].some((word) => PREFERENCE_WORDS.has(word)))
|
|
46
|
+
return {
|
|
47
|
+
text,
|
|
48
|
+
visible,
|
|
49
|
+
informationRequest: hasQuestionWord && (taggedQuestionWord || /\?\s*$/.test(text)),
|
|
50
|
+
negativePreference: negative && preference,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const parsedClauses = (value) => String(value || '')
|
|
55
|
+
.replace(/,\s*(?:but|however)\s+/gi, '\n')
|
|
56
|
+
.split(/\n+|;\s*/)
|
|
57
|
+
.flatMap((part) => {
|
|
58
|
+
const text = part.trim()
|
|
59
|
+
if (!text) return []
|
|
60
|
+
const sentences = nlp(text).json({ terms: true })
|
|
61
|
+
return sentences.length
|
|
62
|
+
? sentences.map((sentence) => analyzeGrammar(sentence.text, sentence.terms || []))
|
|
63
|
+
: [analyzeGrammar(text)]
|
|
64
|
+
})
|
|
65
|
+
.filter((clause) => clause.text)
|
|
66
|
+
|
|
67
|
+
const questionSubject = (analysis) => {
|
|
68
|
+
const index = analysis.visible.findIndex((term) => [...term.concepts].some((word) => QUESTION_WORDS.has(word)))
|
|
69
|
+
return index >= 0 ? analysis.visible.slice(index + 1).map((term) => term.text).join(' ') : analysis.text
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const negativePreferenceSubject = (analysis) => {
|
|
73
|
+
let index = -1
|
|
74
|
+
for (let i = 0; i < analysis.visible.length; i++) {
|
|
75
|
+
if ([...analysis.visible[i].concepts].some((word) => PREFERENCE_OBJECT_WORDS.has(word))) index = i
|
|
76
|
+
}
|
|
77
|
+
if (index < 0) {
|
|
78
|
+
index = analysis.visible.findIndex((term) => [...term.concepts].some((word) => word === 'need' || word === 'want'))
|
|
79
|
+
}
|
|
80
|
+
return index >= 0 ? analysis.visible.slice(index + 1).map((term) => term.text).join(' ') : analysis.text
|
|
81
|
+
}
|
|
27
82
|
|
|
28
83
|
const ACTIONS = Object.freeze([
|
|
29
84
|
{ title: 'Fix', score: 100, re: /\b(?:fix|fixes|fixed|fixing|repair|repairs|repaired|repairing)\b/i },
|
|
@@ -117,35 +172,31 @@ const explicitIntent = (text) => {
|
|
|
117
172
|
}
|
|
118
173
|
|
|
119
174
|
const bestIntentClause = (text) => {
|
|
120
|
-
const clauses = text
|
|
121
|
-
.replace(/,\s*(?:but|however)\s+/gi, '\n')
|
|
122
|
-
.split(/\n+|(?<=[.!?;])\s+/)
|
|
123
|
-
.map((part) => part.trim())
|
|
124
|
-
.filter(Boolean)
|
|
175
|
+
const clauses = parsedClauses(text)
|
|
125
176
|
let best = null
|
|
126
|
-
for (
|
|
127
|
-
const clause =
|
|
177
|
+
for (const parsed of clauses) {
|
|
178
|
+
const clause = parsed.text.replace(/^[-*#>\d.)\s]+/, '').trim()
|
|
128
179
|
if (!clause || /^(?:https?:\/\/|\/|[A-Za-z]:\\)/.test(clause)) continue
|
|
129
|
-
const
|
|
180
|
+
const grammar = clause === parsed.text ? parsed : analyzeGrammar(clause)
|
|
130
181
|
const action = bestAction(clause)
|
|
131
182
|
let score = action?.score || 0
|
|
132
183
|
if (REQUEST.test(clause)) score += 18
|
|
133
|
-
if (
|
|
184
|
+
if (grammar.informationRequest) score += 36
|
|
134
185
|
if (ISSUE.test(clause)) score += /\b(?:crash|fail|flash|stuck|wrong)\w*\b/i.test(clause) ? 36 : 26
|
|
135
|
-
if (
|
|
136
|
-
else if (
|
|
186
|
+
if (grammar.visible.length >= 3 && grammar.visible.length <= 24) score += 10
|
|
187
|
+
else if (grammar.visible.length > 40) score -= 14
|
|
137
188
|
if (NOISE.test(clause)) score -= 30
|
|
138
189
|
if (EXPLANATION.test(clause)) score -= 34
|
|
139
190
|
if (CONSTRAINT.test(clause)) score -= 45
|
|
140
|
-
if (
|
|
191
|
+
if (grammar.negativePreference) score -= 70
|
|
141
192
|
// A short dependent tail such as "From ground up" or "On mobile" adds
|
|
142
193
|
// scope to the preceding request; it is not a standalone task. Recency is
|
|
143
194
|
// deliberately not a signal here: the title should represent the main
|
|
144
195
|
// theme, not whichever sentence happened to come last.
|
|
145
|
-
if (!action && !REQUEST.test(clause) && !
|
|
196
|
+
if (!action && !REQUEST.test(clause) && !grammar.informationRequest && DEPENDENT_FRAGMENT.test(clause)) score -= 24
|
|
146
197
|
if (!best || score > best.score) best = { clause, score }
|
|
147
198
|
}
|
|
148
|
-
return best?.clause || clauses[0] || null
|
|
199
|
+
return best?.clause || clauses[0]?.text || null
|
|
149
200
|
}
|
|
150
201
|
|
|
151
202
|
export function extractTerminalTask(value) {
|
|
@@ -210,9 +261,13 @@ const taskTitle = (value) => {
|
|
|
210
261
|
.replace(/^\s*(?:how about|i (?:do not|don't) know|i guess|i want you to|we need to|your task is to|let(?:'s| us))\s+/i, '')
|
|
211
262
|
.trim()
|
|
212
263
|
if (!body) return null
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
264
|
+
const grammar = analyzeGrammar(body)
|
|
265
|
+
if (grammar.negativePreference) {
|
|
266
|
+
const subject = contentWords(negativePreferenceSubject(grammar)).slice(0, 4)
|
|
267
|
+
return subject.length ? cleanTerminalName(['Avoid', ...subject.map(titleWord)].join(' ')) : null
|
|
268
|
+
}
|
|
269
|
+
const informationRequest = grammar.informationRequest
|
|
270
|
+
if (informationRequest) body = questionSubject(grammar).trim()
|
|
216
271
|
const signalText = body
|
|
217
272
|
body = body
|
|
218
273
|
.replace(/\s*,?\s+(?:so (?:that|you|we|it)\b|because\b|cause\b|if you know what i mean\b)[\s\S]*$/i, '')
|