sanook-cli 0.5.1 → 0.5.2
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/.env.example +161 -3
- package/CHANGELOG.md +57 -8
- package/README.md +240 -23
- package/README.th.md +87 -6
- package/dist/approval.js +6 -0
- package/dist/bin.js +3026 -196
- package/dist/brain-context.js +223 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +371 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +12 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +152 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/commands.js +172 -13
- package/dist/compaction.js +96 -11
- package/dist/config.js +118 -28
- package/dist/context-compression.js +191 -0
- package/dist/cost.js +49 -15
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +37 -8
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +357 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/email.js +472 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +18 -0
- package/dist/gateway/line.js +171 -0
- package/dist/gateway/lock.js +3 -1
- package/dist/gateway/matrix.js +366 -0
- package/dist/gateway/mattermost.js +322 -0
- package/dist/gateway/ntfy.js +218 -0
- package/dist/gateway/schedule.js +31 -4
- package/dist/gateway/serve.js +267 -7
- package/dist/gateway/server.js +253 -19
- package/dist/gateway/service.js +224 -0
- package/dist/gateway/session.js +343 -0
- package/dist/gateway/signal.js +351 -0
- package/dist/gateway/slack.js +124 -0
- package/dist/gateway/sms.js +169 -0
- package/dist/gateway/targets.js +576 -0
- package/dist/gateway/teams.js +106 -0
- package/dist/gateway/telegram.js +38 -15
- package/dist/gateway/webhooks.js +220 -0
- package/dist/gateway/whatsapp.js +230 -0
- package/dist/hooks.js +13 -2
- package/dist/insights-args.js +35 -0
- package/dist/insights.js +86 -0
- package/dist/loop.js +123 -24
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-registry.js +350 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +44 -6
- package/dist/memory.js +100 -33
- package/dist/orchestrate.js +49 -19
- package/dist/personality.js +58 -0
- package/dist/providers/codex.js +70 -36
- package/dist/providers/keys.js +1 -1
- package/dist/providers/models.js +1 -1
- package/dist/providers/registry.js +14 -47
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +75 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session.js +93 -7
- package/dist/skill-install.js +29 -12
- package/dist/support-dump.js +175 -0
- package/dist/tools/edit.js +45 -15
- package/dist/tools/git.js +10 -5
- package/dist/tools/homeassistant.js +106 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +923 -9
- package/dist/tools/read.js +16 -4
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +217 -13
- package/dist/tools/task.js +18 -7
- package/dist/tools/timeout.js +21 -3
- package/dist/trust.js +11 -1
- package/dist/ui/app.js +48 -8
- package/dist/ui/history.js +37 -5
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/setup.js +17 -4
- package/dist/update.js +24 -11
- package/dist/worktree.js +175 -4
- package/package.json +4 -4
- package/second-brain/AGENTS.md +6 -4
- package/second-brain/CLAUDE.md +7 -1
- package/second-brain/Evals/_Index.md +10 -2
- package/second-brain/Evals/quality-ledger.md +9 -1
- package/second-brain/Evals/second-brain-benchmarks.md +62 -0
- package/second-brain/GEMINI.md +5 -4
- package/second-brain/Home.md +1 -1
- package/second-brain/Projects/_Index.md +3 -1
- package/second-brain/Projects/sanook-cli/_Index.md +26 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
- package/second-brain/README.md +1 -1
- package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
- package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
- package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
- package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/_Index.md +6 -1
- package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
- package/second-brain/Reviews/_Index.md +1 -1
- package/second-brain/Runbooks/_Index.md +6 -1
- package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
- package/second-brain/SANOOK.md +45 -0
- package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
- package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
- package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
- package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
- package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
- package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
- package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
- package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
- package/second-brain/Sessions/_Index.md +15 -1
- package/second-brain/Shared/AI-Context-Index.md +22 -0
- package/second-brain/Shared/Context-Packs/_Index.md +9 -1
- package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
- package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
- package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
- package/second-brain/Shared/Operating-State/current-state.md +22 -3
- package/second-brain/Shared/Scripts/_Index.md +3 -1
- package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
- package/second-brain/Shared/User-Memory/_Index.md +4 -1
- package/second-brain/Shared/User-Memory/response-examples.md +98 -0
- package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
- package/second-brain/Templates/_Index.md +9 -0
- package/second-brain/Templates/final-lite.md +111 -0
- package/second-brain/Templates/final.md +231 -0
- package/second-brain/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
const DEFAULT_TARGET_CHARS = 6_000;
|
|
2
|
+
const DEFAULT_MIN_CHARS = 8_000;
|
|
3
|
+
const DEFAULT_MAX_LINE_CHARS = 800;
|
|
4
|
+
const IMPORTANT_RE = /\b(error|exception|fail(?:ed|ure)?|warning|warn|timeout|denied|unauthorized|traceback|panic|regression|todo|fixme)\b/i;
|
|
5
|
+
const CODE_RE = /^\s*(?:import|export|function|class|interface|type|const|let|var|async|await|return|if|for|while|switch|case)\b/;
|
|
6
|
+
const DIFF_RE = /^\s*(?:diff --git|@@|\+\+\+|---|\+|-)/;
|
|
7
|
+
const PATH_RE = /(?:^|\s)[\w@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|py|rs|go|java|css|scss|html|yml|yaml|toml)(?::\d+)?\b/;
|
|
8
|
+
const STRUCTURE_RE = /^\s*(?:#{1,6}\s|\*|-|\d+\.|["'][^"']+["']\s*:)/;
|
|
9
|
+
const TOKEN_RE = /[\p{L}\p{N}_./:-]{2,}/gu;
|
|
10
|
+
function clampPositive(value, fallback) {
|
|
11
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
12
|
+
}
|
|
13
|
+
function tokens(line) {
|
|
14
|
+
return [...line.toLowerCase().matchAll(TOKEN_RE)].map((match) => match[0]);
|
|
15
|
+
}
|
|
16
|
+
function queryTokens(query) {
|
|
17
|
+
const out = new Set();
|
|
18
|
+
for (const token of tokens(query ?? '')) {
|
|
19
|
+
if (token.length >= 3)
|
|
20
|
+
out.add(token);
|
|
21
|
+
const leaf = token.split(/[/:\\]/).pop();
|
|
22
|
+
if (leaf && leaf.length >= 3)
|
|
23
|
+
out.add(leaf);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function shrinkLine(line, maxChars) {
|
|
28
|
+
if (line.length <= maxChars)
|
|
29
|
+
return line;
|
|
30
|
+
const head = Math.max(80, Math.floor(maxChars * 0.55));
|
|
31
|
+
const tail = Math.max(60, maxChars - head - 48);
|
|
32
|
+
return `${line.slice(0, head)} ... [line pruned ${line.length - head - tail} chars] ... ${line.slice(-tail)}`;
|
|
33
|
+
}
|
|
34
|
+
function addAnchors(lines, budget, fromEnd = false) {
|
|
35
|
+
const selected = new Set();
|
|
36
|
+
let chars = 0;
|
|
37
|
+
for (let step = 0; step < lines.length; step++) {
|
|
38
|
+
const index = fromEnd ? lines.length - 1 - step : step;
|
|
39
|
+
if (index < 0 || index >= lines.length)
|
|
40
|
+
break;
|
|
41
|
+
if (chars >= budget)
|
|
42
|
+
break;
|
|
43
|
+
selected.add(index);
|
|
44
|
+
chars += lines[index].length + 1;
|
|
45
|
+
}
|
|
46
|
+
return selected;
|
|
47
|
+
}
|
|
48
|
+
function selectedChars(lines, selected, maxLineChars) {
|
|
49
|
+
let chars = 0;
|
|
50
|
+
for (const index of selected)
|
|
51
|
+
chars += Math.min(lines[index].length, maxLineChars) + 1;
|
|
52
|
+
return chars;
|
|
53
|
+
}
|
|
54
|
+
function lineScores(lines, selected, query) {
|
|
55
|
+
const candidates = lines
|
|
56
|
+
.map((line, index) => ({ line, index }))
|
|
57
|
+
.filter(({ line, index }) => !selected.has(index) && line.trim());
|
|
58
|
+
const df = new Map();
|
|
59
|
+
for (const { line } of candidates) {
|
|
60
|
+
for (const token of new Set(tokens(line)))
|
|
61
|
+
df.set(token, (df.get(token) ?? 0) + 1);
|
|
62
|
+
}
|
|
63
|
+
const total = Math.max(1, candidates.length);
|
|
64
|
+
const seenLines = new Map();
|
|
65
|
+
const scores = new Map();
|
|
66
|
+
for (const { line, index } of candidates) {
|
|
67
|
+
const clean = line.trim();
|
|
68
|
+
const lineTokens = tokens(clean);
|
|
69
|
+
let score = 0;
|
|
70
|
+
for (const token of lineTokens)
|
|
71
|
+
score += Math.log((total + 1) / ((df.get(token) ?? 0) + 1));
|
|
72
|
+
score = score / Math.sqrt(Math.max(1, lineTokens.length));
|
|
73
|
+
if (query.size) {
|
|
74
|
+
let overlap = 0;
|
|
75
|
+
for (const token of new Set(lineTokens)) {
|
|
76
|
+
const leaf = token.split(/[/:\\]/).pop() ?? token;
|
|
77
|
+
if (query.has(token) || query.has(leaf))
|
|
78
|
+
overlap += 1;
|
|
79
|
+
}
|
|
80
|
+
if (overlap)
|
|
81
|
+
score += Math.min(40, overlap * 18);
|
|
82
|
+
}
|
|
83
|
+
if (IMPORTANT_RE.test(clean))
|
|
84
|
+
score += 8;
|
|
85
|
+
if (PATH_RE.test(clean))
|
|
86
|
+
score += 5;
|
|
87
|
+
if (DIFF_RE.test(clean))
|
|
88
|
+
score += 4;
|
|
89
|
+
if (CODE_RE.test(clean))
|
|
90
|
+
score += 3;
|
|
91
|
+
if (STRUCTURE_RE.test(clean))
|
|
92
|
+
score += 2;
|
|
93
|
+
if (/https?:\/\//i.test(clean))
|
|
94
|
+
score += 2;
|
|
95
|
+
if (/^\s*[}\])],?\s*$/.test(clean))
|
|
96
|
+
score -= 1;
|
|
97
|
+
if (clean.length > 500 && !/\s/.test(clean))
|
|
98
|
+
score -= 5;
|
|
99
|
+
const repeated = seenLines.get(clean) ?? 0;
|
|
100
|
+
if (repeated)
|
|
101
|
+
score -= Math.min(8, repeated * 2);
|
|
102
|
+
seenLines.set(clean, repeated + 1);
|
|
103
|
+
scores.set(index, score);
|
|
104
|
+
}
|
|
105
|
+
return scores;
|
|
106
|
+
}
|
|
107
|
+
function renderSelected(lines, selected, maxLineChars) {
|
|
108
|
+
const out = [];
|
|
109
|
+
let omittedLines = 0;
|
|
110
|
+
let omittedChars = 0;
|
|
111
|
+
let gapLines = 0;
|
|
112
|
+
let gapChars = 0;
|
|
113
|
+
const flushGap = () => {
|
|
114
|
+
if (!gapLines)
|
|
115
|
+
return;
|
|
116
|
+
out.push(`... [selective context compression: omitted ${gapLines} line(s), ${gapChars} chars] ...`);
|
|
117
|
+
omittedLines += gapLines;
|
|
118
|
+
omittedChars += gapChars;
|
|
119
|
+
gapLines = 0;
|
|
120
|
+
gapChars = 0;
|
|
121
|
+
};
|
|
122
|
+
for (let index = 0; index < lines.length; index++) {
|
|
123
|
+
if (selected.has(index)) {
|
|
124
|
+
flushGap();
|
|
125
|
+
out.push(shrinkLine(lines[index], maxLineChars));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
gapLines += 1;
|
|
129
|
+
gapChars += lines[index].length + 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
flushGap();
|
|
133
|
+
return { text: out.join('\n'), omittedLines, omittedChars };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Zero-LLM selective context compression inspired by Selective Context / Headroom:
|
|
137
|
+
* keep anchors plus high-information lines (errors, paths, code structure, rare terms),
|
|
138
|
+
* then preserve original order with omission markers.
|
|
139
|
+
*/
|
|
140
|
+
export function selectiveCompressText(input, options = {}) {
|
|
141
|
+
const originalChars = input.length;
|
|
142
|
+
const targetChars = clampPositive(options.targetChars, DEFAULT_TARGET_CHARS);
|
|
143
|
+
const minChars = clampPositive(options.minChars, DEFAULT_MIN_CHARS);
|
|
144
|
+
const maxLineChars = clampPositive(options.maxLineChars, DEFAULT_MAX_LINE_CHARS);
|
|
145
|
+
if (originalChars <= minChars || originalChars <= targetChars) {
|
|
146
|
+
return { text: input, changed: false, originalChars, compressedChars: originalChars, omittedLines: 0, omittedChars: 0 };
|
|
147
|
+
}
|
|
148
|
+
const lines = input.split(/\r?\n/);
|
|
149
|
+
if (lines.length <= 4) {
|
|
150
|
+
const text = shrinkLine(input, targetChars);
|
|
151
|
+
return {
|
|
152
|
+
text,
|
|
153
|
+
changed: text !== input,
|
|
154
|
+
originalChars,
|
|
155
|
+
compressedChars: text.length,
|
|
156
|
+
omittedLines: text === input ? 0 : 1,
|
|
157
|
+
omittedChars: Math.max(0, originalChars - text.length),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const headBudget = Math.min(clampPositive(options.headChars, Math.floor(targetChars * 0.18)), Math.floor(targetChars * 0.35));
|
|
161
|
+
const tailBudget = Math.min(clampPositive(options.tailChars, Math.floor(targetChars * 0.25)), Math.floor(targetChars * 0.45));
|
|
162
|
+
const selected = new Set([
|
|
163
|
+
...addAnchors(lines, headBudget),
|
|
164
|
+
...addAnchors(lines, tailBudget, true),
|
|
165
|
+
]);
|
|
166
|
+
const scores = lineScores(lines, selected, queryTokens(options.query));
|
|
167
|
+
const ranked = [...scores.entries()].sort((a, b) => b[1] - a[1]);
|
|
168
|
+
let used = selectedChars(lines, selected, maxLineChars);
|
|
169
|
+
const softBudget = Math.max(400, targetChars - 600);
|
|
170
|
+
for (const [index] of ranked) {
|
|
171
|
+
const nextCost = Math.min(lines[index].length, maxLineChars) + 1;
|
|
172
|
+
if (used + nextCost > softBudget && selected.size > 0)
|
|
173
|
+
continue;
|
|
174
|
+
selected.add(index);
|
|
175
|
+
used += nextCost;
|
|
176
|
+
if (used >= softBudget)
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
const rendered = renderSelected(lines, selected, maxLineChars);
|
|
180
|
+
if (rendered.text.length >= originalChars) {
|
|
181
|
+
return { text: input, changed: false, originalChars, compressedChars: originalChars, omittedLines: 0, omittedChars: 0 };
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
text: rendered.text,
|
|
185
|
+
changed: true,
|
|
186
|
+
originalChars,
|
|
187
|
+
compressedChars: rendered.text.length,
|
|
188
|
+
omittedLines: rendered.omittedLines,
|
|
189
|
+
omittedChars: rendered.omittedChars,
|
|
190
|
+
};
|
|
191
|
+
}
|
package/dist/cost.js
CHANGED
|
@@ -15,9 +15,6 @@ export const PRICING = {
|
|
|
15
15
|
// Google Gemini (≤200k context tier)
|
|
16
16
|
'google:gemini-2.5-pro': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.31 },
|
|
17
17
|
'google:gemini-2.5-flash': { input: 0.3, output: 2.5, cacheWrite: 0.3, cacheRead: 0.075 },
|
|
18
|
-
// DeepSeek V4
|
|
19
|
-
'deepseek:deepseek-v4-flash': { input: 0.28, output: 0.42, cacheWrite: 0.28, cacheRead: 0.028 },
|
|
20
|
-
'deepseek:deepseek-v4-pro': { input: 0.55, output: 2.19, cacheWrite: 0.55, cacheRead: 0.055 },
|
|
21
18
|
// xAI Grok
|
|
22
19
|
'xai:grok-4.3': { input: 3, output: 15, cacheWrite: 3, cacheRead: 0.75 },
|
|
23
20
|
// Mistral
|
|
@@ -30,6 +27,9 @@ export const PRICING = {
|
|
|
30
27
|
export function hasPricingForKey(specKey) {
|
|
31
28
|
return specKey in PRICING;
|
|
32
29
|
}
|
|
30
|
+
function isPricingKey(key) {
|
|
31
|
+
return /^[^:\s]+:\S+$/.test(key);
|
|
32
|
+
}
|
|
33
33
|
/**
|
|
34
34
|
* merge pricing เพิ่ม/override (จาก config `pricing` หรือ env SANOOK_PRICING)
|
|
35
35
|
* — ให้ budget cap ใช้ได้กับ provider ที่ยังไม่มีในตาราง โดยไม่ต้องแก้โค้ด
|
|
@@ -38,6 +38,8 @@ export function registerPricing(extra) {
|
|
|
38
38
|
if (!extra)
|
|
39
39
|
return;
|
|
40
40
|
for (const [key, p] of Object.entries(extra)) {
|
|
41
|
+
if (!isPricingKey(key))
|
|
42
|
+
continue;
|
|
41
43
|
if (p == null || typeof p !== 'object')
|
|
42
44
|
continue;
|
|
43
45
|
const base = PRICING[key] ?? { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
|
|
@@ -54,17 +56,41 @@ export function registerPricing(extra) {
|
|
|
54
56
|
PRICING[key] = next;
|
|
55
57
|
}
|
|
56
58
|
}
|
|
59
|
+
function safeTokenCount(value) {
|
|
60
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
|
61
|
+
return 0;
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
export class SharedBudget {
|
|
65
|
+
budgetUsd;
|
|
66
|
+
spent = 0;
|
|
67
|
+
constructor(budgetUsd) {
|
|
68
|
+
this.budgetUsd = budgetUsd;
|
|
69
|
+
}
|
|
70
|
+
add(usd) {
|
|
71
|
+
if (Number.isFinite(usd) && usd > 0)
|
|
72
|
+
this.spent += usd;
|
|
73
|
+
}
|
|
74
|
+
get totalUsd() {
|
|
75
|
+
return this.spent;
|
|
76
|
+
}
|
|
77
|
+
get overBudget() {
|
|
78
|
+
return this.budgetUsd != null && this.spent >= this.budgetUsd;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
57
81
|
export class CostMeter {
|
|
58
82
|
specKey;
|
|
59
83
|
budgetUsd;
|
|
84
|
+
sharedBudget;
|
|
60
85
|
inTok = 0;
|
|
61
86
|
outTok = 0;
|
|
62
87
|
cacheReadTok = 0;
|
|
63
88
|
cacheWriteTok = 0;
|
|
64
89
|
spent = 0;
|
|
65
|
-
constructor(specKey, budgetUsd) {
|
|
90
|
+
constructor(specKey, budgetUsd, sharedBudget) {
|
|
66
91
|
this.specKey = specKey;
|
|
67
92
|
this.budgetUsd = budgetUsd;
|
|
93
|
+
this.sharedBudget = sharedBudget;
|
|
68
94
|
}
|
|
69
95
|
/**
|
|
70
96
|
* บวก usage ของ 1 step. cacheWriteTokens ดึงจาก providerMetadata แยก (default 0)
|
|
@@ -72,21 +98,23 @@ export class CostMeter {
|
|
|
72
98
|
* ไม่งั้น double-count cacheRead (cache hit จะกลายเป็นแพงกว่า no-cache)
|
|
73
99
|
*/
|
|
74
100
|
add(usage, cacheWriteTokens = 0) {
|
|
75
|
-
const totalInput = usage.inputTokens
|
|
76
|
-
const output = usage.outputTokens
|
|
77
|
-
const cacheRead = usage.cachedInputTokens
|
|
78
|
-
const
|
|
101
|
+
const totalInput = safeTokenCount(usage.inputTokens);
|
|
102
|
+
const output = safeTokenCount(usage.outputTokens);
|
|
103
|
+
const cacheRead = safeTokenCount(usage.cachedInputTokens);
|
|
104
|
+
const cacheWrite = safeTokenCount(cacheWriteTokens);
|
|
105
|
+
const noCacheInput = Math.max(0, totalInput - cacheRead - cacheWrite);
|
|
79
106
|
this.inTok += noCacheInput;
|
|
80
107
|
this.outTok += output;
|
|
81
108
|
this.cacheReadTok += cacheRead;
|
|
82
|
-
this.cacheWriteTok +=
|
|
109
|
+
this.cacheWriteTok += cacheWrite;
|
|
83
110
|
const p = PRICING[this.specKey];
|
|
84
111
|
if (p) {
|
|
85
|
-
|
|
86
|
-
(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
112
|
+
const delta = (noCacheInput / 1e6) * p.input +
|
|
113
|
+
(output / 1e6) * p.output +
|
|
114
|
+
(cacheRead / 1e6) * p.cacheRead +
|
|
115
|
+
(cacheWrite / 1e6) * p.cacheWrite;
|
|
116
|
+
this.spent += delta;
|
|
117
|
+
this.sharedBudget?.add(delta);
|
|
90
118
|
}
|
|
91
119
|
}
|
|
92
120
|
/** รวม token + cost จาก meter อีกตัว (เช่น primary model ก่อน fallback) — กัน usage หาย/budget reset */
|
|
@@ -96,6 +124,8 @@ export class CostMeter {
|
|
|
96
124
|
this.cacheReadTok += other.cacheReadTok;
|
|
97
125
|
this.cacheWriteTok += other.cacheWriteTok;
|
|
98
126
|
this.spent += other.spent;
|
|
127
|
+
if (this.sharedBudget && this.sharedBudget !== other.sharedBudget)
|
|
128
|
+
this.sharedBudget.add(other.spent);
|
|
99
129
|
}
|
|
100
130
|
get totalUsd() {
|
|
101
131
|
return this.spent;
|
|
@@ -105,7 +135,11 @@ export class CostMeter {
|
|
|
105
135
|
}
|
|
106
136
|
/** true เมื่อใช้เกิน budget (เช็คก่อนยิง request ถัดไป) — no-op ถ้าไม่มี pricing (เตือนที่ entry point) */
|
|
107
137
|
get overBudget() {
|
|
108
|
-
|
|
138
|
+
if (this.sharedBudget?.overBudget)
|
|
139
|
+
return true;
|
|
140
|
+
if (!this.hasPricing)
|
|
141
|
+
return false;
|
|
142
|
+
return this.budgetUsd != null && this.spent >= this.budgetUsd;
|
|
109
143
|
}
|
|
110
144
|
summary() {
|
|
111
145
|
const total = this.inTok + this.outTok + this.cacheReadTok + this.cacheWriteTok;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { detectCodex } from './providers/codex.js';
|
|
2
|
+
import { hasUsableEnvKey, parseSpec, PROVIDERS } from './providers/registry.js';
|
|
3
|
+
/**
|
|
4
|
+
* First-run can skip the setup wizard only when the selected provider is genuinely
|
|
5
|
+
* ready to run: cloud providers need a policy-valid API key, local providers need
|
|
6
|
+
* no key, and delegate providers like Codex must have their official CLI auth ready.
|
|
7
|
+
*/
|
|
8
|
+
export async function providerCanSkipSetup(provider, detect = detectCodex) {
|
|
9
|
+
const cfg = PROVIDERS[provider];
|
|
10
|
+
if (!cfg)
|
|
11
|
+
return false;
|
|
12
|
+
if (cfg.kind === 'delegate') {
|
|
13
|
+
const s = await detect();
|
|
14
|
+
return s.installed && s.loggedIn;
|
|
15
|
+
}
|
|
16
|
+
return hasUsableEnvKey(provider);
|
|
17
|
+
}
|
|
18
|
+
export async function modelNeedsSetup(modelSpec, detect = detectCodex) {
|
|
19
|
+
const { provider } = parseSpec(modelSpec);
|
|
20
|
+
return !(await providerCanSkipSetup(provider, detect));
|
|
21
|
+
}
|
package/dist/gateway/auth.js
CHANGED
|
@@ -4,19 +4,48 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
4
4
|
import { appHomePath } from '../brand.js';
|
|
5
5
|
const GATEWAY_DIR = appHomePath('gateway');
|
|
6
6
|
const TOKEN_FILE = join(GATEWAY_DIR, 'token');
|
|
7
|
+
const TOKEN_FILE_PATTERN = /^([a-f0-9]{64})(?:\r?\n)?$/;
|
|
8
|
+
export async function ensureGatewayDir() {
|
|
9
|
+
await mkdir(GATEWAY_DIR, { recursive: true, mode: 0o700 });
|
|
10
|
+
await chmod(GATEWAY_DIR, 0o700).catch(() => { });
|
|
11
|
+
}
|
|
7
12
|
/** โหลด bearer token ของ gateway; ไม่มี → สร้าง 256-bit ใหม่ เก็บ chmod 600 */
|
|
8
13
|
export async function loadOrCreateToken() {
|
|
14
|
+
for (;;) {
|
|
15
|
+
const existingToken = await readTokenIfPresent();
|
|
16
|
+
if (existingToken !== undefined)
|
|
17
|
+
return existingToken;
|
|
18
|
+
const token = randomBytes(32).toString('hex');
|
|
19
|
+
await ensureGatewayDir();
|
|
20
|
+
try {
|
|
21
|
+
await writeFile(TOKEN_FILE, `${token}\n`, { mode: 0o600, flag: 'wx' });
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (e.code === 'EEXIST')
|
|
25
|
+
continue;
|
|
26
|
+
throw new Error(`ไม่สามารถเขียน gateway token ที่ ${TOKEN_FILE}: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
await chmod(TOKEN_FILE, 0o600).catch(() => { });
|
|
29
|
+
return token;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function readTokenIfPresent() {
|
|
33
|
+
let rawToken;
|
|
9
34
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
35
|
+
rawToken = await readFile(TOKEN_FILE, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e.code !== 'ENOENT') {
|
|
39
|
+
throw new Error(`ไม่สามารถอ่าน gateway token ที่ ${TOKEN_FILE}: ${e.message}`);
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
13
42
|
}
|
|
14
|
-
|
|
15
|
-
|
|
43
|
+
const tokenMatch = TOKEN_FILE_PATTERN.exec(rawToken);
|
|
44
|
+
if (!tokenMatch) {
|
|
45
|
+
throw new Error(`gateway token ที่ ${TOKEN_FILE} ไม่ถูกต้อง: ต้องเป็น hex 64 ตัวอักษร`);
|
|
16
46
|
}
|
|
17
|
-
const token =
|
|
18
|
-
await
|
|
19
|
-
await writeFile(TOKEN_FILE, `${token}\n`, { mode: 0o600 });
|
|
47
|
+
const token = tokenMatch[1];
|
|
48
|
+
await chmod(GATEWAY_DIR, 0o700).catch(() => { });
|
|
20
49
|
await chmod(TOKEN_FILE, 0o600).catch(() => { });
|
|
21
50
|
return token;
|
|
22
51
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { redactKey } from '../providers/keys.js';
|
|
3
|
+
const BLUEBUBBLES_TEXT_LIMIT = 4_000;
|
|
4
|
+
const GUID_CACHE_SIZE = 500;
|
|
5
|
+
const chatGuidCache = new Map();
|
|
6
|
+
function chatGuidCacheKey(config, target) {
|
|
7
|
+
return `${normalizeBlueBubblesServerUrl(config.serverUrl) ?? ''}|${target}`;
|
|
8
|
+
}
|
|
9
|
+
function rememberChatGuid(config, target, guid) {
|
|
10
|
+
chatGuidCache.set(chatGuidCacheKey(config, target), guid);
|
|
11
|
+
if (chatGuidCache.size <= GUID_CACHE_SIZE)
|
|
12
|
+
return;
|
|
13
|
+
const first = chatGuidCache.keys().next().value;
|
|
14
|
+
if (first)
|
|
15
|
+
chatGuidCache.delete(first);
|
|
16
|
+
}
|
|
17
|
+
function redactBlueBubblesDetail(raw, secrets) {
|
|
18
|
+
let safe = redactKey(raw);
|
|
19
|
+
for (const secret of secrets) {
|
|
20
|
+
const value = secret?.trim();
|
|
21
|
+
if (value)
|
|
22
|
+
safe = safe.split(value).join('<secret>');
|
|
23
|
+
}
|
|
24
|
+
return safe;
|
|
25
|
+
}
|
|
26
|
+
export function normalizeBlueBubblesServerUrl(raw) {
|
|
27
|
+
let value = raw?.trim();
|
|
28
|
+
if (!value)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (!/^https?:\/\//i.test(value))
|
|
31
|
+
value = `http://${value}`;
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(value);
|
|
34
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
35
|
+
return undefined;
|
|
36
|
+
return url.toString().replace(/\/+$/, '');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function normalizeBlueBubblesWebhookPath(raw) {
|
|
43
|
+
const value = raw?.trim() || '/bluebubbles-webhook';
|
|
44
|
+
return value.startsWith('/') ? value : `/${value}`;
|
|
45
|
+
}
|
|
46
|
+
export function blueBubblesApiUrl(config, path) {
|
|
47
|
+
const baseUrl = normalizeBlueBubblesServerUrl(config.serverUrl);
|
|
48
|
+
const password = config.password?.trim();
|
|
49
|
+
if (!baseUrl)
|
|
50
|
+
throw new Error('BlueBubbles server URL ต้องเป็น http:// หรือ https:// URL');
|
|
51
|
+
if (!password)
|
|
52
|
+
throw new Error('ยังไม่ได้ตั้ง BlueBubbles password');
|
|
53
|
+
const url = new URL(path, `${baseUrl}/`);
|
|
54
|
+
url.searchParams.set('password', password);
|
|
55
|
+
return url.toString();
|
|
56
|
+
}
|
|
57
|
+
export function parseBlueBubblesTarget(config, explicitTarget) {
|
|
58
|
+
const target = explicitTarget?.trim() || config.homeChannel?.trim();
|
|
59
|
+
if (!target)
|
|
60
|
+
throw new Error('ต้องระบุ BlueBubbles target หรือ home channel ใน gateway config');
|
|
61
|
+
const stripped = target.replace(/^(?:chat|guid)[:/]/i, '').trim();
|
|
62
|
+
if (!stripped || /\s/.test(stripped))
|
|
63
|
+
throw new Error('BlueBubbles target ต้องเป็น chat GUID, email, หรือเบอร์โทรที่ไม่มีช่องว่าง');
|
|
64
|
+
if (stripped.includes(';'))
|
|
65
|
+
return { value: stripped, chatGuid: stripped };
|
|
66
|
+
return { value: stripped };
|
|
67
|
+
}
|
|
68
|
+
export function formatBlueBubblesText(raw) {
|
|
69
|
+
return raw
|
|
70
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
71
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
72
|
+
.replace(/`{1,3}([^`]+)`{1,3}/g, '$1')
|
|
73
|
+
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
|
74
|
+
.trim();
|
|
75
|
+
}
|
|
76
|
+
export function chunkBlueBubblesText(raw, limit = BLUEBUBBLES_TEXT_LIMIT) {
|
|
77
|
+
const text = formatBlueBubblesText(raw) || '(ไม่มีผลลัพธ์)';
|
|
78
|
+
const paragraphs = text.split(/\n\s*\n/).map((part) => part.trim()).filter(Boolean);
|
|
79
|
+
const chunks = [];
|
|
80
|
+
for (const paragraph of paragraphs.length ? paragraphs : [text]) {
|
|
81
|
+
if (paragraph.length <= limit) {
|
|
82
|
+
chunks.push(paragraph);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (let index = 0; index < paragraph.length; index += limit) {
|
|
86
|
+
chunks.push(paragraph.slice(index, index + limit));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return chunks;
|
|
90
|
+
}
|
|
91
|
+
async function readBlueBubblesJsonOrThrow(response, label, secrets = []) {
|
|
92
|
+
const text = await response.text().catch(() => '');
|
|
93
|
+
if (!response.ok)
|
|
94
|
+
throw new Error(`${label} ${response.status}${text ? `: ${redactBlueBubblesDetail(text, secrets).slice(0, 240)}` : ''}`);
|
|
95
|
+
let json;
|
|
96
|
+
try {
|
|
97
|
+
json = (text ? JSON.parse(text) : {});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new Error(`${label} ${response.status}: response ไม่ใช่ JSON: ${redactBlueBubblesDetail(text, secrets).slice(0, 240)}`);
|
|
101
|
+
}
|
|
102
|
+
const status = typeof json.status === 'number' ? json.status : response.status;
|
|
103
|
+
if (status >= 400 || json.error) {
|
|
104
|
+
const detail = json.error?.error || json.error?.type || json.message || 'unknown error';
|
|
105
|
+
throw new Error(`${label} status ${status}: ${redactBlueBubblesDetail(detail, secrets).slice(0, 200)}`);
|
|
106
|
+
}
|
|
107
|
+
return json;
|
|
108
|
+
}
|
|
109
|
+
async function postBlueBubbles(config, path, body) {
|
|
110
|
+
const r = await fetch(blueBubblesApiUrl(config, path), {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
});
|
|
115
|
+
return readBlueBubblesJsonOrThrow(r, `BlueBubbles ${path}`, [config.password]);
|
|
116
|
+
}
|
|
117
|
+
async function getBlueBubbles(config, path) {
|
|
118
|
+
const r = await fetch(blueBubblesApiUrl(config, path));
|
|
119
|
+
return readBlueBubblesJsonOrThrow(r, `BlueBubbles ${path}`, [config.password]);
|
|
120
|
+
}
|
|
121
|
+
async function resolveBlueBubblesChatGuid(config, target) {
|
|
122
|
+
if (target.chatGuid)
|
|
123
|
+
return target.chatGuid;
|
|
124
|
+
const cached = chatGuidCache.get(chatGuidCacheKey(config, target.value));
|
|
125
|
+
if (cached)
|
|
126
|
+
return cached;
|
|
127
|
+
const res = await postBlueBubbles(config, '/api/v1/chat/query', {
|
|
128
|
+
limit: 100,
|
|
129
|
+
offset: 0,
|
|
130
|
+
with: ['participants'],
|
|
131
|
+
});
|
|
132
|
+
for (const chat of res.data ?? []) {
|
|
133
|
+
const guid = chat.guid || chat.chatGuid;
|
|
134
|
+
const identifier = chat.chatIdentifier || chat.identifier;
|
|
135
|
+
if (guid && identifier === target.value) {
|
|
136
|
+
rememberChatGuid(config, target.value, guid);
|
|
137
|
+
return guid;
|
|
138
|
+
}
|
|
139
|
+
for (const participant of chat.participants ?? []) {
|
|
140
|
+
if (guid && participant.address?.trim() === target.value) {
|
|
141
|
+
rememberChatGuid(config, target.value, guid);
|
|
142
|
+
return guid;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
async function canCreateBlueBubblesChat(config) {
|
|
149
|
+
try {
|
|
150
|
+
const info = await getBlueBubbles(config, '/api/v1/server/info');
|
|
151
|
+
return Boolean(info.data?.private_api);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function createBlueBubblesChat(config, target, message) {
|
|
158
|
+
const res = await postBlueBubbles(config, '/api/v1/chat/new', {
|
|
159
|
+
addresses: [target],
|
|
160
|
+
message,
|
|
161
|
+
text: message,
|
|
162
|
+
tempGuid: `temp-${randomUUID()}`,
|
|
163
|
+
});
|
|
164
|
+
const messageId = res.data?.guid || res.data?.messageGuid || 'ok';
|
|
165
|
+
return { target, messageIds: [messageId], messageCount: 1 };
|
|
166
|
+
}
|
|
167
|
+
function looksLikeBlueBubblesAddress(target) {
|
|
168
|
+
return target.includes('@') || /^\+\d{7,15}$/.test(target);
|
|
169
|
+
}
|
|
170
|
+
export async function sendBlueBubblesMessage(config, text, explicitTarget) {
|
|
171
|
+
if (!normalizeBlueBubblesServerUrl(config.serverUrl) || !config.password?.trim()) {
|
|
172
|
+
throw new Error('ยังไม่ได้ตั้ง BlueBubbles server URL/password');
|
|
173
|
+
}
|
|
174
|
+
const target = parseBlueBubblesTarget(config, explicitTarget);
|
|
175
|
+
const chunks = chunkBlueBubblesText(text);
|
|
176
|
+
const messageIds = [];
|
|
177
|
+
let chatGuid;
|
|
178
|
+
for (const chunk of chunks) {
|
|
179
|
+
chatGuid = await resolveBlueBubblesChatGuid(config, target);
|
|
180
|
+
if (!chatGuid) {
|
|
181
|
+
if (looksLikeBlueBubblesAddress(target.value) && (await canCreateBlueBubblesChat(config))) {
|
|
182
|
+
if (chunks.length > 1) {
|
|
183
|
+
throw new Error('BlueBubbles new chat ยังไม่รองรับข้อความหลายส่วนแบบปลอดภัย — ส่งข้อความแรกให้สั้นลงหรือระบุ chat GUID ที่มีอยู่');
|
|
184
|
+
}
|
|
185
|
+
return createBlueBubblesChat(config, target.value, chunk);
|
|
186
|
+
}
|
|
187
|
+
throw new Error(`BlueBubbles chat not found for target: ${target.value}`);
|
|
188
|
+
}
|
|
189
|
+
const res = await postBlueBubbles(config, '/api/v1/message/text', {
|
|
190
|
+
chatGuid,
|
|
191
|
+
tempGuid: `temp-${randomUUID()}`,
|
|
192
|
+
message: chunk,
|
|
193
|
+
text: chunk,
|
|
194
|
+
});
|
|
195
|
+
const messageId = res.data?.guid || res.data?.messageGuid || 'ok';
|
|
196
|
+
if (messageId)
|
|
197
|
+
messageIds.push(String(messageId));
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
target: target.value,
|
|
201
|
+
chatGuid,
|
|
202
|
+
messageIds,
|
|
203
|
+
messageCount: messageIds.length || chunks.length,
|
|
204
|
+
};
|
|
205
|
+
}
|