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.
Files changed (72) hide show
  1. package/dist/README.md +159 -0
  2. package/package.json +11 -3
  3. package/.github/workflows/ci.yml +0 -30
  4. package/.github/workflows/release.yml +0 -67
  5. package/bun.lock +0 -33
  6. package/install.ps1 +0 -119
  7. package/skills/business.md +0 -77
  8. package/skills/default.md +0 -77
  9. package/src/ansi.ts +0 -164
  10. package/src/approval.ts +0 -74
  11. package/src/auth.ts +0 -125
  12. package/src/briefing.ts +0 -52
  13. package/src/claude.ts +0 -267
  14. package/src/cli.ts +0 -137
  15. package/src/clipboard.ts +0 -27
  16. package/src/config.ts +0 -87
  17. package/src/context-window.ts +0 -190
  18. package/src/context.ts +0 -125
  19. package/src/decisions.ts +0 -122
  20. package/src/email.ts +0 -123
  21. package/src/errors.ts +0 -78
  22. package/src/export.ts +0 -82
  23. package/src/finance.ts +0 -148
  24. package/src/git.ts +0 -62
  25. package/src/history.ts +0 -100
  26. package/src/images.ts +0 -68
  27. package/src/index.ts +0 -1431
  28. package/src/investigate.ts +0 -415
  29. package/src/markdown.ts +0 -125
  30. package/src/memos.ts +0 -191
  31. package/src/models.ts +0 -94
  32. package/src/monitor.ts +0 -169
  33. package/src/morning.ts +0 -108
  34. package/src/news.ts +0 -329
  35. package/src/openai-provider.ts +0 -127
  36. package/src/people.ts +0 -472
  37. package/src/personas.ts +0 -99
  38. package/src/platform.ts +0 -84
  39. package/src/plugins.ts +0 -125
  40. package/src/pomodoro.ts +0 -169
  41. package/src/providers.ts +0 -70
  42. package/src/retry.ts +0 -108
  43. package/src/session.ts +0 -128
  44. package/src/skills.ts +0 -102
  45. package/src/tasks.ts +0 -418
  46. package/src/tokens.ts +0 -102
  47. package/src/tool-safety.ts +0 -100
  48. package/src/tools.ts +0 -1479
  49. package/src/tui.ts +0 -693
  50. package/src/types.ts +0 -55
  51. package/src/undo.ts +0 -83
  52. package/src/windows.ts +0 -299
  53. package/src/workflows.ts +0 -197
  54. package/tests/ansi.test.ts +0 -58
  55. package/tests/approval.test.ts +0 -43
  56. package/tests/briefing.test.ts +0 -10
  57. package/tests/cli.test.ts +0 -53
  58. package/tests/context-window.test.ts +0 -83
  59. package/tests/images.test.ts +0 -28
  60. package/tests/memos.test.ts +0 -116
  61. package/tests/models.test.ts +0 -34
  62. package/tests/news.test.ts +0 -13
  63. package/tests/path-guard.test.ts +0 -37
  64. package/tests/people.test.ts +0 -204
  65. package/tests/skills.test.ts +0 -35
  66. package/tests/ssrf.test.ts +0 -80
  67. package/tests/tasks.test.ts +0 -152
  68. package/tests/tokens.test.ts +0 -44
  69. package/tests/tool-safety.test.ts +0 -55
  70. package/tests/windows-security.test.ts +0 -59
  71. package/tests/windows.test.ts +0 -20
  72. 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
- }
@@ -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
- }