@hncode/hncode 0.3.6 → 0.4.0
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/package.json +4 -2
- package/src/agent-task.js +154 -84
- package/src/agent.js +533 -145
- package/src/clipboard.js +5 -2
- package/src/colors.js +242 -238
- package/src/config.js +217 -17
- package/src/file-history.js +177 -0
- package/src/hooks.js +11 -1
- package/src/i18n.js +389 -0
- package/src/index.js +46 -3
- package/src/llm.js +139 -16
- package/src/permissions.js +120 -0
- package/src/session-hub.js +407 -0
- package/src/session.js +31 -0
- package/src/term.js +19 -3
- package/src/tool-result.js +236 -0
- package/src/tools/agent.js +11 -0
- package/src/tools/bash.js +84 -17
- package/src/tools/edit.js +192 -32
- package/src/tools/file-lines.js +5 -0
- package/src/tools/index.js +4 -0
- package/src/tools/memory.js +88 -0
- package/src/tools/read-media-file.js +85 -68
- package/src/tools/read.js +15 -1
- package/src/tools/utils.js +14 -0
- package/src/tools/write.js +35 -5
- package/src/tui.js +11665 -9179
- package/src/web-auth.js +98 -0
- package/src/web-daemon-cli.js +63 -0
- package/src/web-daemon-client.js +203 -0
- package/src/web-daemon.js +871 -0
- package/src/web-public/app.css +1509 -0
- package/src/web-public/app.js +2913 -0
- package/src/web-public/complete.js +154 -0
- package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc.css +775 -0
- package/src/web-public/fonts.css +31 -0
- package/src/web-public/home.css +222 -0
- package/src/web-public/home.html +33 -0
- package/src/web-public/home.js +160 -0
- package/src/web-public/i18n.js +983 -0
- package/src/web-public/index.html +196 -0
- package/src/web-public/login.css +123 -0
- package/src/web-public/login.html +25 -0
- package/src/web-public/login.js +46 -0
- package/src/web-public/settings.js +290 -0
- package/src/web-ws.js +226 -0
- package/src/web.js +380 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// Session hub — the single source of truth shared by the TUI and the web UI.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS: the TUI is the original owner of the transcript, the agent
|
|
4
|
+
// events and the input. The web UI must see exactly what the TUI sees AND be able
|
|
5
|
+
// to drive the same session (send prompts, run slash commands, interrupt, answer
|
|
6
|
+
// approvals). Without one shared object the two would drift: a web-sent prompt
|
|
7
|
+
// would never appear in the terminal, and a TUI-sent one would never reach the
|
|
8
|
+
// browser.
|
|
9
|
+
//
|
|
10
|
+
// Shape: the hub keeps the durable view (transcript rows, status, tasks, todos)
|
|
11
|
+
// and an append-only event log. Both front-ends are SUBSCRIBERS:
|
|
12
|
+
// * `emit(event)` — anything happening in the session. Broadcast to every
|
|
13
|
+
// subscriber; the hub also folds it into its own view so a late subscriber
|
|
14
|
+
// (a browser opened mid-turn) can render the whole state on connect.
|
|
15
|
+
// * `pushRow(row)` — a transcript row was appended by the TUI. Goes out as a
|
|
16
|
+
// `row` event AND into the durable view.
|
|
17
|
+
// Actions (the user's intent) travel the OTHER way through injected handlers, so
|
|
18
|
+
// the web never touches the agent directly — it calls the same functions the
|
|
19
|
+
// keyboard does. That is what keeps behaviour identical in both front-ends.
|
|
20
|
+
|
|
21
|
+
// How many transcript rows / events to keep for a reconnecting client. The
|
|
22
|
+
// transcript is bounded because a long session with big tool results would
|
|
23
|
+
// otherwise grow without limit in a process that may run for days; the browser
|
|
24
|
+
// gets the tail and can scroll back no further than that on a fresh connect.
|
|
25
|
+
//
|
|
26
|
+
// NOTE: this is a MEMORY bound for a long-running process, not a rendering one.
|
|
27
|
+
// The browser is sent everything up to here and the windowed list (vlist.js)
|
|
28
|
+
// keeps only the visible slice in the DOM, so the two limits are independent.
|
|
29
|
+
const MAX_ROWS = 4000;
|
|
30
|
+
const MAX_EVENTS = 2000;
|
|
31
|
+
const SNAPSHOT_ROWS = 0; // 0 = no cap; see snapshot()
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Status fields that animate the Working row and change on EVERY frame a turn is
|
|
35
|
+
* running. They must not count as a "status change" — otherwise a long turn
|
|
36
|
+
* broadcasts the whole status (and the browser rebuilds every card pane) once per
|
|
37
|
+
* frame. The pulse is drawn client-side; these ride along when a real change
|
|
38
|
+
* broadcasts.
|
|
39
|
+
*/
|
|
40
|
+
const ANIMATION_FIELDS = new Set(['spin', 'workMsg', 'pulseStart']);
|
|
41
|
+
// Which fields of a transcript row the browser actually renders. The fingerprint
|
|
42
|
+
// covers exactly these, so streaming text (a growing `text`) invalidates it while
|
|
43
|
+
// an unrelated mutation does not. Keeping the list here — rather than hashing the
|
|
44
|
+
// whole object — also keeps a large tool result from being stringified on every
|
|
45
|
+
// frame.
|
|
46
|
+
const ROW_FIELDS = [
|
|
47
|
+
'role', 'text', 'toolName', 'pending', 'failed',
|
|
48
|
+
'streamContent', 'liveOutput', 'phase', 'tokensBefore', 'tokensAfter',
|
|
49
|
+
'instruction', 'card',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function rowFingerprint(msg) {
|
|
53
|
+
let s = '';
|
|
54
|
+
for (const f of ROW_FIELDS) {
|
|
55
|
+
const v = msg[f];
|
|
56
|
+
if (v === undefined || v === null) { s += '|'; continue; }
|
|
57
|
+
if (typeof v === 'string') s += v.length + ':' + (v.length <= 64 ? v : '');
|
|
58
|
+
else if (typeof v === 'object') s += v === msg.card ? 'card:' + String(v.headline || '') : 'obj';
|
|
59
|
+
else s += String(v);
|
|
60
|
+
s += '|';
|
|
61
|
+
}
|
|
62
|
+
// Tool arguments drive the one-line summary, so a change there must be seen.
|
|
63
|
+
s += msg.toolArgs ? Object.keys(msg.toolArgs).join(',') + ':' + String(msg.toolArgs.command || msg.toolArgs.path || msg.toolArgs.pattern || '').length : '';
|
|
64
|
+
// The diff drives an expanded row's body.
|
|
65
|
+
s += '/' + (Array.isArray(msg.diff) ? msg.diff.length : 0);
|
|
66
|
+
return s;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Content equality for status fields. Scalars by value; arrays element-wise with a
|
|
71
|
+
* one-level object compare (the shape of a todo / task / file entry); other
|
|
72
|
+
* objects field-wise with a JSON fallback for anything nested deeper — exactly the
|
|
73
|
+
* shapes webStatus builds (`options` carries arrays of objects, `pending` carries
|
|
74
|
+
* option lists).
|
|
75
|
+
*/
|
|
76
|
+
function sameValue(a, b) {
|
|
77
|
+
if (a === b) return true;
|
|
78
|
+
if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
79
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
80
|
+
if (Array.isArray(a)) {
|
|
81
|
+
if (a.length !== b.length) return false;
|
|
82
|
+
for (let i = 0; i < a.length; i++) {
|
|
83
|
+
if (a[i] === b[i]) continue;
|
|
84
|
+
if (!sameValue(a[i], b[i])) return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
const ka = Object.keys(a);
|
|
89
|
+
const kb = Object.keys(b);
|
|
90
|
+
if (ka.length !== kb.length) return false;
|
|
91
|
+
for (const k of ka) {
|
|
92
|
+
const va = a[k];
|
|
93
|
+
const vb = b[k];
|
|
94
|
+
if (va === vb) continue;
|
|
95
|
+
if (va && vb && typeof va === 'object' && typeof vb === 'object') {
|
|
96
|
+
if (JSON.stringify(va) !== JSON.stringify(vb)) return false;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
// The subset of a TUI message the browser needs. Built explicitly rather than
|
|
104
|
+
// spreading, so TUI-only bookkeeping (render caches, the arg-stream parser state,
|
|
105
|
+
// the hub id itself) never travels over the wire.
|
|
106
|
+
function snapshotRow(msg) {
|
|
107
|
+
const out = { role: msg.role || 'system', text: msg.text == null ? '' : String(msg.text) };
|
|
108
|
+
for (const k of ['toolName', 'pending', 'failed', 'streamContent', 'liveOutput',
|
|
109
|
+
'phase', 'tokensBefore', 'tokensAfter', 'instruction']) {
|
|
110
|
+
if (msg[k] !== undefined) out[k] = msg[k];
|
|
111
|
+
}
|
|
112
|
+
// The two fields that can be unbounded. `text` is a finished tool result and
|
|
113
|
+
// `liveOutput` is a running command's stream; both are capped BEFORE the wire,
|
|
114
|
+
// because this snapshot travels as one SSE frame and the browser copies it into
|
|
115
|
+
// a fresh row object on every patch. 128 KiB matches the tool-result cap
|
|
116
|
+
// (`MAX_OUTPUT_BYTES`), so the browser sees the same content the terminal kept.
|
|
117
|
+
out.text = capText(out.text, MAX_ROW_TEXT_CHARS);
|
|
118
|
+
if (typeof out.liveOutput === 'string') {
|
|
119
|
+
out.liveOutput = capText(out.liveOutput, MAX_ROW_TEXT_CHARS);
|
|
120
|
+
}
|
|
121
|
+
// A tool's one-line summary needs its identifying argument, not the whole
|
|
122
|
+
// payload — a Write's `content` can be megabytes.
|
|
123
|
+
if (msg.toolArgs && typeof msg.toolArgs === 'object') {
|
|
124
|
+
const a = {};
|
|
125
|
+
for (const k of ['command', 'cmd', 'path', 'file_path', 'pattern', 'query', 'url', 'description', 'task_id']) {
|
|
126
|
+
if (typeof msg.toolArgs[k] === 'string') a[k] = String(msg.toolArgs[k]).slice(0, 400);
|
|
127
|
+
}
|
|
128
|
+
if (Object.keys(a).length) out.toolArgs = a;
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(msg.diff)) out.diff = msg.diff.slice(0, 400);
|
|
131
|
+
if (msg.card && typeof msg.card === 'object') {
|
|
132
|
+
out.card = { phase: msg.card.phase, headline: msg.card.headline, detail: msg.card.detail };
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Per-row character budget for the two fields that can grow without bound. */
|
|
138
|
+
const MAX_ROW_TEXT_CHARS = 128 * 1024;
|
|
139
|
+
|
|
140
|
+
/** Keep the TAIL of an over-long field — the part both front-ends show — and mark
|
|
141
|
+
what was dropped, so a truncated row is not mistaken for a short one. */
|
|
142
|
+
function capText(s, max) {
|
|
143
|
+
const str = String(s == null ? '' : s);
|
|
144
|
+
if (str.length <= max) return str;
|
|
145
|
+
return `...[${str.length - max} chars truncated]\n` + str.slice(str.length - max);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
export class SessionHub {
|
|
151
|
+
constructor() {
|
|
152
|
+
this.rows = []; // durable transcript: { id, role, text, ... }
|
|
153
|
+
this.events = []; // ring of recent events, replayed on connect
|
|
154
|
+
this.subscribers = new Set();
|
|
155
|
+
this.seq = 0;
|
|
156
|
+
this.rowSeq = 0;
|
|
157
|
+
// hub row id -> last fingerprint, for syncChat's incremental diff.
|
|
158
|
+
this._rowFp = new Map();
|
|
159
|
+
// hub row id -> the row object in `this.rows`. syncChat runs on every paint
|
|
160
|
+
// frame, and looking a row up with Array.find() inside the per-message loop
|
|
161
|
+
// made it O(n^2): every token of a long reply walked the whole transcript. The
|
|
162
|
+
// index turns that lookup into a Map hit.
|
|
163
|
+
this._rowById = new Map();
|
|
164
|
+
// Filled in by the TUI once it owns these. A web action that has no handler
|
|
165
|
+
// reports a clear error instead of silently doing nothing.
|
|
166
|
+
this.actions = {
|
|
167
|
+
submit: null, // async (text) => void — same path as typing
|
|
168
|
+
dispatch: null, // async (cmd, arg) => void — slash commands
|
|
169
|
+
interrupt: null, // async () => void
|
|
170
|
+
approve: null, // async (id, ok) => void — answer an approval
|
|
171
|
+
answerQuestion: null,// async (id, answers) => void — AskUserQuestion
|
|
172
|
+
stopTask: null, // async (taskId) => void
|
|
173
|
+
setMode: null, // async (mode) => void
|
|
174
|
+
status: null, // () => object
|
|
175
|
+
shell: null, // async (cmd) => void — `!` passthrough
|
|
176
|
+
steer: null, // async (text) => void — inject into the running turn
|
|
177
|
+
editQueued: null, // async (text) => {text} — recall a queued msg for editing
|
|
178
|
+
dropQueued: null, // async (text) => void — remove a queued message
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---- broadcasting ----------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
subscribe(fn) {
|
|
185
|
+
this.subscribers.add(fn);
|
|
186
|
+
return () => this.subscribers.delete(fn);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Broadcast one event. Subscriber failures must never break the session. */
|
|
190
|
+
emit(event) {
|
|
191
|
+
const e = { ...event, seq: ++this.seq, at: Date.now() };
|
|
192
|
+
this.events.push(e);
|
|
193
|
+
if (this.events.length > MAX_EVENTS) this.events.splice(0, this.events.length - MAX_EVENTS);
|
|
194
|
+
for (const fn of this.subscribers) {
|
|
195
|
+
try { fn(e); } catch { /* one bad client must not kill the others */ }
|
|
196
|
+
}
|
|
197
|
+
return e;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Append a transcript row. `row` is the TUI's own message object (role/text/
|
|
202
|
+
* toolName/toolArgs/pending/failed/diff/…), so the two front-ends render the
|
|
203
|
+
* SAME data rather than two parallel models.
|
|
204
|
+
*/
|
|
205
|
+
pushRow(row) {
|
|
206
|
+
const r = { ...row, id: row.id != null ? row.id : ++this.rowSeq };
|
|
207
|
+
if (row.id == null) r.id = this.rowSeq;
|
|
208
|
+
this.rows.push(r);
|
|
209
|
+
if (this.rows.length > MAX_ROWS) this.rows.splice(0, this.rows.length - MAX_ROWS);
|
|
210
|
+
this.emit({ type: 'row', row: r });
|
|
211
|
+
return r;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Mutate an existing row in place (streaming text, a tool finishing, …). */
|
|
215
|
+
patchRow(id, patch) {
|
|
216
|
+
const r = this.rows.find((x) => x.id === id);
|
|
217
|
+
if (!r) return null;
|
|
218
|
+
Object.assign(r, patch);
|
|
219
|
+
this.emit({ type: 'row_patch', id, patch });
|
|
220
|
+
return r;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Sync the hub's view of the transcript against the TUI's own `state.chat`.
|
|
225
|
+
*
|
|
226
|
+
* This is the bridge between the two front-ends, and it exists so the TUI does
|
|
227
|
+
* NOT have to announce every change. `state.chat` is mutated from half a dozen
|
|
228
|
+
* places (addChat plus direct pushes for streamed prose, tool rows, thinking
|
|
229
|
+
* blocks, the plan bubble, aborts) and one of them is a streaming hot path where
|
|
230
|
+
* an extra call per token would be felt. Instead the caller runs this once per
|
|
231
|
+
* paint: rows are matched by IDENTITY, so a pushed row is picked up on the next
|
|
232
|
+
* frame, and a mutated row is diffed by a cheap field fingerprint.
|
|
233
|
+
*
|
|
234
|
+
* Returns nothing; failures here must never affect rendering.
|
|
235
|
+
*/
|
|
236
|
+
syncChat(chat) {
|
|
237
|
+
const list = Array.isArray(chat) ? chat : [];
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
for (let i = 0; i < list.length; i++) {
|
|
240
|
+
const msg = list[i];
|
|
241
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
242
|
+
// Bind the TUI's message object to a stable hub row id the first time we see
|
|
243
|
+
// it. A WeakMap would be cleaner but the TUI copies rows (spread) in a few
|
|
244
|
+
// paths, so the id lives on the message itself.
|
|
245
|
+
let id = msg._hubId;
|
|
246
|
+
if (id == null) {
|
|
247
|
+
id = ++this.rowSeq;
|
|
248
|
+
Object.defineProperty(msg, '_hubId', { value: id, enumerable: false, configurable: true, writable: true });
|
|
249
|
+
}
|
|
250
|
+
seen.add(id);
|
|
251
|
+
const fp = rowFingerprint(msg);
|
|
252
|
+
const known = this._rowFp.get(id);
|
|
253
|
+
if (known === undefined) {
|
|
254
|
+
const row = { ...snapshotRow(msg), id };
|
|
255
|
+
this.rows.push(row);
|
|
256
|
+
this._rowById.set(id, row);
|
|
257
|
+
this.emit({ type: 'row', row });
|
|
258
|
+
} else if (known !== fp) {
|
|
259
|
+
const target = this._rowById.get(id);
|
|
260
|
+
// The WHOLE row's renderable fields, not a text delta.
|
|
261
|
+
//
|
|
262
|
+
// A delta looked like an easy win — a streamed reply grows one token at a
|
|
263
|
+
// time, so shipping only the appended slice is O(n) instead of O(n^2) over a
|
|
264
|
+
// message. But it puts the sender and the receiver into a shared state
|
|
265
|
+
// machine: the receiver must hold exactly the text the sender assumed, and
|
|
266
|
+
// any divergence (a dropped frame, a reconnect mid-stream, an out-of-order
|
|
267
|
+
// batch) silently TRUNCATES the message instead of visibly failing. That is
|
|
268
|
+
// exactly what happened: replies rendered at a fraction of their real length
|
|
269
|
+
// in the browser while the terminal showed them whole.
|
|
270
|
+
//
|
|
271
|
+
// The full patch costs bandwidth, and this is a LOOPBACK server: the cost
|
|
272
|
+
// that matters is the browser's re-render, and `text` is assigned to one
|
|
273
|
+
// text node either way. Correctness over a micro-optimisation.
|
|
274
|
+
const patch = snapshotRow(msg);
|
|
275
|
+
if (target) Object.assign(target, patch);
|
|
276
|
+
this.emit({ type: 'row_patch', id, patch });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// The TUI rebuilds `state.chat` wholesale on /compact and on session resume.
|
|
280
|
+
// Anything it no longer holds is gone from the hub too, or the browser would
|
|
281
|
+
// keep showing rows the terminal has already dropped.
|
|
282
|
+
//
|
|
283
|
+
// The COUNT is compared first: it is almost always equal, so the common frame
|
|
284
|
+
// never walks the list. The previous version built two filtered copies of
|
|
285
|
+
// `rows` on every frame whether anything had been dropped or not.
|
|
286
|
+
if (this.rows.length !== seen.size) {
|
|
287
|
+
const kept = this.rows.filter((r) => seen.has(r.id));
|
|
288
|
+
if (kept.length !== this.rows.length) {
|
|
289
|
+
this.rows = kept;
|
|
290
|
+
this._rowById = new Map(kept.map((r) => [r.id, r]));
|
|
291
|
+
this.emit({ type: 'rows_reset', rows: this.rows });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (this.rows.length > MAX_ROWS) {
|
|
295
|
+
const dropped = this.rows.splice(0, this.rows.length - MAX_ROWS);
|
|
296
|
+
for (const r of dropped) {
|
|
297
|
+
this._rowById.delete(r.id);
|
|
298
|
+
this._rowFp.delete(r.id);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Update non-transcript session state (context gauge, tasks, todos, model).
|
|
306
|
+
*
|
|
307
|
+
* Broadcasts ONLY when something actually changed. `setStatus` runs on EVERY TUI
|
|
308
|
+
* frame (~12/s), and `webStatus` rebuilds every array and the nested
|
|
309
|
+
* `options`/`pending` objects each time — so identical content always arrives
|
|
310
|
+
* behind FRESH references. A reference compare (`next[k] !== prev[k]`) therefore
|
|
311
|
+
* reported a change every frame, every frame was broadcast, and the browser
|
|
312
|
+
* re-rendered its whole status DOM — the card panes included — 12 times a second
|
|
313
|
+
* while nothing had changed. That idle churn is what showed up as a tab sitting
|
|
314
|
+
* at 800+ MB with typing that stuttered even when the model was not streaming.
|
|
315
|
+
*
|
|
316
|
+
* ANIMATION FIELDS are excluded from the change test. `spin`, `workMsg` and
|
|
317
|
+
* `pulseStart` drive the Working row's pulse and change on every frame while a
|
|
318
|
+
* turn runs, but the ONLY thing that needs them is the pulse — which the browser
|
|
319
|
+
* draws itself with requestAnimationFrame. Broadcasting on each of those changes
|
|
320
|
+
* meant a long turn rebuilt the whole status DOM (and every card pane) once per
|
|
321
|
+
* frame. They ride along in a broadcast a REAL change triggers, never by
|
|
322
|
+
* themselves.
|
|
323
|
+
*
|
|
324
|
+
* Scalars compare by value; objects compare one level deep (arrays element-wise,
|
|
325
|
+
* nested objects by field) with a JSON fallback for anything deeper — exactly
|
|
326
|
+
* the shapes webStatus builds.
|
|
327
|
+
*/
|
|
328
|
+
statusChanged(next) {
|
|
329
|
+
const prev = this.status || {};
|
|
330
|
+
for (const k of Object.keys(next)) {
|
|
331
|
+
if (ANIMATION_FIELDS.has(k)) continue;
|
|
332
|
+
if (!sameValue(next[k], prev[k])) return true;
|
|
333
|
+
}
|
|
334
|
+
// A field present before but dropped from next is also a change.
|
|
335
|
+
for (const k of Object.keys(prev)) {
|
|
336
|
+
if (ANIMATION_FIELDS.has(k)) continue;
|
|
337
|
+
if (!(k in next)) return true;
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
setStatus(patch) {
|
|
343
|
+
const next = { ...(this.status || {}), ...patch };
|
|
344
|
+
if (!this.statusChanged(next)) return this.status;
|
|
345
|
+
this.status = next;
|
|
346
|
+
this.emit({ type: 'status', status: this.status });
|
|
347
|
+
return this.status;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Publish ONE status field as its own event, so a low-frequency change
|
|
352
|
+
* (e.g. a queued message) reaches the browser immediately instead of riding
|
|
353
|
+
* the next whole-status snapshot. Keeps the field in `this.status` too, so a
|
|
354
|
+
* freshly-connected client still gets it via snapshot().
|
|
355
|
+
*/
|
|
356
|
+
pubStatusField(field, value) {
|
|
357
|
+
if (field == null) return this.status;
|
|
358
|
+
const next = { ...(this.status || {}), [field]: value };
|
|
359
|
+
if (!this.statusChanged(next)) return this.status;
|
|
360
|
+
this.status = next;
|
|
361
|
+
const e = { type: field, [field]: value };
|
|
362
|
+
this.emit(e);
|
|
363
|
+
return this.status;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Everything a freshly connected client needs to render the current view.
|
|
368
|
+
*
|
|
369
|
+
* The whole transcript is sent by default. `limit` exists for callers that want
|
|
370
|
+
* a bounded slice (it is a tail), but nothing forces a cap: the browser's
|
|
371
|
+
* windowed list renders only the visible rows, so a long transcript costs one
|
|
372
|
+
* payload rather than one DOM node per row. When a caller DOES cap it, `hidden`
|
|
373
|
+
* reports how much was left out.
|
|
374
|
+
*/
|
|
375
|
+
snapshot(limit) {
|
|
376
|
+
const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : SNAPSHOT_ROWS;
|
|
377
|
+
const start = cap > 0 ? Math.max(0, this.rows.length - cap) : 0;
|
|
378
|
+
return {
|
|
379
|
+
rows: start > 0 ? this.rows.slice(start) : this.rows,
|
|
380
|
+
hidden: start,
|
|
381
|
+
totalRows: this.rows.length,
|
|
382
|
+
status: this.status || {},
|
|
383
|
+
events: this.events.slice(-200),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---- actions (web -> session) ---------------------------------------------
|
|
388
|
+
// Each returns { ok, error } so the HTTP layer can answer honestly instead of
|
|
389
|
+
// pretending an action ran.
|
|
390
|
+
|
|
391
|
+
async call(name, ...args) {
|
|
392
|
+
const fn = this.actions[name];
|
|
393
|
+
if (typeof fn !== 'function') {
|
|
394
|
+
return { ok: false, error: `action "${name}" is not available in this session` };
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
// The handler's return value travels back to the caller. Most actions are
|
|
398
|
+
// fire-and-forget (submit, interrupt) and return undefined, but the ones
|
|
399
|
+
// that READ something — listProviders, getConfig — have nothing else to
|
|
400
|
+
// report with, and dropping it made them look like they did nothing.
|
|
401
|
+
const result = await fn(...args);
|
|
402
|
+
return { ok: true, result };
|
|
403
|
+
} catch (e) {
|
|
404
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
package/src/session.js
CHANGED
|
@@ -10,6 +10,9 @@ import crypto from 'node:crypto';
|
|
|
10
10
|
const DEFAULT_DIR = () => path.join(os.homedir(), '.hncode', 'sessions');
|
|
11
11
|
function dir(custom) { return custom || process.env.HNCODE_SESSIONS_DIR || DEFAULT_DIR(); }
|
|
12
12
|
|
|
13
|
+
/** The resolved session store directory. Exported so the web daemon can read it. */
|
|
14
|
+
export function sessionsDir(custom) { return dir(custom); }
|
|
15
|
+
|
|
13
16
|
// ---- metadata cache --------------------------------------------------------
|
|
14
17
|
// Enumerating sessions (listSessions / latestSession / --continue) re-read every
|
|
15
18
|
// session file to grab its id/title/workspace/timestamps. With hundreds of
|
|
@@ -56,6 +59,34 @@ export function sessionFile(id, storeDir) {
|
|
|
56
59
|
return path.join(dir(storeDir), `${id}.json`);
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/**
|
|
63
|
+
* One `session.messages` entry -> the transcript ROW(s) that mirror it.
|
|
64
|
+
*
|
|
65
|
+
* Returns an ARRAY because an assistant turn with tool calls becomes the
|
|
66
|
+
* assistant row PLUS one `tool` row per call. Both the TUI transcript and the
|
|
67
|
+
* Web UI's read-only view of a finished session render from these rows, so the
|
|
68
|
+
* expansion lives here — in the session data layer — rather than in either
|
|
69
|
+
* front-end. It also keeps the Web daemon off the TUI's import graph: the daemon
|
|
70
|
+
* needs this conversion but must NOT pull in the renderer, the agent and the LLM
|
|
71
|
+
* client to get it.
|
|
72
|
+
*
|
|
73
|
+
* Callers must FLATTEN the result (`flatMap`), not spread it into an object.
|
|
74
|
+
*/
|
|
75
|
+
export function convRowFor(m) {
|
|
76
|
+
if (!m || typeof m !== 'object') return [];
|
|
77
|
+
const text = typeof m.content === 'string' ? m.content : '';
|
|
78
|
+
if (m.role === 'user') return [{ role: 'user', text }];
|
|
79
|
+
if (m.role === 'tool') return [{ role: 'tool_result', text }];
|
|
80
|
+
if (m.role === 'assistant') {
|
|
81
|
+
const rows = [{ role: 'assistant', text }];
|
|
82
|
+
for (const tc of (Array.isArray(m.toolCalls) ? m.toolCalls : [])) {
|
|
83
|
+
if (!tc || typeof tc !== 'object') continue;
|
|
84
|
+
rows.push({ role: 'tool', toolName: tc.name, toolArgs: tc.args || {}, pending: false });
|
|
85
|
+
}
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
59
90
|
// FIX: build the serialized object with a FIXED key order so `updatedAt`
|
|
60
91
|
// always lands BEFORE `messages`. readSessionMeta() stops scanning at the
|
|
61
92
|
// `"messages"` key, so a session whose updatedAt sits after the transcript
|
package/src/term.js
CHANGED
|
@@ -264,10 +264,15 @@ export function estimateTokens(text) {
|
|
|
264
264
|
return Math.ceil(ascii / 4) + nonAscii;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
// Estimate tokens for the
|
|
267
|
+
// Estimate tokens for the MESSAGES only. The system prompt and tool definitions are
|
|
268
|
+
// NOT included: `messages` usually already starts with the system prompt (the TUI
|
|
269
|
+
// builds `[{ role: 'system', content: sysText }, ...]`), so adding `cfg.systemPrompt`
|
|
270
|
+
// on top counted it twice — and the value in `cfg` is the RAW prompt, not the
|
|
271
|
+
// assembled `sysText` (calm/plan/swarm/AGENTS.md appended), so it was also the wrong
|
|
272
|
+
// number. Callers that want the whole request add it themselves via
|
|
273
|
+
// estimateRequestOverhead().
|
|
268
274
|
export function estimateMessagesTokens(messages, cfg) {
|
|
269
|
-
let total =
|
|
270
|
-
if (Array.isArray(cfg && cfg.toolFilter)) total += cfg.toolFilter.length * 8; // rough tool-def overhead
|
|
275
|
+
let total = 0;
|
|
271
276
|
for (const m of messages || []) {
|
|
272
277
|
total += estimateTokens(m.role || '');
|
|
273
278
|
const c = m.content;
|
|
@@ -279,6 +284,17 @@ export function estimateMessagesTokens(messages, cfg) {
|
|
|
279
284
|
return total;
|
|
280
285
|
}
|
|
281
286
|
|
|
287
|
+
// The overhead a request carries BEYOND its messages: the system prompt plus the
|
|
288
|
+
// tool definitions. Passed the assembled system text so the number matches what is
|
|
289
|
+
// actually sent.
|
|
290
|
+
export function estimateRequestOverhead(systemText, cfg) {
|
|
291
|
+
let total = estimateTokens(systemText || '');
|
|
292
|
+
const n = (cfg && Array.isArray(cfg.toolFilter)) ? cfg.toolFilter.length : 0;
|
|
293
|
+
if (n) total += n * 8; // rough per-tool-definition overhead
|
|
294
|
+
return total;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
282
298
|
// Expand TAB characters to spaces at 8-column tab stops. A terminal advances a
|
|
283
299
|
// tab to the NEXT tab stop, but our width math (visualWidth) counts '\t' as 0,
|
|
284
300
|
// so a row containing a tab is padded short and the terminal wraps it onto the
|