smolerclaw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +30 -0
- package/.github/workflows/release.yml +67 -0
- package/bun.lock +33 -0
- package/dist/index.js +321 -0
- package/dist/tinyclaw.exe +0 -0
- package/install.ps1 +119 -0
- package/package.json +25 -0
- package/skills/business.md +77 -0
- package/skills/default.md +77 -0
- package/src/ansi.ts +164 -0
- package/src/approval.ts +74 -0
- package/src/auth.ts +125 -0
- package/src/briefing.ts +52 -0
- package/src/claude.ts +267 -0
- package/src/cli.ts +137 -0
- package/src/clipboard.ts +27 -0
- package/src/config.ts +87 -0
- package/src/context-window.ts +190 -0
- package/src/context.ts +125 -0
- package/src/decisions.ts +122 -0
- package/src/email.ts +123 -0
- package/src/errors.ts +78 -0
- package/src/export.ts +82 -0
- package/src/finance.ts +148 -0
- package/src/git.ts +62 -0
- package/src/history.ts +100 -0
- package/src/images.ts +68 -0
- package/src/index.ts +1431 -0
- package/src/investigate.ts +415 -0
- package/src/markdown.ts +125 -0
- package/src/memos.ts +191 -0
- package/src/models.ts +94 -0
- package/src/monitor.ts +169 -0
- package/src/morning.ts +108 -0
- package/src/news.ts +329 -0
- package/src/openai-provider.ts +127 -0
- package/src/people.ts +472 -0
- package/src/personas.ts +99 -0
- package/src/platform.ts +84 -0
- package/src/plugins.ts +125 -0
- package/src/pomodoro.ts +169 -0
- package/src/providers.ts +70 -0
- package/src/retry.ts +108 -0
- package/src/session.ts +128 -0
- package/src/skills.ts +102 -0
- package/src/tasks.ts +418 -0
- package/src/tokens.ts +102 -0
- package/src/tool-safety.ts +100 -0
- package/src/tools.ts +1479 -0
- package/src/tui.ts +693 -0
- package/src/types.ts +55 -0
- package/src/undo.ts +83 -0
- package/src/windows.ts +299 -0
- package/src/workflows.ts +197 -0
- package/tests/ansi.test.ts +58 -0
- package/tests/approval.test.ts +43 -0
- package/tests/briefing.test.ts +10 -0
- package/tests/cli.test.ts +53 -0
- package/tests/context-window.test.ts +83 -0
- package/tests/images.test.ts +28 -0
- package/tests/memos.test.ts +116 -0
- package/tests/models.test.ts +34 -0
- package/tests/news.test.ts +13 -0
- package/tests/path-guard.test.ts +37 -0
- package/tests/people.test.ts +204 -0
- package/tests/skills.test.ts +35 -0
- package/tests/ssrf.test.ts +80 -0
- package/tests/tasks.test.ts +152 -0
- package/tests/tokens.test.ts +44 -0
- package/tests/tool-safety.test.ts +55 -0
- package/tests/windows-security.test.ts +59 -0
- package/tests/windows.test.ts +20 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { Message } from './types'
|
|
2
|
+
|
|
3
|
+
// Approximate token limits per model family
|
|
4
|
+
const MODEL_LIMITS: Record<string, number> = {
|
|
5
|
+
haiku: 200_000,
|
|
6
|
+
sonnet: 200_000,
|
|
7
|
+
opus: 200_000,
|
|
8
|
+
}
|
|
9
|
+
const DEFAULT_LIMIT = 200_000
|
|
10
|
+
|
|
11
|
+
// Reserve tokens for system prompt + tool definitions + response
|
|
12
|
+
const RESERVED_TOKENS = 20_000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Estimate token count for a string.
|
|
16
|
+
* Uses a fast heuristic: ~4 chars per token for English.
|
|
17
|
+
* More accurate than counting words, faster than a real tokenizer.
|
|
18
|
+
*/
|
|
19
|
+
export function estimateTokens(text: string): number {
|
|
20
|
+
return Math.ceil(text.length / 3.5)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Estimate total tokens for a list of messages.
|
|
25
|
+
*/
|
|
26
|
+
export function estimateMessageTokens(messages: Message[]): number {
|
|
27
|
+
let total = 0
|
|
28
|
+
for (const msg of messages) {
|
|
29
|
+
total += estimateTokens(msg.content)
|
|
30
|
+
if (msg.toolCalls) {
|
|
31
|
+
for (const tc of msg.toolCalls) {
|
|
32
|
+
total += estimateTokens(JSON.stringify(tc.input))
|
|
33
|
+
total += estimateTokens(tc.result)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
total += 10 // overhead per message (role, metadata)
|
|
37
|
+
}
|
|
38
|
+
return total
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get the effective context limit for a model.
|
|
43
|
+
*/
|
|
44
|
+
function getContextLimit(model: string): number {
|
|
45
|
+
const lower = model.toLowerCase()
|
|
46
|
+
for (const [key, limit] of Object.entries(MODEL_LIMITS)) {
|
|
47
|
+
if (lower.includes(key)) return limit
|
|
48
|
+
}
|
|
49
|
+
return DEFAULT_LIMIT
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Trim messages to fit within context window.
|
|
54
|
+
* Strategy:
|
|
55
|
+
* 1. Keep the first message (often sets context)
|
|
56
|
+
* 2. Keep the last N messages that fit the budget
|
|
57
|
+
* 3. Insert a summary marker where messages were dropped
|
|
58
|
+
*
|
|
59
|
+
* Returns a new array — never mutates the input.
|
|
60
|
+
*/
|
|
61
|
+
export function trimToContextWindow(
|
|
62
|
+
messages: Message[],
|
|
63
|
+
model: string,
|
|
64
|
+
systemPromptTokens: number,
|
|
65
|
+
): Message[] {
|
|
66
|
+
const limit = getContextLimit(model) - RESERVED_TOKENS - systemPromptTokens
|
|
67
|
+
|
|
68
|
+
const totalTokens = estimateMessageTokens(messages)
|
|
69
|
+
if (totalTokens <= limit) return messages
|
|
70
|
+
|
|
71
|
+
// Strategy: keep recent messages, drop older ones from the middle
|
|
72
|
+
// Always keep the first user message if it exists (sets project context)
|
|
73
|
+
const result: Message[] = []
|
|
74
|
+
let budget = limit
|
|
75
|
+
|
|
76
|
+
// Scan from newest to oldest to keep recent context
|
|
77
|
+
const reversed = [...messages].reverse()
|
|
78
|
+
const kept: Message[] = []
|
|
79
|
+
|
|
80
|
+
for (const msg of reversed) {
|
|
81
|
+
const msgTokens = estimateTokens(msg.content) +
|
|
82
|
+
(msg.toolCalls?.reduce((sum, tc) =>
|
|
83
|
+
sum + estimateTokens(JSON.stringify(tc.input)) + estimateTokens(tc.result), 0) ?? 0) +
|
|
84
|
+
10
|
|
85
|
+
|
|
86
|
+
if (budget - msgTokens < 0) break
|
|
87
|
+
budget -= msgTokens
|
|
88
|
+
kept.unshift(msg)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// If we dropped messages, add a system note
|
|
92
|
+
const dropped = messages.length - kept.length
|
|
93
|
+
if (dropped > 0) {
|
|
94
|
+
result.push({
|
|
95
|
+
role: 'user' as const,
|
|
96
|
+
content: `[Note: ${dropped} earlier messages were trimmed to fit context. The conversation continues below.]`,
|
|
97
|
+
timestamp: Date.now(),
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
result.push(...kept)
|
|
102
|
+
return result
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if context needs summarization (at 70% capacity).
|
|
107
|
+
*/
|
|
108
|
+
export function needsSummary(
|
|
109
|
+
messages: Message[],
|
|
110
|
+
model: string,
|
|
111
|
+
systemPromptTokens: number,
|
|
112
|
+
): boolean {
|
|
113
|
+
const limit = getContextLimit(model) - RESERVED_TOKENS - systemPromptTokens
|
|
114
|
+
const total = estimateMessageTokens(messages)
|
|
115
|
+
return total > limit * 0.7
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a summarization prompt from old messages.
|
|
120
|
+
* Returns the messages to summarize and how many to keep intact.
|
|
121
|
+
*/
|
|
122
|
+
export function buildSummaryRequest(
|
|
123
|
+
messages: Message[],
|
|
124
|
+
model: string,
|
|
125
|
+
systemPromptTokens: number,
|
|
126
|
+
): { toSummarize: Message[]; toKeep: Message[] } | null {
|
|
127
|
+
const limit = getContextLimit(model) - RESERVED_TOKENS - systemPromptTokens
|
|
128
|
+
const total = estimateMessageTokens(messages)
|
|
129
|
+
if (total <= limit * 0.7) return null
|
|
130
|
+
|
|
131
|
+
// Keep the last 30% of messages intact, summarize the rest
|
|
132
|
+
const keepCount = Math.max(4, Math.floor(messages.length * 0.3))
|
|
133
|
+
const toSummarize = messages.slice(0, messages.length - keepCount)
|
|
134
|
+
const toKeep = messages.slice(messages.length - keepCount)
|
|
135
|
+
|
|
136
|
+
if (toSummarize.length < 2) return null
|
|
137
|
+
|
|
138
|
+
return { toSummarize, toKeep }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Generate the prompt to send to Claude for summarization.
|
|
143
|
+
*/
|
|
144
|
+
export function summarizationPrompt(messages: Message[]): string {
|
|
145
|
+
const transcript = messages.map((m) => {
|
|
146
|
+
let text = `[${m.role}]: ${m.content.slice(0, 500)}`
|
|
147
|
+
if (m.toolCalls?.length) {
|
|
148
|
+
text += `\n Tools used: ${m.toolCalls.map((tc) => tc.name).join(', ')}`
|
|
149
|
+
}
|
|
150
|
+
return text
|
|
151
|
+
}).join('\n\n')
|
|
152
|
+
|
|
153
|
+
return `Summarize this conversation concisely. Focus on:
|
|
154
|
+
1. Key decisions made
|
|
155
|
+
2. Files created or modified
|
|
156
|
+
3. Important context the user shared
|
|
157
|
+
4. Current state of the task
|
|
158
|
+
|
|
159
|
+
Be brief but preserve actionable information. Output ONLY the summary.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
${transcript}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Summarize tool call results to reduce token usage.
|
|
167
|
+
* Truncates long tool results but preserves the first/last lines.
|
|
168
|
+
*/
|
|
169
|
+
export function compressToolResults(messages: Message[], maxResultLen: number = 2000): Message[] {
|
|
170
|
+
return messages.map((msg) => {
|
|
171
|
+
if (!msg.toolCalls?.length) return msg
|
|
172
|
+
|
|
173
|
+
const compressedCalls = msg.toolCalls.map((tc) => {
|
|
174
|
+
if (tc.result.length <= maxResultLen) return tc
|
|
175
|
+
|
|
176
|
+
const lines = tc.result.split('\n')
|
|
177
|
+
const headCount = Math.min(10, lines.length)
|
|
178
|
+
const tailCount = Math.min(5, Math.max(0, lines.length - headCount))
|
|
179
|
+
const omitted = lines.length - headCount - tailCount
|
|
180
|
+
const parts = [...lines.slice(0, headCount)]
|
|
181
|
+
if (omitted > 0) parts.push(`... (${omitted} lines omitted)`)
|
|
182
|
+
if (tailCount > 0) parts.push(...lines.slice(-tailCount))
|
|
183
|
+
const truncated = parts.join('\n')
|
|
184
|
+
|
|
185
|
+
return { ...tc, result: truncated }
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
return { ...msg, toolCalls: compressedCalls }
|
|
189
|
+
})
|
|
190
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { getShellName, IS_WINDOWS } from './platform'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gather context about the current working environment.
|
|
7
|
+
* Injected into the system prompt so Claude knows where it is.
|
|
8
|
+
*/
|
|
9
|
+
export function gatherContext(): string {
|
|
10
|
+
const cwd = process.cwd()
|
|
11
|
+
const parts: string[] = []
|
|
12
|
+
|
|
13
|
+
parts.push(`Working directory: ${cwd}`)
|
|
14
|
+
parts.push(`Platform: ${process.platform} (${process.arch})`)
|
|
15
|
+
parts.push(`Shell: ${getShellName()}`)
|
|
16
|
+
parts.push(`Runtime: Bun ${Bun.version}`)
|
|
17
|
+
parts.push(`Date: ${new Date().toISOString().split('T')[0]}`)
|
|
18
|
+
|
|
19
|
+
if (IS_WINDOWS) {
|
|
20
|
+
parts.push('Note: Use PowerShell syntax for commands (e.g., Get-ChildItem instead of ls, Get-Content instead of cat).')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const project = detectProject(cwd)
|
|
24
|
+
if (project) parts.push(`Project: ${project}`)
|
|
25
|
+
|
|
26
|
+
const git = detectGit(cwd)
|
|
27
|
+
if (git) parts.push(git)
|
|
28
|
+
|
|
29
|
+
return parts.join('\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function detectProject(cwd: string): string | null {
|
|
33
|
+
const indicators: [string, string][] = [
|
|
34
|
+
['package.json', 'Node.js/JavaScript'],
|
|
35
|
+
['Cargo.toml', 'Rust'],
|
|
36
|
+
['go.mod', 'Go'],
|
|
37
|
+
['pyproject.toml', 'Python'],
|
|
38
|
+
['requirements.txt', 'Python'],
|
|
39
|
+
['pom.xml', 'Java (Maven)'],
|
|
40
|
+
['build.gradle', 'Java (Gradle)'],
|
|
41
|
+
['Gemfile', 'Ruby'],
|
|
42
|
+
['composer.json', 'PHP'],
|
|
43
|
+
['Makefile', 'Make'],
|
|
44
|
+
['CMakeLists.txt', 'C/C++ (CMake)'],
|
|
45
|
+
['Dockerfile', 'Docker'],
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const detected: string[] = []
|
|
49
|
+
for (const [file, label] of indicators) {
|
|
50
|
+
if (existsSync(join(cwd, file))) {
|
|
51
|
+
detected.push(label)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (detected.length === 0) return null
|
|
56
|
+
|
|
57
|
+
let name = basename(cwd)
|
|
58
|
+
try {
|
|
59
|
+
const pkg = join(cwd, 'package.json')
|
|
60
|
+
if (existsSync(pkg)) {
|
|
61
|
+
const data = JSON.parse(readFileSync(pkg, 'utf-8'))
|
|
62
|
+
if (data.name) name = data.name
|
|
63
|
+
}
|
|
64
|
+
} catch { /* ignore */ }
|
|
65
|
+
|
|
66
|
+
return `Project: ${name} (${detected.join(', ')})`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Gather rich git context: branch, last commit, changed files summary.
|
|
71
|
+
*/
|
|
72
|
+
function detectGit(cwd: string): string | null {
|
|
73
|
+
if (!existsSync(join(cwd, '.git'))) return null
|
|
74
|
+
|
|
75
|
+
const lines: string[] = []
|
|
76
|
+
|
|
77
|
+
// Branch
|
|
78
|
+
try {
|
|
79
|
+
const head = readFileSync(join(cwd, '.git', 'HEAD'), 'utf-8').trim()
|
|
80
|
+
const branch = head.startsWith('ref: refs/heads/')
|
|
81
|
+
? head.slice('ref: refs/heads/'.length)
|
|
82
|
+
: head.slice(0, 8)
|
|
83
|
+
lines.push(`Git branch: ${branch}`)
|
|
84
|
+
} catch {
|
|
85
|
+
lines.push('Git: initialized')
|
|
86
|
+
return lines.join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Last commit (read from git log via COMMIT_EDITMSG or packed-refs is unreliable, use spawn)
|
|
90
|
+
try {
|
|
91
|
+
const proc = Bun.spawnSync(['git', 'log', '--oneline', '-1'], { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
92
|
+
if (proc.exitCode === 0) {
|
|
93
|
+
const lastCommit = new TextDecoder().decode(proc.stdout).trim()
|
|
94
|
+
if (lastCommit) lines.push(`Last commit: ${lastCommit}`)
|
|
95
|
+
}
|
|
96
|
+
} catch { /* ignore */ }
|
|
97
|
+
|
|
98
|
+
// Changed files summary (git diff --stat, limited)
|
|
99
|
+
try {
|
|
100
|
+
const proc = Bun.spawnSync(['git', 'diff', '--stat', '--stat-width=60'], { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
101
|
+
if (proc.exitCode === 0) {
|
|
102
|
+
const diff = new TextDecoder().decode(proc.stdout).trim()
|
|
103
|
+
if (diff) {
|
|
104
|
+
const diffLines = diff.split('\n')
|
|
105
|
+
const shown = diffLines.slice(0, 15)
|
|
106
|
+
if (diffLines.length > 15) shown.push(`... and ${diffLines.length - 15} more files`)
|
|
107
|
+
lines.push('Uncommitted changes:\n' + shown.join('\n'))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch { /* ignore */ }
|
|
111
|
+
|
|
112
|
+
// Staged files
|
|
113
|
+
try {
|
|
114
|
+
const proc = Bun.spawnSync(['git', 'diff', '--cached', '--stat', '--stat-width=60'], { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
115
|
+
if (proc.exitCode === 0) {
|
|
116
|
+
const staged = new TextDecoder().decode(proc.stdout).trim()
|
|
117
|
+
if (staged) {
|
|
118
|
+
const stagedLines = staged.split('\n').slice(0, 10)
|
|
119
|
+
lines.push('Staged:\n' + stagedLines.join('\n'))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch { /* ignore */ }
|
|
123
|
+
|
|
124
|
+
return lines.length > 0 ? lines.join('\n') : null
|
|
125
|
+
}
|
package/src/decisions.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision log — record important decisions with context and rationale.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
// ─── Types ──────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface Decision {
|
|
11
|
+
id: string
|
|
12
|
+
title: string
|
|
13
|
+
context: string // why this decision was needed
|
|
14
|
+
chosen: string // what was decided
|
|
15
|
+
alternatives?: string // what was considered but rejected
|
|
16
|
+
tags: string[]
|
|
17
|
+
date: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Storage ────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
let _dataDir = ''
|
|
23
|
+
let _decisions: Decision[] = []
|
|
24
|
+
|
|
25
|
+
const DATA_FILE = () => join(_dataDir, 'decisions.json')
|
|
26
|
+
|
|
27
|
+
function save(): void {
|
|
28
|
+
writeFileSync(DATA_FILE(), JSON.stringify(_decisions, null, 2))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function load(): void {
|
|
32
|
+
const file = DATA_FILE()
|
|
33
|
+
if (!existsSync(file)) { _decisions = []; return }
|
|
34
|
+
try { _decisions = JSON.parse(readFileSync(file, 'utf-8')) }
|
|
35
|
+
catch { _decisions = [] }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Init ───────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export function initDecisions(dataDir: string): void {
|
|
41
|
+
_dataDir = dataDir
|
|
42
|
+
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true })
|
|
43
|
+
load()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Operations ─────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export function logDecision(
|
|
49
|
+
title: string,
|
|
50
|
+
context: string,
|
|
51
|
+
chosen: string,
|
|
52
|
+
alternatives?: string,
|
|
53
|
+
tags: string[] = [],
|
|
54
|
+
): Decision {
|
|
55
|
+
const decision: Decision = {
|
|
56
|
+
id: genId(),
|
|
57
|
+
title: title.trim(),
|
|
58
|
+
context: context.trim(),
|
|
59
|
+
chosen: chosen.trim(),
|
|
60
|
+
alternatives: alternatives?.trim(),
|
|
61
|
+
tags: tags.map((t) => t.toLowerCase()),
|
|
62
|
+
date: new Date().toISOString(),
|
|
63
|
+
}
|
|
64
|
+
_decisions = [..._decisions, decision]
|
|
65
|
+
save()
|
|
66
|
+
return decision
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function searchDecisions(query: string): Decision[] {
|
|
70
|
+
const lower = query.toLowerCase()
|
|
71
|
+
return _decisions.filter((d) =>
|
|
72
|
+
d.title.toLowerCase().includes(lower) ||
|
|
73
|
+
d.chosen.toLowerCase().includes(lower) ||
|
|
74
|
+
d.context.toLowerCase().includes(lower) ||
|
|
75
|
+
d.tags.some((t) => t.includes(lower)),
|
|
76
|
+
).sort((a, b) =>
|
|
77
|
+
new Date(b.date).getTime() - new Date(a.date).getTime(),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function listDecisions(limit = 15): Decision[] {
|
|
82
|
+
return [..._decisions]
|
|
83
|
+
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
84
|
+
.slice(0, limit)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Formatting ─────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export function formatDecisionList(decisions: Decision[]): string {
|
|
90
|
+
if (decisions.length === 0) return 'Nenhuma decisao registrada.'
|
|
91
|
+
|
|
92
|
+
const lines = decisions.map((d) => {
|
|
93
|
+
const date = new Date(d.date).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })
|
|
94
|
+
const tags = d.tags.length > 0 ? ` [${d.tags.join(', ')}]` : ''
|
|
95
|
+
return ` [${date}] ${d.title}${tags} {${d.id}}`
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return `Decisoes (${decisions.length}):\n${lines.join('\n')}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function formatDecisionDetail(d: Decision): string {
|
|
102
|
+
const date = new Date(d.date).toLocaleDateString('pt-BR')
|
|
103
|
+
const lines = [
|
|
104
|
+
`--- Decisao {${d.id}} ---`,
|
|
105
|
+
`Titulo: ${d.title}`,
|
|
106
|
+
`Data: ${date}`,
|
|
107
|
+
`\nContexto: ${d.context}`,
|
|
108
|
+
`\nEscolha: ${d.chosen}`,
|
|
109
|
+
]
|
|
110
|
+
if (d.alternatives) lines.push(`\nAlternativas descartadas: ${d.alternatives}`)
|
|
111
|
+
if (d.tags.length > 0) lines.push(`\nTags: ${d.tags.join(', ')}`)
|
|
112
|
+
return lines.join('\n')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function genId(): string {
|
|
118
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
119
|
+
let id = ''
|
|
120
|
+
for (let i = 0; i < 6; i++) id += chars[Math.floor(Math.random() * chars.length)]
|
|
121
|
+
return id
|
|
122
|
+
}
|
package/src/email.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email draft system — generate drafts and open in Outlook.
|
|
3
|
+
* Uses mailto: URI for cross-platform or Outlook COM on Windows.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { IS_WINDOWS } from './platform'
|
|
7
|
+
|
|
8
|
+
export interface EmailDraft {
|
|
9
|
+
to: string
|
|
10
|
+
subject: string
|
|
11
|
+
body: string
|
|
12
|
+
cc?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Open an email draft in the default mail client.
|
|
17
|
+
* On Windows, tries Outlook COM first, then falls back to mailto:.
|
|
18
|
+
*/
|
|
19
|
+
export async function openEmailDraft(draft: EmailDraft): Promise<string> {
|
|
20
|
+
if (IS_WINDOWS) {
|
|
21
|
+
return openInOutlook(draft)
|
|
22
|
+
}
|
|
23
|
+
return openMailto(draft)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Open draft via Outlook COM (Windows only).
|
|
28
|
+
* Creates a new mail item with fields pre-filled.
|
|
29
|
+
*/
|
|
30
|
+
async function openInOutlook(draft: EmailDraft): Promise<string> {
|
|
31
|
+
// Escape single quotes for PowerShell
|
|
32
|
+
const to = draft.to.replace(/'/g, "''")
|
|
33
|
+
const subject = draft.subject.replace(/'/g, "''")
|
|
34
|
+
const body = draft.body.replace(/'/g, "''").replace(/\n/g, '`n')
|
|
35
|
+
const cc = draft.cc?.replace(/'/g, "''") || ''
|
|
36
|
+
|
|
37
|
+
const cmd = [
|
|
38
|
+
'try {',
|
|
39
|
+
' $outlook = New-Object -ComObject Outlook.Application -ErrorAction Stop',
|
|
40
|
+
' $mail = $outlook.CreateItem(0)', // olMailItem = 0
|
|
41
|
+
` $mail.To = '${to}'`,
|
|
42
|
+
` $mail.Subject = '${subject}'`,
|
|
43
|
+
` $mail.Body = '${body}'`,
|
|
44
|
+
cc ? ` $mail.CC = '${cc}'` : '',
|
|
45
|
+
' $mail.Display()',
|
|
46
|
+
' "Email aberto no Outlook."',
|
|
47
|
+
'} catch {',
|
|
48
|
+
' "Outlook nao disponivel. Usando mailto..."',
|
|
49
|
+
'}',
|
|
50
|
+
].filter(Boolean).join('\n')
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const proc = Bun.spawn(
|
|
54
|
+
['powershell', '-NoProfile', '-NonInteractive', '-Command', cmd],
|
|
55
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
56
|
+
)
|
|
57
|
+
const timer = setTimeout(() => proc.kill(), 15_000)
|
|
58
|
+
const [stdout] = await Promise.all([
|
|
59
|
+
new Response(proc.stdout).text(),
|
|
60
|
+
new Response(proc.stderr).text(),
|
|
61
|
+
])
|
|
62
|
+
await proc.exited
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
|
|
65
|
+
const result = stdout.trim()
|
|
66
|
+
|
|
67
|
+
// If Outlook failed, fall back to mailto
|
|
68
|
+
if (result.includes('mailto')) {
|
|
69
|
+
return openMailto(draft)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return result || 'Email aberto no Outlook.'
|
|
73
|
+
} catch {
|
|
74
|
+
return openMailto(draft)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Open draft via mailto: URI (cross-platform fallback).
|
|
80
|
+
*/
|
|
81
|
+
async function openMailto(draft: EmailDraft): Promise<string> {
|
|
82
|
+
const params: string[] = []
|
|
83
|
+
if (draft.subject) params.push(`subject=${encodeURIComponent(draft.subject)}`)
|
|
84
|
+
if (draft.body) params.push(`body=${encodeURIComponent(draft.body)}`)
|
|
85
|
+
if (draft.cc) params.push(`cc=${encodeURIComponent(draft.cc)}`)
|
|
86
|
+
|
|
87
|
+
const mailto = `mailto:${encodeURIComponent(draft.to)}${params.length ? '?' + params.join('&') : ''}`
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const openCmd = IS_WINDOWS
|
|
91
|
+
? ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Start-Process '${mailto}'`]
|
|
92
|
+
: ['xdg-open', mailto]
|
|
93
|
+
|
|
94
|
+
const proc = Bun.spawn(openCmd, { stdout: 'pipe', stderr: 'pipe' })
|
|
95
|
+
const timer = setTimeout(() => proc.kill(), 10_000)
|
|
96
|
+
await Promise.all([
|
|
97
|
+
new Response(proc.stdout).text(),
|
|
98
|
+
new Response(proc.stderr).text(),
|
|
99
|
+
])
|
|
100
|
+
await proc.exited
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
|
|
103
|
+
return 'Email aberto no cliente de email padrao.'
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Format a draft for preview in the TUI.
|
|
111
|
+
*/
|
|
112
|
+
export function formatDraftPreview(draft: EmailDraft): string {
|
|
113
|
+
const lines = [
|
|
114
|
+
'--- Rascunho de Email ---',
|
|
115
|
+
`Para: ${draft.to}`,
|
|
116
|
+
]
|
|
117
|
+
if (draft.cc) lines.push(`CC: ${draft.cc}`)
|
|
118
|
+
lines.push(`Assunto: ${draft.subject}`)
|
|
119
|
+
lines.push('')
|
|
120
|
+
lines.push(draft.body)
|
|
121
|
+
lines.push('------------------------')
|
|
122
|
+
return lines.join('\n')
|
|
123
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translate Anthropic API errors to actionable user messages.
|
|
3
|
+
*/
|
|
4
|
+
export function humanizeError(err: unknown): string {
|
|
5
|
+
if (!(err instanceof Error)) return String(err)
|
|
6
|
+
|
|
7
|
+
const status = (err as { status?: number }).status
|
|
8
|
+
const msg = err.message
|
|
9
|
+
|
|
10
|
+
// HTTP status-based errors
|
|
11
|
+
if (status) {
|
|
12
|
+
switch (status) {
|
|
13
|
+
case 400:
|
|
14
|
+
if (msg.includes('context_length') || msg.includes('too many tokens')) {
|
|
15
|
+
return 'Message too long for the model\'s context window. Try /clear to start fresh or use a shorter prompt.'
|
|
16
|
+
}
|
|
17
|
+
return `Bad request: ${extractDetail(msg)}`
|
|
18
|
+
|
|
19
|
+
case 401:
|
|
20
|
+
return 'Authentication failed. Your API key or subscription token may be expired.\n' +
|
|
21
|
+
'Try: Set ANTHROPIC_API_KEY env var, or run `claude` to refresh subscription credentials.'
|
|
22
|
+
|
|
23
|
+
case 403:
|
|
24
|
+
return 'Access denied. Your API key may not have permission for this model.\n' +
|
|
25
|
+
'Try: /model haiku (uses a more accessible model).'
|
|
26
|
+
|
|
27
|
+
case 404:
|
|
28
|
+
return `Model not found. The model "${extractModel(msg)}" may not exist or be unavailable.\n` +
|
|
29
|
+
'Try: /model to see available models.'
|
|
30
|
+
|
|
31
|
+
case 429:
|
|
32
|
+
return 'Rate limited. Too many requests in a short period.\n' +
|
|
33
|
+
'The request will be retried automatically. If this persists, wait a minute.'
|
|
34
|
+
|
|
35
|
+
case 500:
|
|
36
|
+
case 502:
|
|
37
|
+
case 503:
|
|
38
|
+
return 'Anthropic API is temporarily unavailable. Retrying automatically...'
|
|
39
|
+
|
|
40
|
+
case 529:
|
|
41
|
+
return 'Anthropic API is overloaded. Retrying with backoff...'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Network errors
|
|
46
|
+
const lower = msg.toLowerCase()
|
|
47
|
+
if (lower.includes('econnrefused') || lower.includes('enotfound')) {
|
|
48
|
+
return 'Cannot connect to Anthropic API. Check your internet connection.'
|
|
49
|
+
}
|
|
50
|
+
if (lower.includes('etimedout') || lower.includes('socket hang up')) {
|
|
51
|
+
return 'Connection to Anthropic API timed out. Retrying...'
|
|
52
|
+
}
|
|
53
|
+
if (lower.includes('econnreset')) {
|
|
54
|
+
return 'Connection was reset. This usually recovers automatically.'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Subscription-specific
|
|
58
|
+
if (lower.includes('expired') || lower.includes('invalid_api_key')) {
|
|
59
|
+
return 'Your authentication has expired. Run `claude` to refresh, or set a new ANTHROPIC_API_KEY.'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Default: return original with prefix
|
|
63
|
+
return msg
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function extractDetail(msg: string): string {
|
|
67
|
+
// Try to extract the "detail" or "message" field from API error JSON
|
|
68
|
+
try {
|
|
69
|
+
const match = msg.match(/"message"\s*:\s*"([^"]+)"/)
|
|
70
|
+
if (match) return match[1]
|
|
71
|
+
} catch { /* ignore */ }
|
|
72
|
+
return msg.length > 200 ? msg.slice(0, 200) + '...' : msg
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractModel(msg: string): string {
|
|
76
|
+
const match = msg.match(/model[:\s]+"?([a-z0-9-]+)"?/i)
|
|
77
|
+
return match ? match[1] : 'unknown'
|
|
78
|
+
}
|