smolerclaw 1.0.0 → 1.0.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/dist/README.md +159 -0
- package/package.json +11 -3
- package/.github/workflows/ci.yml +0 -30
- package/.github/workflows/release.yml +0 -67
- package/bun.lock +0 -33
- package/install.ps1 +0 -119
- package/skills/business.md +0 -77
- package/skills/default.md +0 -77
- package/src/ansi.ts +0 -164
- package/src/approval.ts +0 -74
- package/src/auth.ts +0 -125
- package/src/briefing.ts +0 -52
- package/src/claude.ts +0 -267
- package/src/cli.ts +0 -137
- package/src/clipboard.ts +0 -27
- package/src/config.ts +0 -87
- package/src/context-window.ts +0 -190
- package/src/context.ts +0 -125
- package/src/decisions.ts +0 -122
- package/src/email.ts +0 -123
- package/src/errors.ts +0 -78
- package/src/export.ts +0 -82
- package/src/finance.ts +0 -148
- package/src/git.ts +0 -62
- package/src/history.ts +0 -100
- package/src/images.ts +0 -68
- package/src/index.ts +0 -1431
- package/src/investigate.ts +0 -415
- package/src/markdown.ts +0 -125
- package/src/memos.ts +0 -191
- package/src/models.ts +0 -94
- package/src/monitor.ts +0 -169
- package/src/morning.ts +0 -108
- package/src/news.ts +0 -329
- package/src/openai-provider.ts +0 -127
- package/src/people.ts +0 -472
- package/src/personas.ts +0 -99
- package/src/platform.ts +0 -84
- package/src/plugins.ts +0 -125
- package/src/pomodoro.ts +0 -169
- package/src/providers.ts +0 -70
- package/src/retry.ts +0 -108
- package/src/session.ts +0 -128
- package/src/skills.ts +0 -102
- package/src/tasks.ts +0 -418
- package/src/tokens.ts +0 -102
- package/src/tool-safety.ts +0 -100
- package/src/tools.ts +0 -1479
- package/src/tui.ts +0 -693
- package/src/types.ts +0 -55
- package/src/undo.ts +0 -83
- package/src/windows.ts +0 -299
- package/src/workflows.ts +0 -197
- package/tests/ansi.test.ts +0 -58
- package/tests/approval.test.ts +0 -43
- package/tests/briefing.test.ts +0 -10
- package/tests/cli.test.ts +0 -53
- package/tests/context-window.test.ts +0 -83
- package/tests/images.test.ts +0 -28
- package/tests/memos.test.ts +0 -116
- package/tests/models.test.ts +0 -34
- package/tests/news.test.ts +0 -13
- package/tests/path-guard.test.ts +0 -37
- package/tests/people.test.ts +0 -204
- package/tests/skills.test.ts +0 -35
- package/tests/ssrf.test.ts +0 -80
- package/tests/tasks.test.ts +0 -152
- package/tests/tokens.test.ts +0 -44
- package/tests/tool-safety.test.ts +0 -55
- package/tests/windows-security.test.ts +0 -59
- package/tests/windows.test.ts +0 -20
- package/tsconfig.json +0 -19
package/src/cli.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
2
|
-
import { join, dirname } from 'node:path'
|
|
3
|
-
|
|
4
|
-
export interface CliArgs {
|
|
5
|
-
help: boolean
|
|
6
|
-
version: boolean
|
|
7
|
-
model?: string
|
|
8
|
-
session?: string
|
|
9
|
-
maxTokens?: number
|
|
10
|
-
noTools: boolean
|
|
11
|
-
print: boolean
|
|
12
|
-
prompt?: string
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Parse CLI arguments. Zero dependencies.
|
|
17
|
-
*/
|
|
18
|
-
export function parseArgs(argv: string[]): CliArgs {
|
|
19
|
-
const args: CliArgs = {
|
|
20
|
-
help: false,
|
|
21
|
-
version: false,
|
|
22
|
-
noTools: false,
|
|
23
|
-
print: false,
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const positional: string[] = []
|
|
27
|
-
let i = 0
|
|
28
|
-
|
|
29
|
-
while (i < argv.length) {
|
|
30
|
-
const arg = argv[i]
|
|
31
|
-
|
|
32
|
-
switch (arg) {
|
|
33
|
-
case '-h':
|
|
34
|
-
case '--help':
|
|
35
|
-
args.help = true
|
|
36
|
-
break
|
|
37
|
-
|
|
38
|
-
case '-v':
|
|
39
|
-
case '--version':
|
|
40
|
-
args.version = true
|
|
41
|
-
break
|
|
42
|
-
|
|
43
|
-
case '-m':
|
|
44
|
-
case '--model':
|
|
45
|
-
args.model = argv[++i]
|
|
46
|
-
if (!args.model) die('--model requires a value')
|
|
47
|
-
break
|
|
48
|
-
|
|
49
|
-
case '-s':
|
|
50
|
-
case '--session':
|
|
51
|
-
args.session = argv[++i]
|
|
52
|
-
if (!args.session) die('--session requires a value')
|
|
53
|
-
break
|
|
54
|
-
|
|
55
|
-
case '--max-tokens':
|
|
56
|
-
const n = Number(argv[++i])
|
|
57
|
-
if (!n || n <= 0) die('--max-tokens requires a positive number')
|
|
58
|
-
args.maxTokens = n
|
|
59
|
-
break
|
|
60
|
-
|
|
61
|
-
case '--no-tools':
|
|
62
|
-
args.noTools = true
|
|
63
|
-
break
|
|
64
|
-
|
|
65
|
-
case '-p':
|
|
66
|
-
case '--print':
|
|
67
|
-
args.print = true
|
|
68
|
-
break
|
|
69
|
-
|
|
70
|
-
default:
|
|
71
|
-
if (arg.startsWith('-')) {
|
|
72
|
-
die(`Unknown option: ${arg}. Try --help`)
|
|
73
|
-
}
|
|
74
|
-
positional.push(arg)
|
|
75
|
-
}
|
|
76
|
-
i++
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (positional.length > 0) {
|
|
80
|
-
args.prompt = positional.join(' ')
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return args
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// BUILD_VERSION is injected at compile time via --define.
|
|
87
|
-
// Falls back to reading package.json at runtime (dev mode).
|
|
88
|
-
declare const BUILD_VERSION: string | undefined
|
|
89
|
-
|
|
90
|
-
export function getVersion(): string {
|
|
91
|
-
if (typeof BUILD_VERSION !== 'undefined') return BUILD_VERSION
|
|
92
|
-
try {
|
|
93
|
-
const pkgPath = join(dirname(import.meta.dir), 'package.json')
|
|
94
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
95
|
-
return pkg.version || '0.0.0'
|
|
96
|
-
} catch {
|
|
97
|
-
return '0.0.0'
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function printHelp(): void {
|
|
102
|
-
const version = getVersion()
|
|
103
|
-
console.log(`smolerclaw v${version} — the micro AI assistant
|
|
104
|
-
|
|
105
|
-
Usage:
|
|
106
|
-
smolerclaw [options] [prompt]
|
|
107
|
-
|
|
108
|
-
Options:
|
|
109
|
-
-h, --help Show this help
|
|
110
|
-
-v, --version Show version
|
|
111
|
-
-m, --model <name> Override model (e.g. claude-sonnet-4-20250514)
|
|
112
|
-
-s, --session <name> Start with a specific session
|
|
113
|
-
--max-tokens <n> Override max tokens per response
|
|
114
|
-
--no-tools Disable tool use for this session
|
|
115
|
-
-p, --print Print response and exit (no TUI)
|
|
116
|
-
|
|
117
|
-
Examples:
|
|
118
|
-
smolerclaw Interactive TUI mode
|
|
119
|
-
smolerclaw "explain this error" Launch TUI with initial prompt
|
|
120
|
-
smolerclaw -p "what is 2+2" Print answer and exit
|
|
121
|
-
echo "review" | smolerclaw -p Pipe input, print response
|
|
122
|
-
smolerclaw -m claude-sonnet-4-20250514 -s work
|
|
123
|
-
|
|
124
|
-
Commands (inside TUI):
|
|
125
|
-
/help Show commands /clear Clear conversation
|
|
126
|
-
/new New session /load Load session
|
|
127
|
-
/model Show/set model /persona Switch mode
|
|
128
|
-
/briefing Daily briefing /news News radar
|
|
129
|
-
/task Create task /tasks List tasks
|
|
130
|
-
/open Open Windows app /calendar Outlook calendar
|
|
131
|
-
/export Export markdown /exit Quit`)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function die(msg: string): never {
|
|
135
|
-
console.error(`smolerclaw: ${msg}`)
|
|
136
|
-
process.exit(2)
|
|
137
|
-
}
|
package/src/clipboard.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { IS_WINDOWS, IS_MAC } from './platform'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Copy text to system clipboard. Cross-platform.
|
|
5
|
-
*/
|
|
6
|
-
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
7
|
-
try {
|
|
8
|
-
const cmd = IS_WINDOWS
|
|
9
|
-
? ['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value $input']
|
|
10
|
-
: IS_MAC
|
|
11
|
-
? ['pbcopy']
|
|
12
|
-
: ['xclip', '-selection', 'clipboard']
|
|
13
|
-
|
|
14
|
-
const proc = Bun.spawn(cmd, {
|
|
15
|
-
stdin: 'pipe',
|
|
16
|
-
stdout: 'pipe',
|
|
17
|
-
stderr: 'pipe',
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
proc.stdin.write(text)
|
|
21
|
-
proc.stdin.end()
|
|
22
|
-
const code = await proc.exited
|
|
23
|
-
return code === 0
|
|
24
|
-
} catch {
|
|
25
|
-
return false
|
|
26
|
-
}
|
|
27
|
-
}
|
package/src/config.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { homedir } from 'node:os'
|
|
3
|
-
import { join } from 'node:path'
|
|
4
|
-
import { IS_WINDOWS } from './platform'
|
|
5
|
-
import type { TinyClawConfig } from './types'
|
|
6
|
-
|
|
7
|
-
const HOME = homedir()
|
|
8
|
-
|
|
9
|
-
// Platform-aware directories
|
|
10
|
-
const CONFIG_DIR = IS_WINDOWS
|
|
11
|
-
? join(process.env.APPDATA || join(HOME, 'AppData', 'Roaming'), 'smolerclaw')
|
|
12
|
-
: join(HOME, '.config', 'smolerclaw')
|
|
13
|
-
|
|
14
|
-
const DATA_DIR = IS_WINDOWS
|
|
15
|
-
? join(process.env.LOCALAPPDATA || join(HOME, 'AppData', 'Local'), 'smolerclaw')
|
|
16
|
-
: join(HOME, '.local', 'share', 'smolerclaw')
|
|
17
|
-
|
|
18
|
-
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
|
19
|
-
|
|
20
|
-
const DEFAULTS: TinyClawConfig = {
|
|
21
|
-
apiKey: '',
|
|
22
|
-
authMode: 'auto',
|
|
23
|
-
model: 'claude-haiku-4-5-20251001',
|
|
24
|
-
maxTokens: 4096,
|
|
25
|
-
maxHistory: 50,
|
|
26
|
-
systemPrompt: '',
|
|
27
|
-
skillsDir: './skills',
|
|
28
|
-
dataDir: DATA_DIR,
|
|
29
|
-
toolApproval: 'auto',
|
|
30
|
-
language: 'auto',
|
|
31
|
-
maxSessionCost: 0,
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function ensureDir(dir: string): void {
|
|
35
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function loadConfig(): TinyClawConfig {
|
|
39
|
-
ensureDir(CONFIG_DIR)
|
|
40
|
-
ensureDir(DATA_DIR)
|
|
41
|
-
ensureDir(join(DATA_DIR, 'sessions'))
|
|
42
|
-
|
|
43
|
-
// Migrate from old Linux-style paths on Windows if they exist
|
|
44
|
-
if (IS_WINDOWS) {
|
|
45
|
-
migrateOldPaths()
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (!existsSync(CONFIG_FILE)) {
|
|
49
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULTS, null, 2))
|
|
50
|
-
return { ...DEFAULTS }
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
let raw: Record<string, unknown>
|
|
54
|
-
try {
|
|
55
|
-
raw = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
|
|
56
|
-
} catch {
|
|
57
|
-
// Config file corrupted — reset to defaults
|
|
58
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULTS, null, 2))
|
|
59
|
-
return { ...DEFAULTS }
|
|
60
|
-
}
|
|
61
|
-
return { ...DEFAULTS, ...raw }
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function saveConfig(config: TinyClawConfig): void {
|
|
65
|
-
ensureDir(CONFIG_DIR)
|
|
66
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function getConfigPath(): string {
|
|
70
|
-
return CONFIG_FILE
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function getDataDir(): string {
|
|
74
|
-
return DATA_DIR
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** One-time migration from old ~/.config/smolerclaw paths on Windows */
|
|
78
|
-
function migrateOldPaths(): void {
|
|
79
|
-
const oldConfig = join(HOME, '.config', 'smolerclaw', 'config.json')
|
|
80
|
-
if (existsSync(oldConfig) && !existsSync(CONFIG_FILE)) {
|
|
81
|
-
try {
|
|
82
|
-
const data = readFileSync(oldConfig, 'utf-8')
|
|
83
|
-
ensureDir(CONFIG_DIR)
|
|
84
|
-
writeFileSync(CONFIG_FILE, data)
|
|
85
|
-
} catch { /* best effort */ }
|
|
86
|
-
}
|
|
87
|
-
}
|
package/src/context-window.ts
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
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
|
-
}
|