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
package/src/tasks.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task/reminder system with scheduled Windows notifications.
|
|
3
|
+
* Tasks are stored as JSON in the data directory.
|
|
4
|
+
* A background timer checks every 30s for due tasks and fires
|
|
5
|
+
* Windows toast notifications via PowerShell.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { IS_WINDOWS } from './platform'
|
|
11
|
+
|
|
12
|
+
// ─── Types ──────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface Task {
|
|
15
|
+
id: string
|
|
16
|
+
title: string
|
|
17
|
+
dueAt: string | null // ISO 8601 datetime, null = no reminder
|
|
18
|
+
createdAt: string // ISO 8601 datetime
|
|
19
|
+
done: boolean
|
|
20
|
+
notified: boolean // whether the notification was already sent
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Storage ────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
let _dataDir = ''
|
|
26
|
+
let _tasks: Task[] = []
|
|
27
|
+
let _checkTimer: ReturnType<typeof setInterval> | null = null
|
|
28
|
+
let _onNotify: ((task: Task) => void) | null = null
|
|
29
|
+
|
|
30
|
+
const TASKS_FILE = () => join(_dataDir, 'tasks.json')
|
|
31
|
+
|
|
32
|
+
function save(): void {
|
|
33
|
+
writeFileSync(TASKS_FILE(), JSON.stringify(_tasks, null, 2))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function load(): void {
|
|
37
|
+
const file = TASKS_FILE()
|
|
38
|
+
if (!existsSync(file)) {
|
|
39
|
+
_tasks = []
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
_tasks = JSON.parse(readFileSync(file, 'utf-8'))
|
|
44
|
+
} catch {
|
|
45
|
+
_tasks = []
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Public API ─────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Initialize the task system. Must be called once at startup.
|
|
53
|
+
* @param dataDir Directory to store tasks.json
|
|
54
|
+
* @param onNotify Callback when a task notification fires
|
|
55
|
+
*/
|
|
56
|
+
export function initTasks(dataDir: string, onNotify: (task: Task) => void): void {
|
|
57
|
+
_dataDir = dataDir
|
|
58
|
+
_onNotify = onNotify
|
|
59
|
+
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true })
|
|
60
|
+
load()
|
|
61
|
+
|
|
62
|
+
// Start background checker (every 30 seconds) for in-process notifications
|
|
63
|
+
if (_checkTimer) clearInterval(_checkTimer)
|
|
64
|
+
_checkTimer = setInterval(checkDueTasks, 30_000)
|
|
65
|
+
|
|
66
|
+
// Sync pending reminders with Windows Task Scheduler so they fire
|
|
67
|
+
// even if smolerclaw is not running
|
|
68
|
+
syncScheduledTasks().catch(() => {})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stop the background timer (call on exit).
|
|
73
|
+
*/
|
|
74
|
+
export function stopTasks(): void {
|
|
75
|
+
if (_checkTimer) {
|
|
76
|
+
clearInterval(_checkTimer)
|
|
77
|
+
_checkTimer = null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Add a new task with optional due time.
|
|
83
|
+
*/
|
|
84
|
+
export function addTask(title: string, dueAt?: Date): Task {
|
|
85
|
+
const task: Task = {
|
|
86
|
+
id: generateId(),
|
|
87
|
+
title: title.trim(),
|
|
88
|
+
dueAt: dueAt ? dueAt.toISOString() : null,
|
|
89
|
+
createdAt: new Date().toISOString(),
|
|
90
|
+
done: false,
|
|
91
|
+
notified: false,
|
|
92
|
+
}
|
|
93
|
+
_tasks = [..._tasks, task]
|
|
94
|
+
save()
|
|
95
|
+
|
|
96
|
+
// Schedule a Windows Task Scheduler job so the reminder fires
|
|
97
|
+
// even if smolerclaw is not running
|
|
98
|
+
if (dueAt && IS_WINDOWS) {
|
|
99
|
+
scheduleWindowsTask(task).catch(() => {})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return task
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Mark a task as done by ID or partial title match.
|
|
107
|
+
*/
|
|
108
|
+
export function completeTask(idOrTitle: string): Task | null {
|
|
109
|
+
const lower = idOrTitle.toLowerCase()
|
|
110
|
+
const task = _tasks.find(
|
|
111
|
+
(t) => t.id === idOrTitle || t.title.toLowerCase().includes(lower),
|
|
112
|
+
)
|
|
113
|
+
if (!task || task.done) return null
|
|
114
|
+
|
|
115
|
+
_tasks = _tasks.map((t) =>
|
|
116
|
+
t.id === task.id ? { ...t, done: true } : t,
|
|
117
|
+
)
|
|
118
|
+
save()
|
|
119
|
+
|
|
120
|
+
// Remove the scheduled Windows task
|
|
121
|
+
if (task.dueAt && IS_WINDOWS) {
|
|
122
|
+
removeWindowsTask(task.id).catch(() => {})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return _tasks.find((t) => t.id === task.id) || null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remove a task by ID or partial title.
|
|
130
|
+
*/
|
|
131
|
+
export function removeTask(idOrTitle: string): boolean {
|
|
132
|
+
const lower = idOrTitle.toLowerCase()
|
|
133
|
+
const idx = _tasks.findIndex(
|
|
134
|
+
(t) => t.id === idOrTitle || t.title.toLowerCase().includes(lower),
|
|
135
|
+
)
|
|
136
|
+
if (idx === -1) return false
|
|
137
|
+
|
|
138
|
+
const task = _tasks[idx]
|
|
139
|
+
_tasks = [..._tasks.slice(0, idx), ..._tasks.slice(idx + 1)]
|
|
140
|
+
save()
|
|
141
|
+
|
|
142
|
+
// Remove the scheduled Windows task
|
|
143
|
+
if (task.dueAt && IS_WINDOWS) {
|
|
144
|
+
removeWindowsTask(task.id).catch(() => {})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* List all tasks, optionally filtering by done status.
|
|
152
|
+
*/
|
|
153
|
+
export function listTasks(showDone = false): Task[] {
|
|
154
|
+
return showDone ? [..._tasks] : _tasks.filter((t) => !t.done)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Format tasks for display.
|
|
159
|
+
*/
|
|
160
|
+
export function formatTaskList(tasks: Task[]): string {
|
|
161
|
+
if (tasks.length === 0) return 'Nenhuma tarefa pendente.'
|
|
162
|
+
|
|
163
|
+
const lines = tasks.map((t) => {
|
|
164
|
+
const status = t.done ? '[x]' : '[ ]'
|
|
165
|
+
const due = t.dueAt ? ` (${formatDueTime(t.dueAt)})` : ''
|
|
166
|
+
return ` ${status} ${t.title}${due} [${t.id}]`
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
return `Tarefas (${tasks.length}):\n${lines.join('\n')}`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Parse a natural-language time reference into a Date.
|
|
174
|
+
* Supports: "18h", "18:30", "14h30", "amanha 9h", "em 30 minutos"
|
|
175
|
+
*/
|
|
176
|
+
export function parseTime(input: string): Date | null {
|
|
177
|
+
const now = new Date()
|
|
178
|
+
const text = input.toLowerCase().trim()
|
|
179
|
+
|
|
180
|
+
// "em X minutos" / "em X horas"
|
|
181
|
+
const inMatch = text.match(/em\s+(\d+)\s*(min|minutos?|h|horas?)/)
|
|
182
|
+
if (inMatch) {
|
|
183
|
+
const amount = parseInt(inMatch[1])
|
|
184
|
+
const unit = inMatch[2].startsWith('h') ? 'hours' : 'minutes'
|
|
185
|
+
const result = new Date(now)
|
|
186
|
+
if (unit === 'hours') {
|
|
187
|
+
result.setHours(result.getHours() + amount)
|
|
188
|
+
} else {
|
|
189
|
+
result.setMinutes(result.getMinutes() + amount)
|
|
190
|
+
}
|
|
191
|
+
return result
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Check for "amanha" prefix
|
|
195
|
+
let targetDate = new Date(now)
|
|
196
|
+
if (text.includes('amanha') || text.includes('amanhã')) {
|
|
197
|
+
targetDate.setDate(targetDate.getDate() + 1)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// "18h", "18h30", "18:30", "9h", "09:00"
|
|
201
|
+
const timeMatch = text.match(/(\d{1,2})\s*[h:]\s*(\d{2})?/)
|
|
202
|
+
if (timeMatch) {
|
|
203
|
+
const hours = parseInt(timeMatch[1])
|
|
204
|
+
const minutes = parseInt(timeMatch[2] || '0')
|
|
205
|
+
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
|
206
|
+
targetDate.setHours(hours, minutes, 0, 0)
|
|
207
|
+
|
|
208
|
+
// If the time is already past today (and no "amanha"), set to tomorrow
|
|
209
|
+
if (targetDate <= now && !text.includes('amanha') && !text.includes('amanhã')) {
|
|
210
|
+
targetDate.setDate(targetDate.getDate() + 1)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return targetDate
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Background Checker ─────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function checkDueTasks(): void {
|
|
223
|
+
const now = new Date()
|
|
224
|
+
let changed = false
|
|
225
|
+
|
|
226
|
+
for (const task of _tasks) {
|
|
227
|
+
if (task.done || task.notified || !task.dueAt) continue
|
|
228
|
+
|
|
229
|
+
const due = new Date(task.dueAt)
|
|
230
|
+
if (isNaN(due.getTime())) continue
|
|
231
|
+
|
|
232
|
+
// Fire if due time has passed (within the last 5 minutes)
|
|
233
|
+
const diffMs = now.getTime() - due.getTime()
|
|
234
|
+
if (diffMs >= 0 && diffMs < 5 * 60_000) {
|
|
235
|
+
// Mark as notified
|
|
236
|
+
_tasks = _tasks.map((t) =>
|
|
237
|
+
t.id === task.id ? { ...t, notified: true } : t,
|
|
238
|
+
)
|
|
239
|
+
changed = true
|
|
240
|
+
|
|
241
|
+
// Fire Windows toast notification
|
|
242
|
+
fireNotification(task)
|
|
243
|
+
|
|
244
|
+
// Call the callback for TUI display
|
|
245
|
+
_onNotify?.(task)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (changed) save()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Fire a Windows toast notification for a task.
|
|
254
|
+
* Uses PowerShell's BurntToast or built-in toast via .NET.
|
|
255
|
+
*/
|
|
256
|
+
async function fireNotification(task: Task): Promise<void> {
|
|
257
|
+
if (!IS_WINDOWS) return
|
|
258
|
+
|
|
259
|
+
// Use .NET toast notification (works without external modules)
|
|
260
|
+
const title = task.title.replace(/'/g, "''")
|
|
261
|
+
const cmd = [
|
|
262
|
+
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
|
|
263
|
+
'[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null',
|
|
264
|
+
`$template = '<toast><visual><binding template="ToastText02"><text id="1">smolerclaw - Lembrete</text><text id="2">${title}</text></binding></visual><audio src="ms-winsoundevent:Notification.Default"/></toast>'`,
|
|
265
|
+
'$xml = New-Object Windows.Data.Xml.Dom.XmlDocument',
|
|
266
|
+
'$xml.LoadXml($template)',
|
|
267
|
+
`$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)`,
|
|
268
|
+
`[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('smolerclaw').Show($toast)`,
|
|
269
|
+
].join('; ')
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const proc = Bun.spawn(
|
|
273
|
+
['powershell', '-NoProfile', '-NonInteractive', '-Command', cmd],
|
|
274
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
275
|
+
)
|
|
276
|
+
const timer = setTimeout(() => proc.kill(), 10_000)
|
|
277
|
+
await Promise.all([
|
|
278
|
+
new Response(proc.stdout).text(),
|
|
279
|
+
new Response(proc.stderr).text(),
|
|
280
|
+
])
|
|
281
|
+
await proc.exited
|
|
282
|
+
clearTimeout(timer)
|
|
283
|
+
} catch {
|
|
284
|
+
// Best effort — notification failure should not crash
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Windows Task Scheduler Integration ─────────────────────
|
|
289
|
+
|
|
290
|
+
const TASK_PREFIX = 'smolerclaw-reminder-'
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Create a Windows Scheduled Task that fires a toast notification at the due time.
|
|
294
|
+
* Uses schtasks.exe — works without admin rights for the current user.
|
|
295
|
+
*/
|
|
296
|
+
async function scheduleWindowsTask(task: Task): Promise<void> {
|
|
297
|
+
if (!task.dueAt) return
|
|
298
|
+
|
|
299
|
+
const due = new Date(task.dueAt)
|
|
300
|
+
if (isNaN(due.getTime()) || due.getTime() <= Date.now()) return
|
|
301
|
+
|
|
302
|
+
const taskName = `${TASK_PREFIX}${task.id}`
|
|
303
|
+
|
|
304
|
+
// Format date/time for schtasks: MM/DD/YYYY and HH:MM
|
|
305
|
+
const startDate = [
|
|
306
|
+
String(due.getMonth() + 1).padStart(2, '0'),
|
|
307
|
+
String(due.getDate()).padStart(2, '0'),
|
|
308
|
+
String(due.getFullYear()),
|
|
309
|
+
].join('/')
|
|
310
|
+
const startTime = [
|
|
311
|
+
String(due.getHours()).padStart(2, '0'),
|
|
312
|
+
String(due.getMinutes()).padStart(2, '0'),
|
|
313
|
+
].join(':')
|
|
314
|
+
|
|
315
|
+
// PowerShell command that shows a toast notification
|
|
316
|
+
const title = task.title.replace(/'/g, "''").replace(/"/g, '\\"')
|
|
317
|
+
const toastPs = [
|
|
318
|
+
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null;',
|
|
319
|
+
'[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null;',
|
|
320
|
+
`$x = New-Object Windows.Data.Xml.Dom.XmlDocument;`,
|
|
321
|
+
`$x.LoadXml('<toast><visual><binding template=""ToastText02""><text id=""1"">smolerclaw</text><text id=""2"">${title}</text></binding></visual><audio src=""ms-winsoundevent:Notification.Reminder""/></toast>');`,
|
|
322
|
+
`[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('smolerclaw').Show([Windows.UI.Notifications.ToastNotification]::new($x))`,
|
|
323
|
+
].join(' ')
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const proc = Bun.spawn([
|
|
327
|
+
'schtasks', '/Create',
|
|
328
|
+
'/TN', taskName,
|
|
329
|
+
'/SC', 'ONCE',
|
|
330
|
+
'/SD', startDate,
|
|
331
|
+
'/ST', startTime,
|
|
332
|
+
'/TR', `powershell -NoProfile -WindowStyle Hidden -Command "${toastPs}"`,
|
|
333
|
+
'/F', // force overwrite if exists
|
|
334
|
+
], { stdout: 'pipe', stderr: 'pipe' })
|
|
335
|
+
await proc.exited
|
|
336
|
+
} catch {
|
|
337
|
+
// Best effort — scheduler failure should not block task creation
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Remove a scheduled Windows task by task ID.
|
|
343
|
+
*/
|
|
344
|
+
async function removeWindowsTask(taskId: string): Promise<void> {
|
|
345
|
+
const taskName = `${TASK_PREFIX}${taskId}`
|
|
346
|
+
try {
|
|
347
|
+
const proc = Bun.spawn([
|
|
348
|
+
'schtasks', '/Delete', '/TN', taskName, '/F',
|
|
349
|
+
], { stdout: 'pipe', stderr: 'pipe' })
|
|
350
|
+
await proc.exited
|
|
351
|
+
} catch {
|
|
352
|
+
// Ignore — task may not exist
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Sync existing tasks with Task Scheduler on startup.
|
|
358
|
+
* Ensures pending reminders are scheduled even after a restart.
|
|
359
|
+
*/
|
|
360
|
+
async function syncScheduledTasks(): Promise<void> {
|
|
361
|
+
if (!IS_WINDOWS) return
|
|
362
|
+
|
|
363
|
+
const now = Date.now()
|
|
364
|
+
for (const task of _tasks) {
|
|
365
|
+
if (task.done || task.notified || !task.dueAt) continue
|
|
366
|
+
const due = new Date(task.dueAt)
|
|
367
|
+
if (isNaN(due.getTime()) || due.getTime() <= now) continue
|
|
368
|
+
|
|
369
|
+
// Check if the scheduled task exists
|
|
370
|
+
try {
|
|
371
|
+
const proc = Bun.spawn([
|
|
372
|
+
'schtasks', '/Query', '/TN', `${TASK_PREFIX}${task.id}`,
|
|
373
|
+
], { stdout: 'pipe', stderr: 'pipe' })
|
|
374
|
+
const code = await proc.exited
|
|
375
|
+
if (code !== 0) {
|
|
376
|
+
// Task doesn't exist in scheduler — recreate it
|
|
377
|
+
await scheduleWindowsTask(task)
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
await scheduleWindowsTask(task)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
function generateId(): string {
|
|
388
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
389
|
+
let id = ''
|
|
390
|
+
for (let i = 0; i < 6; i++) {
|
|
391
|
+
id += chars[Math.floor(Math.random() * chars.length)]
|
|
392
|
+
}
|
|
393
|
+
return id
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function formatDueTime(isoDate: string): string {
|
|
397
|
+
const date = new Date(isoDate)
|
|
398
|
+
if (isNaN(date.getTime())) return '?'
|
|
399
|
+
|
|
400
|
+
const now = new Date()
|
|
401
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
402
|
+
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
|
403
|
+
|
|
404
|
+
const time = date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
|
|
405
|
+
|
|
406
|
+
if (target.getTime() === today.getTime()) {
|
|
407
|
+
return `hoje ${time}`
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const tomorrow = new Date(today)
|
|
411
|
+
tomorrow.setDate(tomorrow.getDate() + 1)
|
|
412
|
+
if (target.getTime() === tomorrow.getTime()) {
|
|
413
|
+
return `amanha ${time}`
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const dateStr = date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })
|
|
417
|
+
return `${dateStr} ${time}`
|
|
418
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export interface TokenUsage {
|
|
2
|
+
inputTokens: number
|
|
3
|
+
outputTokens: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface CostEstimate {
|
|
7
|
+
inputCostCents: number
|
|
8
|
+
outputCostCents: number
|
|
9
|
+
totalCostCents: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Pricing per 1M tokens in USD (as of 2025)
|
|
13
|
+
const PRICING: Record<string, { input: number; output: number }> = {
|
|
14
|
+
'claude-haiku-4-5-20251001': { input: 1.00, output: 5.00 },
|
|
15
|
+
'claude-sonnet-4-20250514': { input: 3.00, output: 15.00 },
|
|
16
|
+
'claude-sonnet-4-6-20250627': { input: 3.00, output: 15.00 },
|
|
17
|
+
'claude-opus-4-20250514': { input: 15.00, output: 75.00 },
|
|
18
|
+
'claude-opus-4-6-20250318': { input: 15.00, output: 75.00 },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Fallback for unknown models (conservative estimate)
|
|
22
|
+
const DEFAULT_PRICING = { input: 3.00, output: 15.00 }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Estimate cost for a given token usage and model.
|
|
26
|
+
*/
|
|
27
|
+
export function estimateCost(usage: TokenUsage, model: string): CostEstimate {
|
|
28
|
+
const pricing = findPricing(model)
|
|
29
|
+
const inputCostCents = (usage.inputTokens / 1_000_000) * pricing.input * 100
|
|
30
|
+
const outputCostCents = (usage.outputTokens / 1_000_000) * pricing.output * 100
|
|
31
|
+
return {
|
|
32
|
+
inputCostCents,
|
|
33
|
+
outputCostCents,
|
|
34
|
+
totalCostCents: inputCostCents + outputCostCents,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findPricing(model: string): { input: number; output: number } {
|
|
39
|
+
// Exact match
|
|
40
|
+
if (PRICING[model]) return PRICING[model]
|
|
41
|
+
|
|
42
|
+
// Pattern match (e.g., "claude-haiku" matches "claude-haiku-4-5-*")
|
|
43
|
+
const lower = model.toLowerCase()
|
|
44
|
+
if (lower.includes('haiku')) return PRICING['claude-haiku-4-5-20251001']
|
|
45
|
+
if (lower.includes('opus')) return PRICING['claude-opus-4-20250514']
|
|
46
|
+
if (lower.includes('sonnet')) return PRICING['claude-sonnet-4-20250514']
|
|
47
|
+
|
|
48
|
+
return DEFAULT_PRICING
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Tracks cumulative token usage across a session.
|
|
53
|
+
*/
|
|
54
|
+
export class TokenTracker {
|
|
55
|
+
private totalInput = 0
|
|
56
|
+
private totalOutput = 0
|
|
57
|
+
private totalCostCents = 0
|
|
58
|
+
private model: string
|
|
59
|
+
|
|
60
|
+
constructor(model: string) {
|
|
61
|
+
this.model = model
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setModel(model: string): void {
|
|
65
|
+
this.model = model
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
add(usage: TokenUsage): CostEstimate {
|
|
69
|
+
this.totalInput += usage.inputTokens
|
|
70
|
+
this.totalOutput += usage.outputTokens
|
|
71
|
+
const cost = estimateCost(usage, this.model)
|
|
72
|
+
this.totalCostCents += cost.totalCostCents
|
|
73
|
+
return cost
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get totals(): { inputTokens: number; outputTokens: number; costCents: number } {
|
|
77
|
+
return {
|
|
78
|
+
inputTokens: this.totalInput,
|
|
79
|
+
outputTokens: this.totalOutput,
|
|
80
|
+
costCents: this.totalCostCents,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Format a single response's usage for display.
|
|
86
|
+
*/
|
|
87
|
+
formatUsage(usage: TokenUsage): string {
|
|
88
|
+
const cost = estimateCost(usage, this.model)
|
|
89
|
+
return `${fmt(usage.inputTokens)} in / ${fmt(usage.outputTokens)} out (~$${(cost.totalCostCents / 100).toFixed(4)})`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Format cumulative session usage.
|
|
94
|
+
*/
|
|
95
|
+
formatSession(): string {
|
|
96
|
+
return `${fmt(this.totalInput)} in / ${fmt(this.totalOutput)} out | session: ~$${(this.totalCostCents / 100).toFixed(4)}`
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fmt(n: number): string {
|
|
101
|
+
return n.toLocaleString('en-US')
|
|
102
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool safety layer.
|
|
3
|
+
* Classifies tool calls by risk level and detects dangerous patterns.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type RiskLevel = 'safe' | 'moderate' | 'dangerous'
|
|
7
|
+
|
|
8
|
+
interface ToolRisk {
|
|
9
|
+
level: RiskLevel
|
|
10
|
+
reason?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Dangerous command patterns (case-insensitive)
|
|
14
|
+
const DANGEROUS_COMMANDS = [
|
|
15
|
+
/\brm\s+(-rf?|--recursive)/i,
|
|
16
|
+
/\brmdir\s/i,
|
|
17
|
+
/\bdel\s+\/[sS]/i, // Windows: del /S
|
|
18
|
+
/\bRemove-Item\s.*-Recurse/i, // PowerShell
|
|
19
|
+
/\bformat\s+[a-z]:/i, // Windows: format C:
|
|
20
|
+
/\bgit\s+(push\s+--force|reset\s+--hard|clean\s+-fd)/i,
|
|
21
|
+
/\bdrop\s+(table|database)/i,
|
|
22
|
+
/\btruncate\s+table/i,
|
|
23
|
+
/\bchmod\s+777/i,
|
|
24
|
+
/\bchown\s+-R/i,
|
|
25
|
+
/\bcurl\s.*\|\s*(bash|sh)/i, // Pipe to shell
|
|
26
|
+
/\bwget\s.*\|\s*(bash|sh)/i,
|
|
27
|
+
/\bnpm\s+publish/i,
|
|
28
|
+
/\bsudo\s/i,
|
|
29
|
+
/\bkill\s+-9/i,
|
|
30
|
+
/\bshutdown/i,
|
|
31
|
+
/\breboot/i,
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
// Patterns that indicate elevated risk but are common
|
|
35
|
+
const MODERATE_COMMANDS = [
|
|
36
|
+
/\bgit\s+push/i,
|
|
37
|
+
/\bgit\s+commit/i,
|
|
38
|
+
/\bnpm\s+install/i,
|
|
39
|
+
/\bbun\s+(install|add)/i,
|
|
40
|
+
/\bpip\s+install/i,
|
|
41
|
+
/\bcargo\s+install/i,
|
|
42
|
+
/\bmkdir\s+-p/i,
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Assess risk level of a tool call.
|
|
47
|
+
*/
|
|
48
|
+
export function assessToolRisk(name: string, input: Record<string, unknown>): ToolRisk {
|
|
49
|
+
switch (name) {
|
|
50
|
+
case 'read_file':
|
|
51
|
+
case 'list_directory':
|
|
52
|
+
case 'find_files':
|
|
53
|
+
case 'search_files':
|
|
54
|
+
case 'fetch_url':
|
|
55
|
+
return { level: 'safe' }
|
|
56
|
+
|
|
57
|
+
case 'write_file':
|
|
58
|
+
return { level: 'moderate', reason: `write ${input.path}` }
|
|
59
|
+
|
|
60
|
+
case 'edit_file':
|
|
61
|
+
return { level: 'moderate', reason: `edit ${input.path}` }
|
|
62
|
+
|
|
63
|
+
case 'run_command': {
|
|
64
|
+
const cmd = String(input.command || '')
|
|
65
|
+
|
|
66
|
+
// Check dangerous patterns first
|
|
67
|
+
for (const pattern of DANGEROUS_COMMANDS) {
|
|
68
|
+
if (pattern.test(cmd)) {
|
|
69
|
+
return { level: 'dangerous', reason: cmd }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check moderate patterns
|
|
74
|
+
for (const pattern of MODERATE_COMMANDS) {
|
|
75
|
+
if (pattern.test(cmd)) {
|
|
76
|
+
return { level: 'moderate', reason: cmd }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { level: 'moderate', reason: cmd }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
default:
|
|
84
|
+
return { level: 'moderate', reason: `unknown tool: ${name}` }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format a risk assessment for display.
|
|
90
|
+
*/
|
|
91
|
+
export function formatRisk(risk: ToolRisk): string {
|
|
92
|
+
switch (risk.level) {
|
|
93
|
+
case 'safe':
|
|
94
|
+
return ''
|
|
95
|
+
case 'moderate':
|
|
96
|
+
return risk.reason || 'modification'
|
|
97
|
+
case 'dangerous':
|
|
98
|
+
return `DANGEROUS: ${risk.reason}`
|
|
99
|
+
}
|
|
100
|
+
}
|