@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.
Files changed (146) hide show
  1. package/package.json +4 -2
  2. package/src/agent-task.js +154 -84
  3. package/src/agent.js +533 -145
  4. package/src/clipboard.js +5 -2
  5. package/src/colors.js +242 -238
  6. package/src/config.js +217 -17
  7. package/src/file-history.js +177 -0
  8. package/src/hooks.js +11 -1
  9. package/src/i18n.js +389 -0
  10. package/src/index.js +46 -3
  11. package/src/llm.js +139 -16
  12. package/src/permissions.js +120 -0
  13. package/src/session-hub.js +407 -0
  14. package/src/session.js +31 -0
  15. package/src/term.js +19 -3
  16. package/src/tool-result.js +236 -0
  17. package/src/tools/agent.js +11 -0
  18. package/src/tools/bash.js +84 -17
  19. package/src/tools/edit.js +192 -32
  20. package/src/tools/file-lines.js +5 -0
  21. package/src/tools/index.js +4 -0
  22. package/src/tools/memory.js +88 -0
  23. package/src/tools/read-media-file.js +85 -68
  24. package/src/tools/read.js +15 -1
  25. package/src/tools/utils.js +14 -0
  26. package/src/tools/write.js +35 -5
  27. package/src/tui.js +11665 -9179
  28. package/src/web-auth.js +98 -0
  29. package/src/web-daemon-cli.js +63 -0
  30. package/src/web-daemon-client.js +203 -0
  31. package/src/web-daemon.js +871 -0
  32. package/src/web-public/app.css +1509 -0
  33. package/src/web-public/app.js +2913 -0
  34. package/src/web-public/complete.js +154 -0
  35. package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
  36. package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
  37. package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
  38. package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
  39. package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
  40. package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
  41. package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
  42. package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
  43. package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
  44. package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
  45. package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
  46. package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
  47. package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
  48. package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
  49. package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
  50. package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
  51. package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
  52. package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
  53. package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
  54. package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
  55. package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
  56. package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
  57. package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
  58. package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
  59. package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
  60. package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
  61. package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
  62. package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
  63. package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
  64. package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
  65. package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
  66. package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
  67. package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
  68. package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
  69. package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
  70. package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
  71. package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
  72. package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
  73. package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
  74. package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
  75. package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
  76. package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
  77. package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
  78. package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
  79. package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
  80. package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
  81. package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
  82. package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
  83. package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
  84. package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
  85. package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
  86. package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
  87. package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
  88. package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
  89. package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
  90. package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
  91. package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
  92. package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
  93. package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
  94. package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
  95. package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
  96. package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
  97. package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
  98. package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
  99. package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
  100. package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
  101. package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
  102. package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
  103. package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
  104. package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
  105. package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
  106. package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
  107. package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
  108. package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
  109. package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
  110. package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
  111. package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
  112. package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
  113. package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
  114. package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
  115. package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
  116. package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
  117. package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
  118. package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
  119. package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
  120. package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
  121. package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
  122. package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
  123. package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
  124. package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
  125. package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
  126. package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
  127. package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
  128. package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
  129. package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
  130. package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
  131. package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
  132. package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
  133. package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
  134. package/src/web-public/fonts/noto-sans-sc.css +775 -0
  135. package/src/web-public/fonts.css +31 -0
  136. package/src/web-public/home.css +222 -0
  137. package/src/web-public/home.html +33 -0
  138. package/src/web-public/home.js +160 -0
  139. package/src/web-public/i18n.js +983 -0
  140. package/src/web-public/index.html +196 -0
  141. package/src/web-public/login.css +123 -0
  142. package/src/web-public/login.html +25 -0
  143. package/src/web-public/login.js +46 -0
  144. package/src/web-public/settings.js +290 -0
  145. package/src/web-ws.js +226 -0
  146. package/src/web.js +380 -0
package/src/config.js CHANGED
@@ -5,10 +5,19 @@
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import fs from 'node:fs';
8
+ import crypto from 'node:crypto';
8
9
  import { parse } from './toml.js';
9
10
  import { pluginConfigDefaults } from './plugin.js';
10
11
 
11
- const home = os.homedir();
12
+ // Where the per-user .hncode directory lives. HNCODE_HOME overrides it, mirroring
13
+ // Claude Code's CLAUDE_CONFIG_DIR: it lets a test (or a user keeping config on
14
+ // another drive) relocate the whole directory instead of writing into the real
15
+ // home. Resolved per call, NOT captured once at module load — a test that sets the
16
+ // variable has already imported this module by then, and a captured value silently
17
+ // wrote into the developer's actual home directory.
18
+ function homeDir() {
19
+ return process.env.HNCODE_HOME || os.homedir();
20
+ }
12
21
 
13
22
  export const DEFAULTS = {
14
23
  model: '',
@@ -60,7 +69,7 @@ export async function fetchCatalog() {
60
69
  }
61
70
 
62
71
  export function hncodeConfigFile() {
63
- return process.env.HNCODE_CONFIG || path.join(home, '.hncode', 'config.toml');
72
+ return process.env.HNCODE_CONFIG || path.join(homeDir(), '.hncode', 'config.toml');
64
73
  }
65
74
 
66
75
  // ---- personal preferences (/personal) ----
@@ -72,10 +81,68 @@ export function hncodeConfigFile() {
72
81
  // Both are OPTIONAL; a missing/empty file injects nothing. The files are read
73
82
  // fresh on each turn, so an edit through /personal takes effect immediately.
74
83
  export function personalPromptFile(scope, workspace) {
75
- if (scope === 'global') return path.join(home, '.hncode', 'PERSONAL.md');
84
+ if (scope === 'global') return path.join(homeDir(), '.hncode', 'PERSONAL.md');
76
85
  return path.join(workspace || process.cwd(), '.hncode', 'PERSONAL.md');
77
86
  }
78
87
 
88
+ // ---- agent memory (/memory) --------------------------------------------------
89
+ // Notes the AGENT writes for itself, as opposed to /personal's user-authored
90
+ // preferences. Same shape and same injection points, but a separate file so the
91
+ // two never overwrite each other: the user owns PERSONAL.md, the model owns
92
+ // MEMORY.md. Two scopes, project LAST so it can refine the global one.
93
+ // * global — ~/.hncode/MEMORY.md
94
+ // * project — <workspace>/.hncode/MEMORY.md
95
+ export function memoryFile(scope, workspace) {
96
+ if (scope === 'global') return path.join(homeDir(), '.hncode', 'MEMORY.md');
97
+ return path.join(workspace || process.cwd(), '.hncode', 'MEMORY.md');
98
+ }
99
+
100
+ // Header for injected memory. Deliberately hedged ("notes, verify before relying")
101
+ // so a stale entry written several sessions ago cannot masquerade as fact.
102
+ export const MEMORY_PROMPT_HEADER =
103
+ 'AGENT MEMORY (notes you wrote in earlier sessions; treat as context, not as\n'
104
+ + 'verified fact — re-check anything a current task depends on):';
105
+
106
+ export function readMemoryRaw(scope, workspace) {
107
+ try { return fs.readFileSync(memoryFile(scope, workspace), 'utf8'); } catch { return ''; }
108
+ }
109
+
110
+ export function writeMemoryRaw(scope, workspace, text) {
111
+ const file = memoryFile(scope, workspace);
112
+ const value = String(text == null ? '' : text);
113
+ if (!value.trim()) {
114
+ try { fs.unlinkSync(file); } catch { /* already gone */ }
115
+ return file;
116
+ }
117
+ fs.mkdirSync(path.dirname(file), { recursive: true });
118
+ fs.writeFileSync(file, value.replace(/\s+$/, '') + '\n', 'utf8');
119
+ return file;
120
+ }
121
+
122
+ // Append one note to a scope's memory, creating the file if needed. Appending
123
+ // (rather than replacing) is what a memory tool needs: the agent records a single
124
+ // learning and must not have to re-emit everything it knew before.
125
+ export function appendMemory(scope, workspace, note) {
126
+ const text = String(note == null ? '' : note).trim();
127
+ if (!text) return memoryFile(scope, workspace);
128
+ const existing = readMemoryRaw(scope, workspace).trim();
129
+ const stamp = new Date().toISOString().slice(0, 10);
130
+ const line = `- ${text.replace(/\s+/g, ' ')} _(${stamp})_`;
131
+ const body = existing ? `${existing}\n${line}` : line;
132
+ return writeMemoryRaw(scope, workspace, body);
133
+ }
134
+
135
+ export function readMemory(workspace) {
136
+ const parts = [];
137
+ for (const scope of ['global', 'project']) {
138
+ const t = readMemoryRaw(scope, workspace).trim();
139
+ if (t) parts.push(t);
140
+ }
141
+ if (!parts.length) return '';
142
+ return MEMORY_PROMPT_HEADER + '\n' + parts.join('\n\n');
143
+ }
144
+
145
+
79
146
  // Header placed before the injected preferences, so the model knows where the
80
147
  // text came from and how much authority it has.
81
148
  export const PERSONAL_PROMPT_HEADER =
@@ -193,9 +260,34 @@ function loadBaseToml() {
193
260
  return base;
194
261
  }
195
262
 
263
+
264
+ // A TCP port from config.toml, or `fallback` when it is absent or nonsense. TOML
265
+ // parses `web_port = 8765` as a number but `web_port = "8765"` as a string, and a
266
+ // hand-edited file can hold anything, so both are accepted and everything else
267
+ // falls back rather than making the daemon fail to bind on a garbage port.
268
+ export function tomlPort(value, fallback) {
269
+ if (value === undefined || value === null || value === '') return fallback;
270
+ const n = Number(value);
271
+ if (!Number.isFinite(n) || n < 0 || n > 65535) return fallback;
272
+ return Math.floor(n);
273
+ }
274
+
275
+ // A 0-1 ratio from config.toml / env. Accepts a number (0.85), a whole number
276
+ // (85 = 85%), or a percent string ("85%"). Anything outside (0,1) falls back.
277
+ export function clampRatio(value, fallback) {
278
+ if (value === undefined || value === null || value === '') return fallback;
279
+ const s = String(value).trim();
280
+ const pct = s.endsWith('%');
281
+ const n = parseFloat(s);
282
+ if (!Number.isFinite(n)) return fallback;
283
+ const v = pct ? n / 100 : (n > 1 ? n / 100 : n);
284
+ return (v > 0 && v < 1) ? v : fallback;
285
+ }
286
+
196
287
  export function resolveConfig() {
197
288
  const root = loadBaseToml() || {};
198
289
  // Merge plugin-provided defaults UNDER the user's config: an explicitly set
290
+ // Merge plugin-provided defaults UNDER the user's config: an explicitly set
199
291
  // user value always wins.
200
292
  for (const [k, v] of Object.entries(pluginConfigDefaults || {})) {
201
293
  if (!(k in root)) root[k] = v;
@@ -250,7 +342,7 @@ const b = baseUrl.replace(/\/+$/, '');
250
342
  // `contextLength` (discovered from the provider's /v1/models), then the
251
343
  // default. This is what stops every model from being pinned to 128k.
252
344
  const overrideContext = process.env.HNCODE_MAX_CONTEXT || root.max_context_size || root.max_context_tokens;
253
- const modelContext = Number(modelEntry.contextLength || modelEntry.context_length || modelEntry.max_context_size || 0);
345
+ const modelContext = Number(modelEntry.contextLength || modelEntry.context_length || modelEntry.context_window || modelEntry.max_context_size || 0);
254
346
  const maxContext = Number(overrideContext || modelContext || DEFAULTS.maxContextTokens);
255
347
  const maxOutput = Number(process.env.HNCODE_MAX_OUTPUT || root.max_output_size || DEFAULTS.maxOutputTokens);
256
348
  const reasoning = process.env.HNCODE_REASONING ? /^(1|true|yes)$/i.test(process.env.HNCODE_REASONING) : !!root.reasoning;
@@ -262,10 +354,32 @@ const b = baseUrl.replace(/\/+$/, '');
262
354
  maxContextTokens: maxContext, maxOutputTokens: maxOutput, reasoning,
263
355
  workspace: process.env.HNCODE_WORKSPACE || root.workspace || DEFAULTS.workspace,
264
356
  allowExternal,
357
+ // Standing permission rules (see permissions.js). `[permissions]` in config.toml:
358
+ // [permissions]
359
+ // allow = ["Bash(npm run test *)", "Read(./src/**)"]
360
+ // ask = ["Bash(git push *)"]
361
+ // deny = ["Read(./.env)"]
362
+ // Checked before the mode rules, so a deny cannot be escaped by switching to
363
+ // Auto and an explicit ask still prompts inside it.
364
+ permissions: {
365
+ allow: Array.isArray(root.permissions && root.permissions.allow) ? root.permissions.allow.map(String) : [],
366
+ ask: Array.isArray(root.permissions && root.permissions.ask) ? root.permissions.ask.map(String) : [],
367
+ deny: Array.isArray(root.permissions && root.permissions.deny) ? root.permissions.deny.map(String) : [],
368
+ },
265
369
  // A user-supplied system prompt (set via /set-system-prompt). Empty means
266
370
  // "use the built-in SYSTEM_PROMPT". Read from config.toml so it persists
267
371
  // across restarts.
268
372
  systemPrompt: process.env.HNCODE_SYSTEM_PROMPT || root.system_prompt || '',
373
+ // ---- Web UI daemon -------------------------------------------------------
374
+ // One daemon serves EVERY hncode session on this machine: it holds the fixed
375
+ // port, proxies each session's requests to that session's own loopback
376
+ // server, and reads the sessions on disk for the ones whose TUI has exited.
377
+ // These live in config.toml so the address and the token stay the SAME across
378
+ // restarts — a browser bookmark and a saved token keep working, which is the
379
+ // whole point of a single global UI.
380
+ webHost: process.env.HNCODE_WEB_HOST || root.web_host || '127.0.0.1',
381
+ webPort: tomlPort(process.env.HNCODE_WEB_PORT || root.web_port, 8765),
382
+ webToken: process.env.HNCODE_WEB_TOKEN || root.web_token || '',
269
383
  // CALM MODE (/calm-mode): persisted boolean. When true an instruction is
270
384
  // appended to the system prompt to suppress narration.
271
385
  calmMode: process.env.HNCODE_CALM_MODE
@@ -287,12 +401,43 @@ const b = baseUrl.replace(/\/+$/, '');
287
401
  ? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_UPDATE)
288
402
  : (root.auto_update === true || root.auto_update === 'true'),
289
403
  // AUTO-COMPACTION (/auto-compact): when true, the agent summarizes older
290
- // history once a request reaches 85% of the model context. Default ON. Set
291
- // `auto_compact = false` (or HNCODE_AUTO_COMPACT=0) to turn it off.
404
+ // history once a request reaches the trigger ratio of the model context.
405
+ // Default ON. Set `auto_compact = false` (or HNCODE_AUTO_COMPACT=0) to turn it off.
292
406
  autoCompact: process.env.HNCODE_AUTO_COMPACT
293
407
  ? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_COMPACT)
294
408
  : !(root.auto_compact === false || root.auto_compact === 'false'),
295
- // PROMPT CACHE (/cache): explicit cache breakpoints for long stable prefixes.
409
+ // COMPACTION TUNING (config.toml):
410
+ // compact_threshold — fraction of the model window at which auto-compaction
411
+ // fires (default 0.85 = 85%).
412
+ // compact_keep_ratio — fraction of the CURRENT usage to KEEP after a
413
+ // compaction (default 0.2 = keep ~20% of what was used).
414
+ // Both accept a number (0-1) or a percent string ("85%"). Env overrides:
415
+ // HNCODE_COMPACT_THRESHOLD / HNCODE_COMPACT_KEEP_RATIO.
416
+ compactThreshold: clampRatio(
417
+ process.env.HNCODE_COMPACT_THRESHOLD || root.compact_threshold, 0.85,
418
+ ),
419
+ compactKeepRatio: clampRatio(
420
+ process.env.HNCODE_COMPACT_KEEP_RATIO || root.compact_keep_ratio, 0.2,
421
+ ),
422
+ // TOOL-RESULT TRIMMING (config.toml), the cheaper cousin of compaction: old
423
+ // tool outputs are elided from the REQUEST (never from the saved history) once
424
+ // it grows past the trigger, down to the keep fraction of the tool-result text.
425
+ // auto_trim — ON by default; `false` never trims automatically.
426
+ // trim_threshold — fire when the request reaches this fraction of the
427
+ // model window (default 0.5 = 50%).
428
+ // trim_keep_ratio — fraction of the tool-result text to KEEP (default
429
+ // 0.3 = keep 30%, so ~70% of it is elided).
430
+ // Both accept a number (0-1) or a percent string ("50%"). Env overrides:
431
+ // HNCODE_AUTO_TRIM / HNCODE_TRIM_THRESHOLD / HNCODE_TRIM_KEEP_RATIO.
432
+ autoTrim: process.env.HNCODE_AUTO_TRIM
433
+ ? /^(1|true|yes|on)$/i.test(process.env.HNCODE_AUTO_TRIM)
434
+ : !(root.auto_trim === false || root.auto_trim === 'false'),
435
+ trimThreshold: clampRatio(
436
+ process.env.HNCODE_TRIM_THRESHOLD || root.trim_threshold, 0.5,
437
+ ),
438
+ trimKeepRatio: clampRatio(
439
+ process.env.HNCODE_TRIM_KEEP_RATIO || root.trim_keep_ratio, 0.3,
440
+ ),
296
441
  // Default ON — it only ever adds provider-recognised markers, and an
297
442
  // unsupported field on a non-caching gateway is filtered out in cache.js.
298
443
  // Set HNCODE_PROMPT_CACHE=0 or `prompt_cache = false` to disable.
@@ -405,8 +550,47 @@ export function setConfigString(key, value) {
405
550
  fs.mkdirSync(path.dirname(file), { recursive: true });
406
551
  fs.writeFileSync(file, out.join('\n') + (out.length ? '\n' : ''), 'utf8');
407
552
  return file;
553
+ return file;
554
+ }
555
+
556
+ // The Web UI's access token, generated ONCE and persisted to config.toml.
557
+ //
558
+ // It is deliberately not regenerated per launch: the daemon outlives individual
559
+ // sessions, and a token that changed on every restart would invalidate every
560
+ // browser's saved cookie and force a re-login each time — which defeats having a
561
+ // single stable UI. `HNCODE_WEB_TOKEN` overrides for scripted setups.
562
+ export function ensureWebToken() {
563
+ const fromEnv = (process.env.HNCODE_WEB_TOKEN || '').trim();
564
+ if (fromEnv) return fromEnv;
565
+ const cfg = resolveConfig();
566
+ if (cfg.webToken) return cfg.webToken;
567
+ // 32 random bytes, base64url: 43 chars, URL- and cookie-safe, ~256 bits.
568
+ const token = crypto.randomBytes(32).toString('base64url');
569
+ try { setConfigString('web_token', token); }
570
+ catch { /* read-only config: the token lives for this run only */ }
571
+ return token;
572
+ }
573
+
574
+ // Write a boolean root-level key as a TOML LITERAL (`key = true`), not a string.
575
+
576
+ // Write a boolean root-level key as a TOML LITERAL (`key = true`), not a string.
577
+ // setConfigString() renders every value as a quoted string, so `true` would land
578
+ // as `"true"` — which reads back as a truthy string rather than a boolean, and
579
+ // would make `key = false` truthy. Reuses setConfigString for the surgery (it
580
+ // already knows how to replace a key in place and where a new root key goes), then
581
+ // rewrites the one line it produced.
582
+ export function setConfigBool(key, value) {
583
+ const file = setConfigString(key, String(!!value));
584
+ let text = fs.readFileSync(file, 'utf8');
585
+ const wanted = `${key} = ${value ? 'true' : 'false'}`;
586
+ const re = new RegExp('^\\s*' + escapeRe(key) + '\\s*=\\s*.*$', 'm');
587
+ if (re.test(text)) text = text.replace(re, wanted);
588
+ else text = wanted + '\n' + text;
589
+ fs.writeFileSync(file, text, 'utf8');
590
+ return file;
408
591
  }
409
592
 
593
+
410
594
  // Render `key = <value>` as TOML. Uses a multi-line basic string when the value
411
595
  // contains newlines (readable, and TOML-valid), a plain one otherwise.
412
596
  //
@@ -544,9 +728,16 @@ function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
544
728
  // Fetch the model list from a provider's API. The base URL is used as given and
545
729
  // we append only `/models` (so `https://host/v1` → `https://host/v1/models`).
546
730
  // Anthropic sends x-api-key + anthropic-version. Returns [{id, display}] or [].
547
- export async function fetchModels({ baseUrl, apiKey, protocol }) {
731
+ export async function fetchModels({ baseUrl, apiKey, protocol, throwOnError = false }) {
548
732
  const b = String(baseUrl || '').replace(/\/+$/, '');
549
- if (!b) return [];
733
+ if (!b) {
734
+ // `throwOnError` makes a failure LOUD instead of an empty list. The model
735
+ // pickers use the quiet form — "no models" is a normal state there — but a
736
+ // user who pressed "discover models" and got nothing needs to know whether
737
+ // the endpoint refused the key or simply has no catalogue.
738
+ if (throwOnError) throw new Error('no base URL');
739
+ return [];
740
+ }
550
741
  const url = `${b}/models`;
551
742
  const headers = { 'content-type': 'application/json' };
552
743
  if (protocol === 'anthropic') {
@@ -559,7 +750,10 @@ export async function fetchModels({ baseUrl, apiKey, protocol }) {
559
750
  const timer = setTimeout(() => ctrl.abort(), 15000);
560
751
  try {
561
752
  const res = await fetch(url, { headers, signal: ctrl.signal });
562
- if (!res.ok) return [];
753
+ if (!res.ok) {
754
+ if (throwOnError) throw new Error(`${res.status} ${res.statusText || ''}`.trim());
755
+ return [];
756
+ }
563
757
  const json = await res.json();
564
758
  // OpenAI: { data: [{ id }] } | Anthropic: { data: [{ id, display_name }] }
565
759
  const arr = Array.isArray(json.data) ? json.data : (Array.isArray(json.models) ? json.models : []);
@@ -567,7 +761,7 @@ export async function fetchModels({ baseUrl, apiKey, protocol }) {
567
761
  id: String(m.id || m.name || ''),
568
762
  display: m.display_name || m.displayName || '',
569
763
  // Capability hints (servers vary in what they expose).
570
- contextLength: m.context_length || m.contextLength || undefined,
764
+ contextLength: m.context_length || m.contextLength || m.context_window || undefined,
571
765
  maxTokens: m.max_tokens || undefined, // NEW: per-model token limit from /v1/models
572
766
  maxOutputTokens: m.max_output_tokens || m.maxOutputTokens || undefined,
573
767
  ownedBy: m.owned_by || undefined,
@@ -577,10 +771,14 @@ export async function fetchModels({ baseUrl, apiKey, protocol }) {
577
771
  || undefined,
578
772
  efforts: m.reasoning_efforts || m.supported_efforts || undefined,
579
773
  })).filter((m) => m.id);
580
- } catch { return []; }
581
- finally { clearTimeout(timer); }
774
+ } catch (e) {
775
+ if (throwOnError) throw e;
776
+ return [];
777
+ } finally { clearTimeout(timer); }
582
778
  }
583
779
 
780
+ // ---- thinking effort capability ----
781
+
584
782
  // ---- thinking effort capability ----
585
783
  // The set of selectable efforts for a model. Honours, in order:
586
784
  // 1. an explicit `efforts` / `reasoning_efforts` list on the model entry
@@ -643,10 +841,12 @@ export function bareModelId(provider, key, entry) {
643
841
  const e = entry || {};
644
842
  const providerName = e.provider || provider || '';
645
843
  const k = String(key || '');
646
- // API 只需要 model id 本身(可能带子前缀,如 qoder/deepseek-flash),
647
- // 不要 hncode 的 provider 前缀(traebuddy/)。所以剥掉 "<provider>/"。
844
+ // The API wants the model id on its own (it may carry a sub-prefix like
845
+ // "qoder/deepseek-flash"), NOT hncode's provider prefix ("traebuddy/"), so
846
+ // "<provider>/" is stripped off.
648
847
  if (providerName && k.startsWith(providerName + '/')) return k.slice(providerName.length + 1);
649
- // key 没有 provider 前缀时,显式 model 字段优先;否则用 key 本身。
848
+ // When the key has no provider prefix, an explicit `model` field wins;
849
+ // otherwise the key itself is the id.
650
850
  const fromField = typeof e.model === 'string' ? e.model : '';
651
851
  return fromField || k;
652
852
  }
@@ -729,7 +929,7 @@ function contextForModel(cfg, modelName) {
729
929
  const models = (root.models) || {};
730
930
  const overrideContext = process.env.HNCODE_MAX_CONTEXT || root.max_context_size || root.max_context_tokens;
731
931
  const entry = models[modelName] || {};
732
- const modelContext = Number(entry.contextLength || entry.context_length || entry.max_context_size || 0);
932
+ const modelContext = Number(entry.contextLength || entry.context_length || entry.context_window || entry.max_context_size || 0);
733
933
  return Number(overrideContext || modelContext || DEFAULTS.maxContextTokens);
734
934
  }
735
935
 
@@ -0,0 +1,177 @@
1
+ // File checkpoints for /undo.
2
+ //
3
+ // The old /undo only rewound the CONVERSATION: it truncated the transcript and
4
+ // said "Undid N prompts", while every Edit/Write the agent had made stayed on
5
+ // disk. That reads as a lie — the user believes the work was withdrawn and then
6
+ // finds their files still rewritten. This module makes the rewind real: the
7
+ // content a file had BEFORE the agent touched it is kept, and /undo puts it back
8
+ // (and deletes a file the agent created).
9
+ //
10
+ // The model follows Claude Code's fileHistory (src/utils/fileHistory.ts):
11
+ // * a backup is taken per TURN, lazily, on the first change to a file within
12
+ // that turn — so the recorded version is exactly the pre-turn state;
13
+ // * one entry per user turn is recorded even when nothing changed, so the
14
+ // index lines up with the user prompts /undo counts;
15
+ // * backups are content copies under ~/.hncode/file-history/<sessionId>/, so a
16
+ // rewind cannot fail on a file that has since been edited by hand (it just
17
+ // overwrites it back).
18
+ //
19
+ // Only files the agent itself modified are ever touched. A file the user edited
20
+ // in their own editor during the turn is not in the index and is left alone.
21
+
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import crypto from 'node:crypto';
26
+
27
+ function homeDir() {
28
+ return process.env.HNCODE_HOME || os.homedir();
29
+ }
30
+
31
+ export function historyDir(sessionId) {
32
+ return path.join(homeDir(), '.hncode', 'file-history', String(sessionId || 'unscoped'));
33
+ }
34
+
35
+ function indexPath(sessionId) {
36
+ return path.join(historyDir(sessionId), 'index.json');
37
+ }
38
+
39
+ // Read the index. A missing or corrupt file yields an empty one rather than a
40
+ // throw: checkpoints are a safety net, and losing them must not break /undo's
41
+ // conversation rewind.
42
+ function readIndex(sessionId) {
43
+ try {
44
+ const doc = JSON.parse(fs.readFileSync(indexPath(sessionId), 'utf8'));
45
+ if (doc && Array.isArray(doc.turns)) {
46
+ for (const t of doc.turns) if (!Array.isArray(t.files)) t.files = [];
47
+ return doc;
48
+ }
49
+ } catch { /* first run, or the file was removed */ }
50
+ return { turns: [] };
51
+ }
52
+
53
+ function writeIndex(sessionId, doc) {
54
+ const file = indexPath(sessionId);
55
+ try {
56
+ fs.mkdirSync(path.dirname(file), { recursive: true });
57
+ // Same atomic-replace shape as session.js: a crash mid-write must not leave a
58
+ // half-written index that a later read would have to guess at.
59
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`;
60
+ fs.writeFileSync(tmp, JSON.stringify(doc), 'utf8');
61
+ fs.renameSync(tmp, file);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // Start a new turn: append an (initially empty) entry and return its id. The id
69
+ // is just the turn's position, which is what /undo counts in.
70
+ export function beginTurn(sessionId) {
71
+ const doc = readIndex(sessionId);
72
+ doc.turns.push({ at: Date.now(), files: [] });
73
+ if (doc.turns.length > 200) doc.turns.splice(0, doc.turns.length - 200);
74
+ writeIndex(sessionId, doc);
75
+ return sessionId == null ? null : doc.turns.length - 1;
76
+ }
77
+
78
+ // Record `filePath`'s CURRENT content as the version to restore, ONCE per turn —
79
+ // called immediately before the agent writes, so what is captured is the state
80
+ // before any of this turn's changes.
81
+ //
82
+ // Returns true when a backup was taken (or already existed for this turn).
83
+ export function recordBeforeWrite(sessionId, filePath) {
84
+ const abs = path.resolve(filePath);
85
+ const doc = readIndex(sessionId);
86
+ const turn = doc.turns[doc.turns.length - 1];
87
+ // No turn open (the caller forgot beginTurn): open one so the backup still has a
88
+ // home, rather than dropping it and silently losing the ability to rewind.
89
+ if (!turn) { beginTurn(sessionId); return recordBeforeWrite(sessionId, filePath); }
90
+ if (turn.files.some((f) => f.path === abs)) return true;
91
+
92
+ let existed = true;
93
+ let content = null;
94
+ try {
95
+ content = fs.readFileSync(abs);
96
+ } catch (e) {
97
+ if (e && e.code === 'ENOENT') existed = false;
98
+ else return false; // unreadable: do not claim a checkpoint we cannot honour
99
+ }
100
+
101
+ let backup = '';
102
+ if (existed) {
103
+ const dir = historyDir(sessionId);
104
+ try {
105
+ fs.mkdirSync(dir, { recursive: true });
106
+ const name = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 16)
107
+ + '-' + crypto.randomBytes(3).toString('hex') + '.bak';
108
+ backup = path.join(dir, name);
109
+ fs.writeFileSync(backup, content);
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+ turn.files.push({ path: abs, existed, backup });
115
+ return writeIndex(sessionId, doc);
116
+ }
117
+
118
+ // How many turns are on record.
119
+ export function turnCount(sessionId) {
120
+ return readIndex(sessionId).turns.length;
121
+ }
122
+
123
+ /**
124
+ * Rewind `count` turns: put every file the agent changed back to how it was
125
+ * before those turns, and delete any file the agent created in them.
126
+ *
127
+ * Walks the turns from newest to oldest and, for each file, keeps the version
128
+ * recorded EARLIEST among the rewound turns — that is the pre-turn state. Returns
129
+ * { restored: [path], deleted: [path], turns: n, failed: [{path, error}] }.
130
+ */
131
+ export function rewind(sessionId, count = 1) {
132
+ const doc = readIndex(sessionId);
133
+ const n = Math.max(0, Math.min(Number(count) || 0, doc.turns.length));
134
+ const out = { restored: [], deleted: [], turns: 0, failed: [] };
135
+ if (!n) return out;
136
+
137
+ const rewound = doc.turns.slice(doc.turns.length - n);
138
+ // For each file, keep the FIRST record seen walking oldest -> newest: that is
139
+ // the content it had before the first of the rewound turns touched it. Taking
140
+ // the newest instead would restore a halfway state (the version left by the
141
+ // previous rewound turn). A file CREATED in the oldest rewound turn records
142
+ // `existed: false`, which correctly wins over the copies taken in later turns.
143
+ const chosen = new Map();
144
+ for (const turn of rewound) {
145
+ for (const f of turn.files) if (!chosen.has(f.path)) chosen.set(f.path, f);
146
+ }
147
+ const ordered = [...chosen.entries()];
148
+
149
+ for (const [file, rec] of ordered) {
150
+ try {
151
+ if (rec.existed === false) {
152
+ // The agent created it in one of these turns: it did not exist before.
153
+ fs.rmSync(file, { force: true });
154
+ out.deleted.push(file);
155
+ } else {
156
+ fs.mkdirSync(path.dirname(file), { recursive: true });
157
+ fs.copyFileSync(rec.backup, file);
158
+ out.restored.push(file);
159
+ }
160
+ } catch (e) {
161
+ out.failed.push({ path: file, error: e && e.message ? e.message : String(e) });
162
+ }
163
+ }
164
+ doc.turns.splice(doc.turns.length - n, n);
165
+ writeIndex(sessionId, doc);
166
+ out.turns = n;
167
+ return out;
168
+ }
169
+
170
+ // Human-readable summary for the /undo notice.
171
+ export function describeRewind(res) {
172
+ const parts = [];
173
+ if (res.restored.length) parts.push(`${res.restored.length} file(s) restored`);
174
+ if (res.deleted.length) parts.push(`${res.deleted.length} created file(s) removed`);
175
+ if (res.failed.length) parts.push(`${res.failed.length} could not be restored`);
176
+ return parts.join(', ');
177
+ }
package/src/hooks.js CHANGED
@@ -8,8 +8,13 @@
8
8
  // "hooks": {
9
9
  // "PreToolUse": [{ "matcher": "Edit|Write", "command": "npm run lint" }],
10
10
  // "PostToolUse": [{ "matcher": "Edit", "command": "npx prettier --write $FILE" }],
11
+ // "PostToolUseFailure": [{ "matcher": "Bash", "command": "notify-fail.sh" }],
11
12
  // "UserPromptSubmit": [{ "command": "echo submitting" }],
12
13
  // "SessionStart": [{ "command": "git fetch --quiet" }],
14
+ // "SessionEnd": [{ "command": "echo bye >> ~/sessions.log" }],
15
+ // "SubagentStop": [{ "command": "echo sub done" }],
16
+ // "PostCompact": [{ "command": "archive-summary.sh" }],
17
+ // "Notification": [{ "command": "notify-send hncode" }],
13
18
  // "Stop": [{ "command": "echo turn done >> ~/turns.log" }]
14
19
  // }
15
20
  // }
@@ -39,11 +44,16 @@ import cp from 'node:child_process';
39
44
  // of silently never firing.
40
45
  export const HOOK_EVENTS = [
41
46
  'SessionStart',
47
+ 'SessionEnd',
42
48
  'UserPromptSubmit',
43
49
  'PreToolUse',
44
50
  'PostToolUse',
51
+ 'PostToolUseFailure',
45
52
  'Stop',
53
+ 'SubagentStop',
46
54
  'PreCompact',
55
+ 'PostCompact',
56
+ 'Notification',
47
57
  ];
48
58
 
49
59
  // A hook that hangs must not hang the agent. 60s is generous for a formatter or
@@ -209,7 +219,7 @@ export async function runShellHooks(config, event, payload, cwd) {
209
219
  // for Edit, for BOTH PreToolUse and PostToolUse. Applying it to PreToolUse
210
220
  // alone made a PostToolUse hook run on every tool — a "format after edit" hook
211
221
  // ran after reads and searches too.
212
- const isToolEvent = event === 'PreToolUse' || event === 'PostToolUse';
222
+ const isToolEvent = event === 'PreToolUse' || event === 'PostToolUse' || event === 'PostToolUseFailure';
213
223
  const results = [];
214
224
  for (const entry of list) {
215
225
  if (isToolEvent && !matcherApplies(entry.matcher, payload && payload.toolName)) continue;