@tiens.nguyen/gu-cli 1.0.817 → 1.0.820
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/gonext_agent_chat.py +16 -23
- package/gu-repl.mjs +75 -12
- package/package.json +2 -1
- package/terminal-title.mjs +165 -0
package/gonext_agent_chat.py
CHANGED
|
@@ -10556,11 +10556,18 @@ def run_agent_chat(cfg):
|
|
|
10556
10556
|
"run /context-window <tokens> to use more")})
|
|
10557
10557
|
|
|
10558
10558
|
# Task #90 Phase 3 — completion gate (code-enforced, complements the prompt). Fires at
|
|
10559
|
-
# most ONCE per turn: refuse the first final_answer that
|
|
10560
|
-
#
|
|
10561
|
-
# a verify-or-explain nudge fed back to the model (smolagents final_answer_checks: a
|
|
10559
|
+
# most ONCE per turn: refuse the first final_answer that hands the task back to the user,
|
|
10560
|
+
# with a verify-or-explain nudge fed back to the model (smolagents final_answer_checks: a
|
|
10562
10561
|
# raised message becomes the check-failure the model sees and retries on). After one
|
|
10563
10562
|
# nudge it ACCEPTS unconditionally — no loop; an honest "here's what failed" is valid.
|
|
10563
|
+
#
|
|
10564
|
+
# IT NO LONGER FIRES ON "THE LAST COMMAND EXITED 0" (GitHub #136). That trigger read the
|
|
10565
|
+
# truthful-exit label every successful run_command carries as proof of unverified work, so
|
|
10566
|
+
# it rejected the answer of any turn that ENDED on a command — measured over 296 real
|
|
10567
|
+
# turns: 140 rejections (47% of turns), at least 65 of them straight after the test run or
|
|
10568
|
+
# read-only check that WAS the verification, the rest mostly after a commit or a posted
|
|
10569
|
+
# comment. Each cost a ritual re-check plus a rewritten answer: 400 steps, 11.6% of all
|
|
10570
|
+
# input tokens. The label still sits in the Observation, where the model reads it.
|
|
10564
10571
|
_completion_gate: dict = {"nudged": False}
|
|
10565
10572
|
# Punt phrases: handing the CORE task back ("…start it yourself", "you'll need to run").
|
|
10566
10573
|
# Deliberately narrow so a helpful tip ("you can also run npm test") does NOT trigger it.
|
|
@@ -10586,31 +10593,17 @@ def run_agent_chat(cfg):
|
|
|
10586
10593
|
|
|
10587
10594
|
def _final_answer_gate(final_answer, memory, agent=None):
|
|
10588
10595
|
"""smolagents final_answer_check. True = accept; raising = reject with the message
|
|
10589
|
-
the model then sees. Only nudges when
|
|
10596
|
+
the model then sees. Only nudges when the answer hands the job back to the user."""
|
|
10590
10597
|
if _completion_gate["nudged"]:
|
|
10591
10598
|
return True # already nudged once this turn — accept, never loop
|
|
10592
10599
|
ans = str(final_answer if final_answer is not None else "")
|
|
10593
|
-
|
|
10594
|
-
# The most-recent tool result: a command that finished but whose EFFECT was never
|
|
10595
|
-
# verified (Phase-1 'EXITED 0 (…VERIFY…)' marker), with no server/HTTP confirmation.
|
|
10596
|
-
last_unverified = False
|
|
10597
|
-
try:
|
|
10598
|
-
for _st in reversed(getattr(memory, "steps", []) or []):
|
|
10599
|
-
_obs = getattr(_st, "observations", None)
|
|
10600
|
-
if isinstance(_obs, str) and _obs.strip():
|
|
10601
|
-
last_unverified = ("EXITED 0 (" in _obs) and not any(
|
|
10602
|
-
k in _obs for k in
|
|
10603
|
-
("RUNNING:", "listening", "HTTP 2", "http://localhost"))
|
|
10604
|
-
break
|
|
10605
|
-
except Exception: # noqa: BLE001
|
|
10606
|
-
pass
|
|
10607
|
-
if punt or last_unverified:
|
|
10600
|
+
if _PUNT_RE.search(ans):
|
|
10608
10601
|
_completion_gate["nudged"] = True
|
|
10609
10602
|
raise ValueError(
|
|
10610
|
-
"NOT DONE YET —
|
|
10611
|
-
"
|
|
10612
|
-
" 1) VERIFY it —
|
|
10613
|
-
"confirm the change took — and answer WITH that evidence; OR\n"
|
|
10603
|
+
"NOT DONE YET — your answer hands the work back to the user. Do ONE of these, "
|
|
10604
|
+
"then call final_answer:\n"
|
|
10605
|
+
" 1) DO it yourself with your tools, VERIFY it — run it, run the test, or "
|
|
10606
|
+
"re-read the file to confirm the change took — and answer WITH that evidence; OR\n"
|
|
10614
10607
|
" 2) if it genuinely cannot be done with these tools, state plainly WHAT "
|
|
10615
10608
|
"failed and WHY.\n"
|
|
10616
10609
|
"Do NOT hand an unfinished task back to the user.")
|
package/gu-repl.mjs
CHANGED
|
@@ -154,6 +154,7 @@ import {
|
|
|
154
154
|
parseModelFallback,
|
|
155
155
|
} from "./model-fallback.mjs";
|
|
156
156
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
157
|
+
import { TITLE_POP, TITLE_PUSH, idleTitle, needsSummary, summariseForTitle, titleSeq, trimTitle } from "./terminal-title.mjs";
|
|
157
158
|
import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
|
|
158
159
|
import {
|
|
159
160
|
isDoubleClick,
|
|
@@ -1603,6 +1604,40 @@ if (IS_TTY) {
|
|
|
1603
1604
|
process.on("exit", () => _stdoutWrite(PASTE_MODE_OFF));
|
|
1604
1605
|
}
|
|
1605
1606
|
|
|
1607
|
+
// THE TAB SAYS WHAT IS BEING WORKED ON (task #357) — see terminal-title.mjs. Until the first
|
|
1608
|
+
// question it names the folder, which is already more than the `node /opt/homebrew/…/gu` a
|
|
1609
|
+
// terminal shows for a program that never names itself.
|
|
1610
|
+
const TITLE_ON = IS_TTY && process.env.GU_NO_TITLE !== "1";
|
|
1611
|
+
let _titleSeq = 0; // which question the title belongs to; a late summary for an older one is dropped
|
|
1612
|
+
const writeTitle = (text) => {
|
|
1613
|
+
if (TITLE_ON) ui(titleSeq(text));
|
|
1614
|
+
};
|
|
1615
|
+
if (TITLE_ON) {
|
|
1616
|
+
ui(TITLE_PUSH);
|
|
1617
|
+
writeTitle(idleTitle(process.cwd()));
|
|
1618
|
+
// Hand the title back: restore the one that was there where the terminal keeps a stack, and
|
|
1619
|
+
// clear ours where it does not, so the shell is not left wearing the last question.
|
|
1620
|
+
process.on("exit", () => _stdoutWrite(titleSeq("") + TITLE_POP));
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* A new question: the trimmed text now, and — only when it had to be cut — the agent model's
|
|
1625
|
+
* few-word summary whenever it arrives. Fire-and-forget by design: it never delays the turn,
|
|
1626
|
+
* never prints, and a summary that lands after the NEXT question is thrown away.
|
|
1627
|
+
*/
|
|
1628
|
+
function showQuestionInTitle(question) {
|
|
1629
|
+
if (!TITLE_ON) return;
|
|
1630
|
+
const mine = ++_titleSeq;
|
|
1631
|
+
writeTitle(trimTitle(question));
|
|
1632
|
+
if (!needsSummary(question)) return;
|
|
1633
|
+
void (async () => {
|
|
1634
|
+
const settings = await fetchWorkerSettings();
|
|
1635
|
+
if (!settings || mine !== _titleSeq) return;
|
|
1636
|
+
const title = await summariseForTitle(question, settings, { fetchImpl: doctorFetch });
|
|
1637
|
+
if (title && mine === _titleSeq) writeTitle(title);
|
|
1638
|
+
})().catch(() => {});
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1606
1641
|
// ">>" is a MARGIN MARKER like the ● bullets — it hangs in the gutter (no leading gutter) so
|
|
1607
1642
|
// the user's typed input lands on the SAME text column (GUTTER+1) as the agent's bullets and
|
|
1608
1643
|
// answer. ">> " is exactly GUTTER-wide, so input aligns; the ">>" right-edge sits at the same
|
|
@@ -11057,14 +11092,23 @@ const answerFrom = (s) =>
|
|
|
11057
11092
|
const THOUGHT_PREVIEW_LINES = 3; // #134: dim lines always shown under the cut purple thought
|
|
11058
11093
|
const THOUGHT_OPEN_LINES = 8; // …and after a click / Ctrl+T
|
|
11059
11094
|
const wrapThought = (s, width, maxLines) => {
|
|
11060
|
-
const
|
|
11095
|
+
const W = Math.max(4, width);
|
|
11096
|
+
// A single token wider than a row (a URL, a path) is the only thing that MUST be split; it is
|
|
11097
|
+
// chunked to the row width so no line ever exceeds `width` (a wider row wraps physically and
|
|
11098
|
+
// breaks the redraw's row maths, #109/#138).
|
|
11099
|
+
const words = [];
|
|
11100
|
+
for (const w0 of String(s || "").split(/\s+/).filter(Boolean)) {
|
|
11101
|
+
let w = w0;
|
|
11102
|
+
while (w.length > W) { words.push(w.slice(0, W)); w = w.slice(W); }
|
|
11103
|
+
words.push(w);
|
|
11104
|
+
}
|
|
11061
11105
|
const lines = [];
|
|
11062
11106
|
let cur = "";
|
|
11063
11107
|
let truncated = false;
|
|
11064
11108
|
for (let i = 0; i < words.length; i++) {
|
|
11065
11109
|
const w = words[i];
|
|
11066
11110
|
const next = cur ? cur + " " + w : w;
|
|
11067
|
-
if (next.length <=
|
|
11111
|
+
if (next.length <= W) {
|
|
11068
11112
|
cur = next;
|
|
11069
11113
|
} else {
|
|
11070
11114
|
if (cur) lines.push(cur);
|
|
@@ -11075,15 +11119,30 @@ const wrapThought = (s, width, maxLines) => {
|
|
|
11075
11119
|
if (cur && lines.length < maxLines) lines.push(cur);
|
|
11076
11120
|
else if (cur) truncated = true;
|
|
11077
11121
|
if (truncated && lines.length) {
|
|
11078
|
-
|
|
11079
|
-
|
|
11080
|
-
|
|
11081
|
-
|
|
11082
|
-
|
|
11083
|
-
|
|
11084
|
-
|
|
11122
|
+
// End on a WHOLE, meaningful word (#134): drop whole words until the "…" fits, then drop a
|
|
11123
|
+
// dangling connective ("the", "of", "and"…) and trailing punctuation, so the line never
|
|
11124
|
+
// ends "…and the…" or "word,…". Only a lone oversized word is ever cut inside itself.
|
|
11125
|
+
const idx0 = lines.length - 1;
|
|
11126
|
+
const first = lines[idx0].split(" ")[0];
|
|
11127
|
+
const dangling = /^(a|an|the|of|to|and|or|but|in|on|at|for|with|by|from|as|is|are|that|which|if|then|than|so|into|via|per)$/i;
|
|
11128
|
+
const isDangling = (w) => dangling.test(w.replace(/[^\w]/g, ""));
|
|
11129
|
+
let idx = idx0;
|
|
11130
|
+
let ws = lines[idx].split(" ");
|
|
11131
|
+
for (;;) {
|
|
11132
|
+
while (ws.length > 0 && isDangling(ws[ws.length - 1])) ws.pop();
|
|
11133
|
+
if (ws.length === 0) {
|
|
11134
|
+
if (idx > 0) { lines.pop(); idx--; ws = lines[idx].split(" "); continue; }
|
|
11135
|
+
ws = [first];
|
|
11136
|
+
break;
|
|
11137
|
+
}
|
|
11138
|
+
if (ws.length > 1 && ws.join(" ").length + 1 > W) { ws.pop(); continue; }
|
|
11139
|
+
break;
|
|
11085
11140
|
}
|
|
11086
|
-
lines
|
|
11141
|
+
lines.length = idx + 1;
|
|
11142
|
+
let kept = ws.join(" ").replace(/[\s,;:(\-\u2013\u2014]+$/, "");
|
|
11143
|
+
if (!kept) kept = ws[0];
|
|
11144
|
+
if (kept.length + 1 > W) kept = kept.slice(0, W - 1);
|
|
11145
|
+
lines[idx] = kept + "\u2026";
|
|
11087
11146
|
}
|
|
11088
11147
|
return lines;
|
|
11089
11148
|
};
|
|
@@ -11773,8 +11832,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11773
11832
|
// Line 1 shows the head; the grey expansion CONTINUES from the same point (no dup, no
|
|
11774
11833
|
// split word).
|
|
11775
11834
|
const sp = fullLiveThought.lastIndexOf(" ", max - 1);
|
|
11776
|
-
|
|
11777
|
-
|
|
11835
|
+
// Any space at all beats a mid-word cut (#134); only a first word wider than the row is split.
|
|
11836
|
+
const cut = sp > 0 ? sp : max - 1;
|
|
11837
|
+
label = `${fullLiveThought.slice(0, cut).replace(/[\s,;:(\-\u2013\u2014]+$/, "")}… (${secs}s)`;
|
|
11778
11838
|
rest = fullLiveThought.slice(cut).trim();
|
|
11779
11839
|
} else {
|
|
11780
11840
|
const primary = runningTool
|
|
@@ -14323,6 +14383,9 @@ async function main() {
|
|
|
14323
14383
|
history.push({ role: "user", content: taskLine });
|
|
14324
14384
|
}
|
|
14325
14385
|
await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
|
|
14386
|
+
// #357: the tab is this question until the next one. A bare "continue" with nothing to
|
|
14387
|
+
// resume says nothing about the work, so the title it follows stays.
|
|
14388
|
+
if (!isResumeWord(taskLine)) showQuestionInTitle(taskLine);
|
|
14326
14389
|
try {
|
|
14327
14390
|
const turnStartMs = Date.now();
|
|
14328
14391
|
// #323: priced by the coder this turn STARTS on — a /model switch afterwards must not put
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.820",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -115,6 +115,7 @@
|
|
|
115
115
|
"model-doctor.mjs",
|
|
116
116
|
"coding-key-prompt.mjs",
|
|
117
117
|
"model-fallback.mjs",
|
|
118
|
+
"terminal-title.mjs",
|
|
118
119
|
"server-keys.mjs",
|
|
119
120
|
"add-server.mjs",
|
|
120
121
|
"powershell-alias.mjs",
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TERMINAL'S TITLE IS THE QUESTION BEING WORKED ON (task #357).
|
|
3
|
+
*
|
|
4
|
+
* Reported: the tab said `node /opt/homebrew/…/gu` — where the gu bin lives, which is what a
|
|
5
|
+
* terminal shows when the program inside never names itself. With several gu tabs open that is
|
|
6
|
+
* the same useless string on every one. The ask: show the current turn's question instead,
|
|
7
|
+
* shortened, and keep it there until the next question.
|
|
8
|
+
*
|
|
9
|
+
* TWO STEPS, AND THE FIRST NEVER WAITS FOR THE SECOND. The trimmed question goes up the moment
|
|
10
|
+
* the turn starts. Only a question too long to fit is then sent to the agent model for a
|
|
11
|
+
* few-word summary, which replaces the trimmed one if — and only if — it arrives usable and the
|
|
12
|
+
* user has not asked something else in the meantime. Every way that call can fail (no model
|
|
13
|
+
* configured, a provider that wants a key this side does not hold, a timeout, a model that
|
|
14
|
+
* answers with an essay) ends the same way: the trimmed question stays.
|
|
15
|
+
*
|
|
16
|
+
* WHY SHORT QUESTIONS ARE NOT SUMMARISED. The agent model is the one the turn itself is about to
|
|
17
|
+
* use, and a single-GPU Ollama queues requests rather than running them side by side — a summary
|
|
18
|
+
* of "fix the login test" would cost the turn its first seconds to say the same thing in fewer
|
|
19
|
+
* words. A question that already fits is its own best title.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no terminal and no network of its own. `fetchImpl` is injected, and the caller writes
|
|
22
|
+
* the bytes.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** What fits on a tab next to its neighbours. Terminals clip a title; they do not wrap it. */
|
|
26
|
+
export const TITLE_MAX = 60;
|
|
27
|
+
|
|
28
|
+
/** How much of a long question the summary is given — the ask is in the opening lines. */
|
|
29
|
+
export const SUMMARY_INPUT_MAX = 1200;
|
|
30
|
+
|
|
31
|
+
const ELLIPSIS = "…";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One line of plain text, safe to put inside an escape sequence.
|
|
35
|
+
*
|
|
36
|
+
* NOT COSMETIC. The title is written as `ESC ] 0 ; <text> BEL`, so a BEL or ESC inside the text
|
|
37
|
+
* ends the sequence early and the rest is read by the terminal as commands — and the text is
|
|
38
|
+
* whatever was typed or PASTED as a question. Every C0/C1 control character goes, newlines
|
|
39
|
+
* included (as spaces: a pasted stack trace is one title, not twenty).
|
|
40
|
+
*/
|
|
41
|
+
export function cleanTitleText(text) {
|
|
42
|
+
return String(text ?? "")
|
|
43
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ")
|
|
44
|
+
.replace(/\s+/g, " ")
|
|
45
|
+
.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The question cut to fit, on a word boundary when there is one in the last third. */
|
|
49
|
+
export function trimTitle(text, max = TITLE_MAX) {
|
|
50
|
+
const s = cleanTitleText(text);
|
|
51
|
+
const chars = Array.from(s); // by code point: never half of an emoji or a Vietnamese letter
|
|
52
|
+
if (chars.length <= max) return s;
|
|
53
|
+
const cut = chars.slice(0, max - 1).join("");
|
|
54
|
+
const space = cut.lastIndexOf(" ");
|
|
55
|
+
const head = space >= Math.floor((max * 2) / 3) ? cut.slice(0, space) : cut;
|
|
56
|
+
return head.replace(/[\s,;:.\-–—]+$/u, "") + ELLIPSIS;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Only a question that had to be cut is worth a model call (see the header). */
|
|
60
|
+
export function needsSummary(text, max = TITLE_MAX) {
|
|
61
|
+
return Array.from(cleanTitleText(text)).length > max;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The bytes that set the window and tab title. "" hands the title back to the terminal. */
|
|
65
|
+
export function titleSeq(text) {
|
|
66
|
+
return `\x1b]0;${cleanTitleText(text)}\x07`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** xterm's title stack: remember the title that was there, and put it back on the way out.
|
|
70
|
+
* Terminals that do not have one ignore both. */
|
|
71
|
+
export const TITLE_PUSH = "\x1b[22;0t";
|
|
72
|
+
export const TITLE_POP = "\x1b[23;0t";
|
|
73
|
+
|
|
74
|
+
/** What the tab says before anything has been asked. */
|
|
75
|
+
export function idleTitle(folder) {
|
|
76
|
+
const name = cleanTitleText(String(folder ?? "").split(/[\\/]/).filter(Boolean).pop() ?? "");
|
|
77
|
+
return name ? `gu · ${trimTitle(name, TITLE_MAX - 5)}` : "gu";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Where the agent model answers chat, from a base that may or may not already end in /v1. */
|
|
81
|
+
export function chatEndpoint(url) {
|
|
82
|
+
const root = String(url ?? "").trim().replace(/\/+$/, "");
|
|
83
|
+
if (!root) return "";
|
|
84
|
+
return /\/v1$/i.test(root) ? `${root}/chat/completions` : `${root}/v1/chat/completions`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The agent model this machine is set to use, from the settings row.
|
|
89
|
+
*
|
|
90
|
+
* With a direct URL and no name the API itself sends the literal "default_model" (an MLX server
|
|
91
|
+
* answers to anything), so the same is sent here rather than skipping the summary on every MLX
|
|
92
|
+
* host.
|
|
93
|
+
*/
|
|
94
|
+
export function agentModelFromSettings(settings) {
|
|
95
|
+
const str = (v) => String(v ?? "").trim();
|
|
96
|
+
const url = str(settings?.agentModelUrl);
|
|
97
|
+
if (!url) return null;
|
|
98
|
+
return { url, model: str(settings?.agentModelName) || "default_model" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function summaryRequest(model, question) {
|
|
102
|
+
const q = Array.from(cleanTitleText(question)).slice(0, SUMMARY_INPUT_MAX).join("");
|
|
103
|
+
return {
|
|
104
|
+
model,
|
|
105
|
+
stream: false,
|
|
106
|
+
max_tokens: 200, // room for a reasoning model to think briefly and still answer
|
|
107
|
+
messages: [
|
|
108
|
+
{
|
|
109
|
+
role: "system",
|
|
110
|
+
content:
|
|
111
|
+
"You write window titles. Reply with ONLY a title of at most 8 words that says what " +
|
|
112
|
+
"the user's request is about, in the language the request is written in. No quotes, " +
|
|
113
|
+
"no trailing punctuation, no explanation. Do not answer or carry out the request.",
|
|
114
|
+
},
|
|
115
|
+
{ role: "user", content: `Request:\n${q}` },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The title out of the model's reply, or "" when the reply is not one.
|
|
122
|
+
*
|
|
123
|
+
* A model that ignores the instruction and starts answering the question must not get its first
|
|
124
|
+
* paragraph into the tab, so anything that is not a single short line is refused, and the caller
|
|
125
|
+
* keeps the trimmed question.
|
|
126
|
+
*/
|
|
127
|
+
export function pickSummary(body, max = TITLE_MAX) {
|
|
128
|
+
let raw = body?.choices?.[0]?.message?.content;
|
|
129
|
+
if (typeof raw !== "string") return "";
|
|
130
|
+
raw = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
|
131
|
+
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
132
|
+
if (lines.length !== 1) return "";
|
|
133
|
+
const line = cleanTitleText(lines[0])
|
|
134
|
+
.replace(/^title\s*:\s*/i, "")
|
|
135
|
+
.replace(/^["'“”‘’`*]+|["'“”‘’`*]+$/gu, "")
|
|
136
|
+
.replace(/[.。!?]+$/u, "")
|
|
137
|
+
.trim();
|
|
138
|
+
const n = Array.from(line).length;
|
|
139
|
+
return n > 0 && n <= max ? line : "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Ask the agent model for a title. → the title, or "" for every reason there is not one.
|
|
144
|
+
* Never throws: this runs beside a turn and must not be able to disturb it.
|
|
145
|
+
*/
|
|
146
|
+
export async function summariseForTitle(
|
|
147
|
+
question,
|
|
148
|
+
settings,
|
|
149
|
+
{ fetchImpl = fetch, timeoutMs = 20_000 } = {},
|
|
150
|
+
) {
|
|
151
|
+
const agent = agentModelFromSettings(settings);
|
|
152
|
+
if (!agent || !needsSummary(question)) return "";
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetchImpl(chatEndpoint(agent.url), {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify(summaryRequest(agent.model, question)),
|
|
158
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
159
|
+
});
|
|
160
|
+
if (!res?.ok) return "";
|
|
161
|
+
return pickSummary(await res.json());
|
|
162
|
+
} catch {
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
}
|