sanook-cli 0.5.2 → 0.5.7

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 (127) hide show
  1. package/CHANGELOG.md +112 -2
  2. package/README.md +15 -3
  3. package/README.th.md +8 -1
  4. package/dist/approval.js +7 -0
  5. package/dist/bin.js +637 -56
  6. package/dist/brain-consolidate.js +335 -0
  7. package/dist/brain-context.js +42 -3
  8. package/dist/brain-final.js +15 -9
  9. package/dist/brain-link.js +73 -0
  10. package/dist/brain-metrics.js +277 -0
  11. package/dist/brain-new.js +402 -0
  12. package/dist/brain-pack.js +210 -0
  13. package/dist/brain-repair.js +280 -0
  14. package/dist/brain.js +3 -0
  15. package/dist/brand.js +4 -0
  16. package/dist/cli-args.js +47 -9
  17. package/dist/cli-option-values.js +1 -1
  18. package/dist/clipboard.js +65 -0
  19. package/dist/commands.js +98 -15
  20. package/dist/config.js +66 -34
  21. package/dist/context-pack.js +145 -0
  22. package/dist/cost.js +20 -0
  23. package/dist/dashboard/api-helpers.js +87 -0
  24. package/dist/dashboard/server.js +179 -0
  25. package/dist/dashboard/static/app.js +277 -0
  26. package/dist/dashboard/static/index.html +39 -0
  27. package/dist/dashboard/static/styles.css +85 -0
  28. package/dist/diff.js +10 -2
  29. package/dist/gateway/auth.js +14 -3
  30. package/dist/gateway/deliver.js +45 -3
  31. package/dist/gateway/doctor.js +456 -0
  32. package/dist/gateway/email.js +30 -1
  33. package/dist/gateway/ledger.js +20 -1
  34. package/dist/gateway/session.js +34 -11
  35. package/dist/hotkeys.js +21 -0
  36. package/dist/i18n/en.js +98 -0
  37. package/dist/i18n/index.js +19 -0
  38. package/dist/i18n/th.js +98 -0
  39. package/dist/i18n/types.js +1 -0
  40. package/dist/insights-args.js +24 -4
  41. package/dist/knowledge.js +55 -29
  42. package/dist/loop.js +65 -9
  43. package/dist/mcp-hub.js +33 -0
  44. package/dist/mcp-registry.js +153 -9
  45. package/dist/mcp-risk.js +71 -0
  46. package/dist/mcp.js +77 -5
  47. package/dist/memory-log.js +90 -0
  48. package/dist/memory-store.js +37 -1
  49. package/dist/memory.js +51 -7
  50. package/dist/model-picker.js +58 -0
  51. package/dist/orchestrate.js +7 -5
  52. package/dist/plan-handoff.js +17 -0
  53. package/dist/polyglot.js +162 -0
  54. package/dist/process-runner.js +96 -0
  55. package/dist/project-init.js +91 -0
  56. package/dist/project-registry.js +143 -0
  57. package/dist/project-scaffold.js +124 -0
  58. package/dist/prompt-size.js +155 -0
  59. package/dist/providers/codex-login.js +138 -0
  60. package/dist/providers/codex.js +20 -8
  61. package/dist/providers/keys.js +21 -0
  62. package/dist/providers/models.js +1 -1
  63. package/dist/providers/registry.js +11 -1
  64. package/dist/search/cli.js +9 -1
  65. package/dist/search/embedding-config.js +22 -0
  66. package/dist/search/engine.js +2 -13
  67. package/dist/search/indexer.js +10 -10
  68. package/dist/session-brain.js +103 -0
  69. package/dist/session-distill.js +84 -0
  70. package/dist/session.js +1 -11
  71. package/dist/skill-install.js +24 -1
  72. package/dist/skills.js +33 -0
  73. package/dist/slash-completion.js +155 -0
  74. package/dist/support-dump.js +31 -0
  75. package/dist/tool-catalog.js +59 -0
  76. package/dist/tools/index.js +5 -0
  77. package/dist/tools/permission.js +82 -16
  78. package/dist/tools/polyglot.js +126 -0
  79. package/dist/tools/sandbox.js +38 -13
  80. package/dist/tools/search.js +9 -2
  81. package/dist/tools/task.js +22 -2
  82. package/dist/tools/timeout.js +7 -5
  83. package/dist/tools/web-fetch-tool.js +33 -0
  84. package/dist/turn-retrieval.js +83 -0
  85. package/dist/ui/app.js +874 -35
  86. package/dist/ui/banner.js +78 -4
  87. package/dist/ui/markdown.js +122 -0
  88. package/dist/ui/overlay.js +496 -0
  89. package/dist/ui/queue.js +23 -0
  90. package/dist/ui/render.js +30 -2
  91. package/dist/ui/session-panel.js +115 -0
  92. package/dist/ui/setup-providers.js +40 -0
  93. package/dist/ui/setup.js +163 -50
  94. package/dist/ui/status.js +142 -0
  95. package/dist/ui/thinking-panel.js +36 -0
  96. package/dist/ui/tool-trail.js +97 -0
  97. package/dist/ui/transcript.js +26 -0
  98. package/dist/ui/useBusyElapsed.js +19 -0
  99. package/dist/ui/useEditor.js +144 -5
  100. package/dist/ui/useGitBranch.js +57 -0
  101. package/dist/update.js +32 -6
  102. package/dist/usage-cli.js +160 -0
  103. package/dist/usage-ledger.js +169 -0
  104. package/dist/web-fetch.js +637 -0
  105. package/dist/web-surface.js +190 -0
  106. package/package.json +4 -3
  107. package/scripts/postinstall.mjs +4 -4
  108. package/second-brain/Projects/_Index.md +17 -4
  109. package/second-brain/Projects/sanook-cli/_Index.md +7 -3
  110. package/second-brain/Projects/sanook-cli/context.md +35 -0
  111. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  112. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  113. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  114. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
  115. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  116. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  117. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  118. package/second-brain/Research/_Index.md +2 -0
  119. package/second-brain/Shared/Operating-State/current-state.md +14 -23
  120. package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
  121. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  122. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  123. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  124. package/second-brain/Templates/project-workspace/context.md +28 -0
  125. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  126. package/second-brain/Templates/project-workspace/overview.md +39 -0
  127. package/second-brain/Templates/project-workspace/repo.md +33 -0
@@ -0,0 +1,97 @@
1
+ import { inspect } from 'node:util';
2
+ export const TOOL_TRAIL_LIMIT = 6;
3
+ function clip(text, width) {
4
+ if (width <= 0)
5
+ return '';
6
+ return text.length > width ? `${text.slice(0, Math.max(0, width - 3))}...` : text;
7
+ }
8
+ function normalizeWhitespace(text) {
9
+ return text.replace(/\s+/g, ' ').trim();
10
+ }
11
+ export function compactToolDetail(detail, width = 64) {
12
+ if (detail === undefined || detail === null)
13
+ return '';
14
+ const text = typeof detail === 'string'
15
+ ? detail
16
+ : (() => {
17
+ try {
18
+ return JSON.stringify(detail);
19
+ }
20
+ catch {
21
+ return inspect(detail, { breakLength: Infinity, depth: 2 });
22
+ }
23
+ })();
24
+ return clip(normalizeWhitespace(text), width);
25
+ }
26
+ function trimItems(items) {
27
+ return items.slice(Math.max(0, items.length - TOOL_TRAIL_LIMIT));
28
+ }
29
+ function latestRunningToolIndex(items, tool) {
30
+ for (let i = items.length - 1; i >= 0; i -= 1) {
31
+ const item = items[i];
32
+ if (item.status !== 'running')
33
+ continue;
34
+ if (!tool || item.name === tool)
35
+ return i;
36
+ }
37
+ return -1;
38
+ }
39
+ export function updateToolTrailOnEvent(items, event, nextId) {
40
+ if (event.type === 'tool-call') {
41
+ const name = event.tool?.trim() || 'tool';
42
+ return {
43
+ items: trimItems([...items, { detail: compactToolDetail(event.detail), id: nextId, name, status: 'running' }]),
44
+ nextId: nextId + 1,
45
+ };
46
+ }
47
+ if (event.type === 'tool-result') {
48
+ const index = latestRunningToolIndex(items, event.tool);
49
+ const detail = compactToolDetail(event.detail);
50
+ if (index === -1) {
51
+ const name = event.tool?.trim() || 'tool';
52
+ return {
53
+ items: trimItems([...items, { detail, id: nextId, name, status: 'done' }]),
54
+ nextId: nextId + 1,
55
+ };
56
+ }
57
+ const next = [...items];
58
+ next[index] = { ...next[index], detail: detail || next[index].detail, status: 'done' };
59
+ return { items: trimItems(next), nextId };
60
+ }
61
+ const index = latestRunningToolIndex(items);
62
+ const detail = compactToolDetail(event.detail ?? event.text);
63
+ if (index === -1)
64
+ return { items, nextId };
65
+ const next = [...items];
66
+ next[index] = { ...next[index], detail: detail || next[index].detail, status: 'error' };
67
+ return { items: trimItems(next), nextId };
68
+ }
69
+ function markerForStatus(status) {
70
+ return status === 'running' ? '>' : status === 'done' ? '+' : '!';
71
+ }
72
+ function statusSummary(items) {
73
+ const running = items.filter((item) => item.status === 'running').length;
74
+ const done = items.filter((item) => item.status === 'done').length;
75
+ const error = items.filter((item) => item.status === 'error').length;
76
+ return [`${done} done`, running ? `${running} running` : '', error ? `${error} error` : ''].filter(Boolean).join(' / ');
77
+ }
78
+ export function toolTrailLines(items, columns, mode = 'expanded') {
79
+ if (mode === 'hidden')
80
+ return [];
81
+ if (!items.length)
82
+ return [];
83
+ const width = Math.max(24, Math.min(Math.max(30, columns - 4), 96));
84
+ const nameWidth = Math.max(8, Math.min(24, Math.floor(width * 0.34)));
85
+ const detailWidth = Math.max(0, width - nameWidth - 18);
86
+ const lines = [`Sanook tool trail (${items.length})`, `view: ${mode} | ${statusSummary(items)} | Ctrl+T / /trail`];
87
+ if (mode === 'compact') {
88
+ lines.push(`tools: ${items.map((item) => `${markerForStatus(item.status)}${item.name}`).join(' ')}`);
89
+ return lines.map((line) => clip(line, width));
90
+ }
91
+ for (const item of items) {
92
+ const marker = markerForStatus(item.status);
93
+ const detail = item.detail ? ` ${clip(item.detail, detailWidth)}` : '';
94
+ lines.push(`${marker} ${clip(item.name, nameWidth).padEnd(nameWidth)} ${item.status.padEnd(7)}${detail}`);
95
+ }
96
+ return lines.map((line) => clip(line, width));
97
+ }
@@ -0,0 +1,26 @@
1
+ export const DEFAULT_TRANSCRIPT_WINDOW = 30;
2
+ /** Window into conversation turns — scrollFromBottom=0 pins to the latest messages. */
3
+ export function getTranscriptWindow(totalLength, windowSize, scrollFromBottom = 0) {
4
+ if (totalLength <= 0) {
5
+ return { end: 0, scrollFromBottom: 0, showNewer: false, showOlder: false, start: 0 };
6
+ }
7
+ const size = Math.max(1, Math.min(windowSize, totalLength));
8
+ const maxScroll = Math.max(0, totalLength - size);
9
+ const scroll = Math.max(0, Math.min(scrollFromBottom, maxScroll));
10
+ const end = totalLength - scroll;
11
+ const start = Math.max(0, end - size);
12
+ return {
13
+ end,
14
+ scrollFromBottom: scroll,
15
+ showNewer: scroll > 0,
16
+ showOlder: start > 0,
17
+ start,
18
+ };
19
+ }
20
+ export function transcriptWindowSize(rows, min = 8, max = 40) {
21
+ const terminalRows = rows ?? 24;
22
+ return Math.max(min, Math.min(max, terminalRows - 12));
23
+ }
24
+ export function transcriptScrollStep(windowSize) {
25
+ return Math.max(3, Math.floor(windowSize / 2));
26
+ }
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from 'react';
2
+ export function useBusyElapsedSeconds(busy) {
3
+ const [startedAt, setStartedAt] = useState(null);
4
+ const [now, setNow] = useState(() => Date.now());
5
+ useEffect(() => {
6
+ if (!busy) {
7
+ setStartedAt(null);
8
+ return;
9
+ }
10
+ const started = Date.now();
11
+ setStartedAt(started);
12
+ setNow(started);
13
+ const interval = setInterval(() => setNow(Date.now()), 1_000);
14
+ return () => clearInterval(interval);
15
+ }, [busy]);
16
+ if (startedAt == null)
17
+ return undefined;
18
+ return Math.max(0, Math.floor((now - startedAt) / 1_000));
19
+ }
@@ -1,15 +1,148 @@
1
1
  import { useState, useRef } from 'react';
2
+ export const PASTE_COLLAPSE_LINES = 5;
3
+ export const PASTE_COLLAPSE_CHARS = 2000;
4
+ export const PASTE_SNIPPET_RE = /\[\[ paste [^\n]*?\]\]/g;
5
+ const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
6
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
7
+ : null;
8
+ const BRACKETED_PASTE_RE = /\x1b?\[20[01]~/g;
9
+ const BRACKETED_PASTE_TEST_RE = /\x1b?\[20[01]~/;
10
+ const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' });
11
+ function compactNumber(n) {
12
+ return COMPACT_NUMBER.format(n).replace(/[KMBT]$/, (suffix) => suffix.toLowerCase());
13
+ }
14
+ function oneLinePreview(text) {
15
+ return text.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]');
16
+ }
17
+ export function stripTrailingPasteNewlines(text) {
18
+ return /[^\n]/.test(text) ? text.replace(/\n+$/, '') : text;
19
+ }
20
+ export function normalizePastedInput(input) {
21
+ return stripTrailingPasteNewlines(input.replace(BRACKETED_PASTE_RE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
22
+ }
23
+ export function isPasteLikeInput(input) {
24
+ return BRACKETED_PASTE_TEST_RE.test(input) || input.includes('\n');
25
+ }
26
+ export function pasteTokenLabel(text, lineCount) {
27
+ const preview = oneLinePreview(text);
28
+ const count = `${compactNumber(lineCount)} lines`;
29
+ if (!preview)
30
+ return `[[ paste ${count} ]]`;
31
+ const headWidth = 20;
32
+ const tailWidth = 28;
33
+ const body = preview.length <= headWidth + tailWidth + 5
34
+ ? preview
35
+ : `${preview.slice(0, headWidth).trimEnd()}.. ${preview.slice(-tailWidth).trimStart()}`;
36
+ return `[[ paste ${count} · ${body} ]]`;
37
+ }
38
+ function insertToken(value, cursor, token) {
39
+ const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '';
40
+ const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '';
41
+ const insert = `${lead}${token}${tail}`;
42
+ return { cursor: cursor + insert.length, value: value.slice(0, cursor) + insert + value.slice(cursor) };
43
+ }
44
+ export function trimPasteSnips(snips) {
45
+ const maxCount = 32;
46
+ const maxChars = 4 * 1024 * 1024;
47
+ const out = [];
48
+ let chars = 0;
49
+ for (let i = snips.length - 1; i >= 0; i -= 1) {
50
+ const snip = snips[i];
51
+ if (out.length >= maxCount || chars + snip.text.length > maxChars)
52
+ break;
53
+ chars += snip.text.length;
54
+ out.unshift(snip);
55
+ }
56
+ return out.length === snips.length ? snips : out;
57
+ }
58
+ export function applyPasteInput(value, cursor, input, pasteSnips) {
59
+ const text = normalizePastedInput(input);
60
+ if (!text)
61
+ return { cursor, pasteSnips, value };
62
+ const lineCount = text.split('\n').length;
63
+ const shouldCollapse = lineCount >= PASTE_COLLAPSE_LINES || text.length >= PASTE_COLLAPSE_CHARS;
64
+ if (!shouldCollapse) {
65
+ return {
66
+ cursor: cursor + text.length,
67
+ pasteSnips,
68
+ value: value.slice(0, cursor) + text + value.slice(cursor),
69
+ };
70
+ }
71
+ const label = pasteTokenLabel(text, lineCount);
72
+ const inserted = insertToken(value, cursor, label);
73
+ return {
74
+ ...inserted,
75
+ pasteSnips: trimPasteSnips([...pasteSnips, { label, text }]),
76
+ };
77
+ }
78
+ export function expandPasteSnippets(value, pasteSnips) {
79
+ const byLabel = new Map();
80
+ for (const { label, text } of pasteSnips) {
81
+ const matches = byLabel.get(label);
82
+ if (matches)
83
+ matches.push(text);
84
+ else
85
+ byLabel.set(label, [text]);
86
+ }
87
+ return value.replace(PASTE_SNIPPET_RE, (token) => byLabel.get(token)?.shift() ?? token);
88
+ }
89
+ export function graphemeBoundaries(text) {
90
+ const boundaries = [0];
91
+ if (segmenter) {
92
+ for (const segment of segmenter.segment(text))
93
+ boundaries.push(segment.index + segment.segment.length);
94
+ }
95
+ else {
96
+ let index = 0;
97
+ for (const point of Array.from(text)) {
98
+ index += point.length;
99
+ boundaries.push(index);
100
+ }
101
+ }
102
+ return boundaries.at(-1) === text.length ? boundaries : [...boundaries, text.length];
103
+ }
104
+ export function clampCursorToGrapheme(text, cursor) {
105
+ const target = Math.max(0, Math.min(cursor, text.length));
106
+ const boundaries = graphemeBoundaries(text);
107
+ let best = 0;
108
+ for (const boundary of boundaries) {
109
+ if (boundary > target)
110
+ break;
111
+ best = boundary;
112
+ }
113
+ return best;
114
+ }
115
+ export function previousGraphemeCursor(text, cursor) {
116
+ const target = clampCursorToGrapheme(text, cursor);
117
+ let previous = 0;
118
+ for (const boundary of graphemeBoundaries(text)) {
119
+ if (boundary >= target)
120
+ return previous;
121
+ previous = boundary;
122
+ }
123
+ return previous;
124
+ }
125
+ export function nextGraphemeCursor(text, cursor) {
126
+ const target = clampCursorToGrapheme(text, cursor);
127
+ for (const boundary of graphemeBoundaries(text)) {
128
+ if (boundary > target)
129
+ return boundary;
130
+ }
131
+ return text.length;
132
+ }
2
133
  export function useEditor(history) {
3
134
  const [value, setValue] = useState('');
4
135
  const [cursor, setCursor] = useState(0);
136
+ const [pasteSnips, setPasteSnips] = useState([]);
5
137
  const histIndex = useRef(null); // null = กำลังแก้ draft (ไม่ได้อยู่ในประวัติ)
6
138
  const draft = useRef('');
7
139
  const set = (v, c = v.length) => {
8
140
  setValue(v);
9
- setCursor(Math.max(0, Math.min(c, v.length)));
141
+ setCursor(clampCursorToGrapheme(v, c));
10
142
  };
11
143
  const reset = () => {
12
144
  histIndex.current = null;
145
+ setPasteSnips([]);
13
146
  set('');
14
147
  };
15
148
  const insert = (s) => set(value.slice(0, cursor) + s + value.slice(cursor), cursor + s.length);
@@ -51,9 +184,9 @@ export function useEditor(history) {
51
184
  if (key.downArrow)
52
185
  return historyNext(), 'handled';
53
186
  if (key.leftArrow)
54
- return setCursor(Math.max(0, cursor - 1)), 'handled';
187
+ return setCursor(previousGraphemeCursor(value, cursor)), 'handled';
55
188
  if (key.rightArrow)
56
- return setCursor(Math.min(value.length, cursor + 1)), 'handled';
189
+ return setCursor(nextGraphemeCursor(value, cursor)), 'handled';
57
190
  if (key.ctrl) {
58
191
  switch (input) {
59
192
  case 'a': return setCursor(0), 'handled';
@@ -71,13 +204,19 @@ export function useEditor(history) {
71
204
  if (key.backspace || key.delete) {
72
205
  if (cursor === 0)
73
206
  return 'handled';
74
- return set(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1), 'handled';
207
+ const previous = previousGraphemeCursor(value, cursor);
208
+ return set(value.slice(0, previous) + value.slice(cursor), previous), 'handled';
75
209
  }
76
210
  if (input && !key.meta) {
77
211
  histIndex.current = null; // เริ่มพิมพ์ = ออกจากโหมดดูประวัติ
212
+ if (isPasteLikeInput(input)) {
213
+ const pasted = applyPasteInput(value, cursor, input, pasteSnips);
214
+ setPasteSnips(pasted.pasteSnips);
215
+ return set(pasted.value, pasted.cursor), 'handled';
216
+ }
78
217
  return insert(input), 'handled';
79
218
  }
80
219
  return 'none';
81
220
  };
82
- return { value, cursor, setValue: (v) => set(v), reset, handleKey };
221
+ return { value, cursor, pasteSnips, expandValue: (v = value) => expandPasteSnippets(v, pasteSnips), setValue: (v) => set(v), reset, handleKey };
83
222
  }
@@ -0,0 +1,57 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { useEffect, useState } from 'react';
4
+ const execFileP = promisify(execFile);
5
+ const BRANCH_CACHE_MS = 15_000;
6
+ const BRANCH_TIMEOUT_MS = 700;
7
+ const cache = new Map();
8
+ const inflight = new Map();
9
+ export async function resolveGitBranch(cwd) {
10
+ try {
11
+ const { stdout } = await execFileP('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
12
+ timeout: BRANCH_TIMEOUT_MS,
13
+ });
14
+ const branch = stdout.trim();
15
+ return branch && branch !== 'HEAD' ? branch : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function loadCachedGitBranch(cwd) {
22
+ const active = inflight.get(cwd);
23
+ if (active)
24
+ return active;
25
+ const load = resolveGitBranch(cwd).finally(() => inflight.delete(cwd));
26
+ inflight.set(cwd, load);
27
+ return load;
28
+ }
29
+ export function useGitBranch(cwd) {
30
+ const [branch, setBranch] = useState(() => cache.get(cwd)?.branch ?? null);
31
+ useEffect(() => {
32
+ let cancelled = false;
33
+ const refresh = async () => {
34
+ const cached = cache.get(cwd);
35
+ if (cached && Date.now() - cached.at < BRANCH_CACHE_MS) {
36
+ if (!cancelled)
37
+ setBranch(cached.branch);
38
+ return;
39
+ }
40
+ const branch = await loadCachedGitBranch(cwd);
41
+ cache.set(cwd, { at: Date.now(), branch });
42
+ if (!cancelled)
43
+ setBranch(branch);
44
+ };
45
+ void refresh();
46
+ const interval = setInterval(() => void refresh(), BRANCH_CACHE_MS);
47
+ return () => {
48
+ cancelled = true;
49
+ clearInterval(interval);
50
+ };
51
+ }, [cwd]);
52
+ return branch;
53
+ }
54
+ export function clearGitBranchCacheForTests() {
55
+ cache.clear();
56
+ inflight.clear();
57
+ }
package/dist/update.js CHANGED
@@ -1,8 +1,25 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
3
3
  export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
4
+ const SEMVER_NUMERIC_IDENTIFIER = '(?:0|[1-9]\\d*)';
5
+ const SEMVER_NON_NUMERIC_PRERELEASE_IDENTIFIER = '\\d*[A-Za-z-][0-9A-Za-z-]*';
6
+ const SEMVER_PRERELEASE_IDENTIFIER = `(?:${SEMVER_NUMERIC_IDENTIFIER}|${SEMVER_NON_NUMERIC_PRERELEASE_IDENTIFIER})`;
7
+ const NPM_VERSION_PATTERN = new RegExp(`^v?${SEMVER_NUMERIC_IDENTIFIER}\\.${SEMVER_NUMERIC_IDENTIFIER}\\.${SEMVER_NUMERIC_IDENTIFIER}` +
8
+ `(?:-${SEMVER_PRERELEASE_IDENTIFIER}(?:\\.${SEMVER_PRERELEASE_IDENTIFIER})*)?` +
9
+ `(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`);
10
+ function isAbortError(err) {
11
+ return typeof err === 'object' && err !== null && 'name' in err && err.name === 'AbortError';
12
+ }
13
+ function readLatestDistTag(body) {
14
+ if (typeof body !== 'object' || body === null)
15
+ return undefined;
16
+ const distTags = body['dist-tags'];
17
+ if (typeof distTags !== 'object' || distTags === null)
18
+ return undefined;
19
+ return distTags.latest;
20
+ }
4
21
  function packageUrl(registry, packageName) {
5
- const base = registry.replace(/\/+$/, '') || DEFAULT_REGISTRY;
22
+ const base = registry.trim().replace(/\/+$/, '') || DEFAULT_REGISTRY;
6
23
  const encoded = encodeURIComponent(packageName).replace(/^%40/, '@');
7
24
  return `${base}/${encoded}`;
8
25
  }
@@ -84,7 +101,8 @@ export function shouldCheckForUpdate(cache, nowMs = Date.now(), intervalMs = UPD
84
101
  export async function fetchLatestVersion(meta, opts = {}) {
85
102
  const fetchImpl = opts.fetchImpl ?? fetch;
86
103
  const ctrl = new AbortController();
87
- const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 8000);
104
+ const timeoutMs = opts.timeoutMs ?? 8000;
105
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
88
106
  try {
89
107
  const res = await fetchImpl(packageUrl(opts.registry ?? process.env.npm_config_registry ?? DEFAULT_REGISTRY, meta.name), {
90
108
  headers: { accept: 'application/vnd.npm.install-v1+json' },
@@ -93,11 +111,19 @@ export async function fetchLatestVersion(meta, opts = {}) {
93
111
  if (!res.ok) {
94
112
  throw new Error(`npm registry ตอบ ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`);
95
113
  }
96
- const body = (await res.json());
97
- const latest = body['dist-tags']?.latest;
98
- if (!latest)
114
+ const body = await res.json();
115
+ const latest = readLatestDistTag(body);
116
+ if (typeof latest !== 'string' || !latest.trim())
99
117
  throw new Error('npm registry ไม่มี dist-tag "latest"');
100
- return latest;
118
+ const trimmedLatest = latest.trim();
119
+ if (!NPM_VERSION_PATTERN.test(trimmedLatest))
120
+ throw new Error('npm registry dist-tag "latest" ไม่ใช่ semver');
121
+ return trimmedLatest;
122
+ }
123
+ catch (err) {
124
+ if (ctrl.signal.aborted && isAbortError(err))
125
+ throw new Error(`npm registry timeout after ${timeoutMs}ms`);
126
+ throw err;
101
127
  }
102
128
  finally {
103
129
  clearTimeout(timer);
@@ -0,0 +1,160 @@
1
+ import { BRAND } from './brand.js';
2
+ import { takeValue } from './cli-option-values.js';
3
+ import { aggregateUsageEvents, loadUsageEvents, usageEventsPath } from './usage-ledger.js';
4
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
5
+ function shiftDays(days) {
6
+ const d = new Date();
7
+ d.setDate(d.getDate() - days);
8
+ return d.toISOString().slice(0, 10);
9
+ }
10
+ export function parseUsageArgs(args) {
11
+ if (args.includes('-h') || args.includes('--help'))
12
+ return null;
13
+ let mode = 'daily';
14
+ let since;
15
+ let until;
16
+ let days = 30;
17
+ let json = false;
18
+ let noColor = false;
19
+ const positional = [];
20
+ for (let i = 0; i < args.length; i++) {
21
+ const arg = args[i];
22
+ if (arg === '--json')
23
+ json = true;
24
+ else if (arg === '--no-color')
25
+ noColor = true;
26
+ else if (arg === '--since') {
27
+ const picked = takeValue(args, i);
28
+ if (!picked.value || !DATE_RE.test(picked.value))
29
+ return null;
30
+ since = picked.value;
31
+ i = picked.nextIndex;
32
+ }
33
+ else if (arg.startsWith('--since=')) {
34
+ since = arg.slice('--since='.length);
35
+ if (!DATE_RE.test(since))
36
+ return null;
37
+ }
38
+ else if (arg === '--until') {
39
+ const picked = takeValue(args, i);
40
+ if (!picked.value || !DATE_RE.test(picked.value))
41
+ return null;
42
+ until = picked.value;
43
+ i = picked.nextIndex;
44
+ }
45
+ else if (arg.startsWith('--until=')) {
46
+ until = arg.slice('--until='.length);
47
+ if (!DATE_RE.test(until))
48
+ return null;
49
+ }
50
+ else if (arg === '--days') {
51
+ const picked = takeValue(args, i);
52
+ const n = Number(picked.value);
53
+ if (!Number.isInteger(n) || n <= 0)
54
+ return null;
55
+ days = n;
56
+ i = picked.nextIndex;
57
+ }
58
+ else if (arg.startsWith('--days=')) {
59
+ const n = Number(arg.slice('--days='.length));
60
+ if (!Number.isInteger(n) || n <= 0)
61
+ return null;
62
+ days = n;
63
+ }
64
+ else if (!arg.startsWith('-'))
65
+ positional.push(arg);
66
+ else
67
+ return null;
68
+ }
69
+ if (positional[0]) {
70
+ if (!['daily', 'weekly', 'monthly', 'session'].includes(positional[0]))
71
+ return null;
72
+ mode = positional[0];
73
+ }
74
+ if (!since)
75
+ since = shiftDays(days - 1);
76
+ if (!until)
77
+ until = new Date().toISOString().slice(0, 10);
78
+ return { mode, since, until, days, json, noColor };
79
+ }
80
+ function fmt(n) {
81
+ return n.toLocaleString('en-US');
82
+ }
83
+ function fmtCost(n) {
84
+ return n > 0 ? `$${n.toFixed(2)}` : '$0.00';
85
+ }
86
+ function renderTable(title, rows, wide) {
87
+ if (!rows.length) {
88
+ return [
89
+ `╭${'─'.repeat(Math.max(42, title.length + 4))}╮`,
90
+ `│ ${title.padEnd(Math.max(40, title.length + 2))} │`,
91
+ `╰${'─'.repeat(Math.max(42, title.length + 4))}╯`,
92
+ '',
93
+ `(no usage recorded — run ${BRAND.cliName} and complete a turn first)`,
94
+ `ledger: ${usageEventsPath()}`,
95
+ ].join('\n');
96
+ }
97
+ const lines = [];
98
+ lines.push(`╭${'─'.repeat(title.length + 4)}╮`);
99
+ lines.push(`│ ${title} │`);
100
+ lines.push(`╰${'─'.repeat(title.length + 4)}╯`);
101
+ lines.push('');
102
+ if (wide) {
103
+ lines.push('┌────────────┬─────────┬──────────────────┬─────────┬─────────┬────────────┬────────────┐');
104
+ lines.push('│ Period │ Turns │ Models │ Input │ Output │ Cache R/W │ Cost (USD) │');
105
+ lines.push('├────────────┼─────────┼──────────────────┼─────────┼─────────┼────────────┼────────────┤');
106
+ for (const row of rows) {
107
+ const models = row.models.join(' ').slice(0, 16).padEnd(16);
108
+ const cache = `${fmt(row.cacheReadTokens)}/${fmt(row.cacheWriteTokens)}`.padStart(10);
109
+ lines.push(`│ ${row.label.padEnd(10)} │ ${String(row.turns).padStart(7)} │ ${models} │ ${fmt(row.inputTokens).padStart(7)} │ ${fmt(row.outputTokens).padStart(7)} │ ${cache} │ ${fmtCost(row.costUsd).padStart(10)} │`);
110
+ }
111
+ lines.push('└────────────┴─────────┴──────────────────┴─────────┴─────────┴────────────┴────────────┘');
112
+ }
113
+ else {
114
+ lines.push('┌────────────┬──────────────────┬─────────┬─────────┬────────────┐');
115
+ lines.push('│ Period │ Models │ Input │ Output │ Cost (USD) │');
116
+ lines.push('├────────────┼──────────────────┼─────────┼─────────┼────────────┤');
117
+ for (const row of rows) {
118
+ const models = row.models.join(' ').slice(0, 16).padEnd(16);
119
+ lines.push(`│ ${row.label.padEnd(10)} │ ${models} │ ${fmt(row.inputTokens).padStart(7)} │ ${fmt(row.outputTokens).padStart(7)} │ ${fmtCost(row.costUsd).padStart(10)} │`);
120
+ }
121
+ lines.push('└────────────┴──────────────────┴─────────┴─────────┴────────────┘');
122
+ }
123
+ const totalCost = rows.reduce((sum, row) => sum + row.costUsd, 0);
124
+ const totalTokens = rows.reduce((sum, row) => sum + row.totalTokens, 0);
125
+ lines.push('');
126
+ lines.push(`totals: ${fmt(totalTokens)} tokens · ${fmtCost(totalCost)} estimated · ledger: ${usageEventsPath()}`);
127
+ return lines.join('\n');
128
+ }
129
+ export async function renderUsageReport(options) {
130
+ const events = await loadUsageEvents({ since: options.since, until: options.until });
131
+ const rows = aggregateUsageEvents(events, options.mode);
132
+ if (options.json) {
133
+ return JSON.stringify({
134
+ agent: BRAND.cliName,
135
+ mode: options.mode,
136
+ since: options.since,
137
+ until: options.until,
138
+ events: events.length,
139
+ rows,
140
+ ledger: usageEventsPath(),
141
+ }, null, 2);
142
+ }
143
+ const title = options.mode === 'daily'
144
+ ? `${BRAND.productName} Usage Report — Daily`
145
+ : options.mode === 'weekly'
146
+ ? `${BRAND.productName} Usage Report — Weekly`
147
+ : options.mode === 'monthly'
148
+ ? `${BRAND.productName} Usage Report — Monthly`
149
+ : `${BRAND.productName} Usage Report — Sessions`;
150
+ const wide = (process.stdout.columns ?? 100) >= 100;
151
+ return renderTable(title, rows, wide);
152
+ }
153
+ export function usageHelpText() {
154
+ return [
155
+ `ใช้: ${BRAND.cliName} usage [daily|weekly|monthly|session] [--days N] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--json]`,
156
+ '',
157
+ 'บันทึก token/cost ทุก agent turn ลง ~/.sanook/usage/events.jsonl (ccusage-style local ledger).',
158
+ 'ปิดได้ด้วย SANOOK_DISABLE_USAGE=1',
159
+ ].join('\n');
160
+ }