@noobdemon/noob-cli 1.5.2 → 1.5.4

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/agent.js +36 -12
  3. package/src/api.js +63 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -175,24 +175,48 @@ function buildPrompt(history) {
175
175
  }
176
176
 
177
177
  // Extract a single tool call from an assistant message, if present.
178
+ // NOTE: we do NOT match up to a closing ``` fence — write_file content routinely
179
+ // contains its own ```code``` fences (e.g. a README), and the first inner fence
180
+ // would close the block early and break the JSON. Instead, find the ```tool (or
181
+ // ```json) opener and brace-match the first balanced JSON object after it.
178
182
  export function parseToolCall(text) {
179
- // Preferred: ```tool { ... } ```
180
- let m = text.match(/```tool\s*\n([\s\S]*?)```/);
181
- // Fallback: a ```json block that contains a "name" field.
182
- if (!m) {
183
- const j = text.match(/```json\s*\n([\s\S]*?)```/);
184
- if (j && /"name"\s*:/.test(j[1])) m = j;
185
- }
186
- if (!m) return null;
187
- try {
188
- const obj = JSON.parse(m[1].trim());
183
+ for (const fence of ["tool", "json"]) {
184
+ const open = text.match(new RegExp("```" + fence + "[ \\t]*\\n"));
185
+ if (!open) continue;
186
+ const obj = extractJsonObject(text, open.index + open[0].length);
189
187
  if (obj && typeof obj.name === "string") return { name: obj.name, input: obj.input || {} };
190
- } catch {
191
- /* malformed — treat as prose */
192
188
  }
193
189
  return null;
194
190
  }
195
191
 
192
+ // Parse the first balanced {…} at/after `from`, tracking string literals and
193
+ // escapes so braces or backticks INSIDE string values don't throw off the depth
194
+ // count. Returns the parsed object, or null if malformed/truncated (unbalanced).
195
+ function extractJsonObject(s, from) {
196
+ const start = s.indexOf("{", from);
197
+ if (start === -1) return null;
198
+ let depth = 0, inStr = false, esc = false;
199
+ for (let i = start; i < s.length; i++) {
200
+ const ch = s[i];
201
+ if (inStr) {
202
+ if (esc) esc = false;
203
+ else if (ch === "\\") esc = true;
204
+ else if (ch === '"') inStr = false;
205
+ continue;
206
+ }
207
+ if (ch === '"') inStr = true;
208
+ else if (ch === "{") depth++;
209
+ else if (ch === "}" && --depth === 0) {
210
+ try {
211
+ return JSON.parse(s.slice(start, i + 1));
212
+ } catch {
213
+ return null; // malformed JSON — treat as prose
214
+ }
215
+ }
216
+ }
217
+ return null; // unbalanced (e.g. stream cut mid-block) — auto-continue finishes it
218
+ }
219
+
196
220
  /**
197
221
  * Run one agent turn (which may span several tool steps).
198
222
  *
package/src/api.js CHANGED
@@ -41,10 +41,59 @@ async function parseError(resp) {
41
41
 
42
42
  /**
43
43
  * Stream a chat/merge/search request from the gateway.
44
+ *
45
+ * Auto-continue (chat only): the chat upstream runs on Vercel, which kills the
46
+ * function at ~300s. A long reply gets cut mid-stream — the gateway flags this
47
+ * with `{truncated:true}` (or the connection just drops with no `{done}`). When
48
+ * that happens we re-send the SAME transcript plus the partial reply so far and
49
+ * ask the model to write ONLY the rest, then append it. The caller sees one
50
+ * seamless stream. Capped by `maxContinues` so a genuinely broken upstream can't
51
+ * loop forever.
52
+ *
44
53
  * @returns {Promise<{text:string, reasoning:string}>}
45
54
  */
46
- export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs = 120000 }) {
55
+ export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs = 120000, maxContinues = 6 }) {
47
56
  const endpoint = mode === "search" ? "/api/search" : mode === "merge" ? "/api/merge" : "/api/chat";
57
+
58
+ let fullText = "";
59
+ let reasoning = "";
60
+ let prompt = message; // prompt gửi đi: lần đầu = nguyên bản, các lần sau = nối tiếp
61
+
62
+ for (let attempt = 0; ; attempt++) {
63
+ const r = await streamOnce({ endpoint, mode, message: prompt, model, signal, idleMs, onStatus, onDelta, onReasoning });
64
+ fullText = mode === "chat" ? fullText + r.text : r.text; // chat: ghép các đoạn nối tiếp; mode khác: thay thế
65
+ if (r.reasoning) reasoning = r.reasoning;
66
+
67
+ // Còn nối tiếp được không? Chỉ với chat, khi bị cắt, còn lượt, và lần này có
68
+ // ra chữ thật (đoạn rỗng → coi như xong, tránh lặp vô tận).
69
+ if (!r.truncated || mode !== "chat" || attempt >= maxContinues || !r.text.trim()) break;
70
+ prompt = continuationPrompt(message, fullText);
71
+ }
72
+
73
+ return { text: fullText.trim(), reasoning: reasoning.trim() };
74
+ }
75
+
76
+ // Dựng prompt "nối tiếp" khi câu trả lời bị cắt giữa chừng: gửi lại nguyên ngữ
77
+ // cảnh gốc + phần model đã viết dở + yêu cầu viết TIẾP đúng chỗ dừng, không lặp.
78
+ function continuationPrompt(message, partial) {
79
+ const bar = "=".repeat(60);
80
+ return (
81
+ message +
82
+ "\n\n" + bar +
83
+ "\n## ASSISTANT (bị ngắt giữa chừng — phần trả lời dưới đây CHƯA hoàn tất)\n" +
84
+ partial +
85
+ "\n\n" + bar +
86
+ "\n# SYSTEM: Phần trả lời ngay trên bị mạng/timeout cắt ngang trước khi xong. " +
87
+ "Hãy VIẾT TIẾP liền mạch từ ĐÚNG ký tự cuối cùng ở trên — KHÔNG lặp lại hay diễn đạt lại bất kỳ chữ nào đã hiện, KHÔNG mở đầu lại, KHÔNG thêm lời dẫn. " +
88
+ "Chỉ xuất phần CÒN LẠI. Nếu đang viết dở một khối tool thì hoàn tất đúng khối đó. Nếu thật ra đã xong, chỉ xuất một dấu cách rồi dừng."
89
+ );
90
+ }
91
+
92
+ /**
93
+ * One network attempt of the stream. Returns this attempt's accumulated text +
94
+ * a `truncated` flag telling the caller whether the reply was cut short.
95
+ */
96
+ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onStatus, onDelta, onReasoning }) {
48
97
  const body = mode === "search" ? { query: message } : mode === "merge" ? { message } : { message, model };
49
98
 
50
99
  // Idle-timeout: nếu KHÔNG nhận được byte nào trong idleMs (kết nối treo), tự
@@ -65,6 +114,8 @@ export async function stream({ mode = "chat", message, model, signal, onDelta, o
65
114
 
66
115
  let text = "";
67
116
  let reasoning = "";
117
+ let sawDone = false; // thấy {done} = stream kết thúc tử tế (không bị cắt)
118
+ let truncated = false; // gateway báo upstream bị cắt giữa chừng (Vercel 300s)
68
119
 
69
120
  // Một dòng SSE → cập nhật text/reasoning. Tách ra để dùng lại khi flush dòng cuối.
70
121
  const processLine = (rawLine) => {
@@ -88,6 +139,8 @@ export async function stream({ mode = "chat", message, model, signal, onDelta, o
88
139
  onReasoning?.(p.reasoning);
89
140
  if (p.answer) text = p.answer;
90
141
  }
142
+ if (p.truncated) truncated = true;
143
+ if (p.done) sawDone = true;
91
144
  if (p.error) throw new ApiError(p.error, {});
92
145
  };
93
146
 
@@ -118,9 +171,17 @@ export async function stream({ mode = "chat", message, model, signal, onDelta, o
118
171
  buf += decoder.decode(); // flush decoder
119
172
  if (buf.trim()) processLine(buf); // dòng SSE cuối không có '\n' — đừng bỏ sót
120
173
 
121
- return { text: text.trim(), reasoning: reasoning.trim() };
174
+ // Chat: gateway gửi {done} khi xong sạch. Stream EOF mà chưa thấy {done}
175
+ // đã có chữ → kết nối/edge rớt giữa chừng → coi như bị cắt để nối tiếp.
176
+ if (mode === "chat" && !sawDone && text) truncated = true;
177
+
178
+ return { text, reasoning, truncated };
122
179
  } catch (err) {
180
+ if (signal?.aborted) throw err; // người dùng bấm Ctrl+C → huỷ thật, không nối tiếp
123
181
  if (timedOut) throw new ApiError("Kết nối tới máy chủ quá thời gian chờ (treo).", { code: "timeout" });
182
+ // Rớt mạng giữa chừng (không phải huỷ, không phải treo): với chat, nếu đã có
183
+ // chữ thì trả phần đã nhận + cờ truncated để lớp trên nối tiếp.
184
+ if (mode === "chat" && text) return { text, reasoning, truncated: true };
124
185
  throw err;
125
186
  } finally {
126
187
  clearTimeout(idle);