@usejarvis/brain 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/LICENSE +153 -0
- package/README.md +278 -0
- package/bin/jarvis.ts +413 -0
- package/package.json +74 -0
- package/scripts/ensure-bun.cjs +8 -0
- package/src/actions/README.md +421 -0
- package/src/actions/app-control/desktop-controller.test.ts +26 -0
- package/src/actions/app-control/desktop-controller.ts +438 -0
- package/src/actions/app-control/interface.ts +64 -0
- package/src/actions/app-control/linux.ts +273 -0
- package/src/actions/app-control/macos.ts +54 -0
- package/src/actions/app-control/sidecar-launcher.test.ts +23 -0
- package/src/actions/app-control/sidecar-launcher.ts +286 -0
- package/src/actions/app-control/windows.ts +44 -0
- package/src/actions/browser/cdp.ts +138 -0
- package/src/actions/browser/chrome-launcher.ts +252 -0
- package/src/actions/browser/session.ts +437 -0
- package/src/actions/browser/stealth.ts +49 -0
- package/src/actions/index.ts +20 -0
- package/src/actions/terminal/executor.ts +157 -0
- package/src/actions/terminal/wsl-bridge.ts +126 -0
- package/src/actions/test.ts +93 -0
- package/src/actions/tools/agents.ts +321 -0
- package/src/actions/tools/builtin.ts +846 -0
- package/src/actions/tools/commitments.ts +192 -0
- package/src/actions/tools/content.ts +217 -0
- package/src/actions/tools/delegate.ts +147 -0
- package/src/actions/tools/desktop.test.ts +55 -0
- package/src/actions/tools/desktop.ts +305 -0
- package/src/actions/tools/goals.ts +376 -0
- package/src/actions/tools/local-tools-guard.ts +20 -0
- package/src/actions/tools/registry.ts +171 -0
- package/src/actions/tools/research.ts +111 -0
- package/src/actions/tools/sidecar-list.ts +57 -0
- package/src/actions/tools/sidecar-route.ts +105 -0
- package/src/actions/tools/workflows.ts +216 -0
- package/src/agents/agent.ts +132 -0
- package/src/agents/delegation.ts +107 -0
- package/src/agents/hierarchy.ts +113 -0
- package/src/agents/index.ts +19 -0
- package/src/agents/messaging.ts +125 -0
- package/src/agents/orchestrator.ts +576 -0
- package/src/agents/role-discovery.ts +61 -0
- package/src/agents/sub-agent-runner.ts +307 -0
- package/src/agents/task-manager.ts +151 -0
- package/src/authority/approval-delivery.ts +59 -0
- package/src/authority/approval.ts +196 -0
- package/src/authority/audit.ts +158 -0
- package/src/authority/authority.test.ts +519 -0
- package/src/authority/deferred-executor.ts +103 -0
- package/src/authority/emergency.ts +66 -0
- package/src/authority/engine.ts +297 -0
- package/src/authority/index.ts +12 -0
- package/src/authority/learning.ts +111 -0
- package/src/authority/tool-action-map.ts +74 -0
- package/src/awareness/analytics.ts +466 -0
- package/src/awareness/awareness.test.ts +332 -0
- package/src/awareness/capture-engine.ts +305 -0
- package/src/awareness/context-graph.ts +130 -0
- package/src/awareness/context-tracker.ts +349 -0
- package/src/awareness/index.ts +25 -0
- package/src/awareness/intelligence.ts +321 -0
- package/src/awareness/ocr-engine.ts +88 -0
- package/src/awareness/service.ts +528 -0
- package/src/awareness/struggle-detector.ts +342 -0
- package/src/awareness/suggestion-engine.ts +476 -0
- package/src/awareness/types.ts +201 -0
- package/src/cli/autostart.ts +241 -0
- package/src/cli/deps.ts +449 -0
- package/src/cli/doctor.ts +230 -0
- package/src/cli/helpers.ts +401 -0
- package/src/cli/onboard.ts +580 -0
- package/src/comms/README.md +329 -0
- package/src/comms/auth-error.html +48 -0
- package/src/comms/channels/discord.ts +228 -0
- package/src/comms/channels/signal.ts +56 -0
- package/src/comms/channels/telegram.ts +316 -0
- package/src/comms/channels/whatsapp.ts +60 -0
- package/src/comms/channels.test.ts +173 -0
- package/src/comms/desktop-notify.ts +114 -0
- package/src/comms/example.ts +129 -0
- package/src/comms/index.ts +129 -0
- package/src/comms/streaming.ts +142 -0
- package/src/comms/voice.test.ts +152 -0
- package/src/comms/voice.ts +291 -0
- package/src/comms/websocket.test.ts +409 -0
- package/src/comms/websocket.ts +473 -0
- package/src/config/README.md +387 -0
- package/src/config/index.ts +6 -0
- package/src/config/loader.test.ts +137 -0
- package/src/config/loader.ts +142 -0
- package/src/config/types.ts +260 -0
- package/src/daemon/README.md +232 -0
- package/src/daemon/agent-service-interface.ts +9 -0
- package/src/daemon/agent-service.ts +600 -0
- package/src/daemon/api-routes.ts +2119 -0
- package/src/daemon/background-agent-service.ts +396 -0
- package/src/daemon/background-agent.test.ts +78 -0
- package/src/daemon/channel-service.ts +201 -0
- package/src/daemon/commitment-executor.ts +297 -0
- package/src/daemon/event-classifier.ts +239 -0
- package/src/daemon/event-coalescer.ts +123 -0
- package/src/daemon/event-reactor.ts +214 -0
- package/src/daemon/health.ts +220 -0
- package/src/daemon/index.ts +1004 -0
- package/src/daemon/llm-settings.ts +316 -0
- package/src/daemon/observer-service.ts +150 -0
- package/src/daemon/pid.ts +98 -0
- package/src/daemon/research-queue.ts +155 -0
- package/src/daemon/services.ts +175 -0
- package/src/daemon/ws-service.ts +788 -0
- package/src/goals/accountability.ts +240 -0
- package/src/goals/awareness-bridge.ts +185 -0
- package/src/goals/estimator.ts +185 -0
- package/src/goals/events.ts +28 -0
- package/src/goals/goals.test.ts +400 -0
- package/src/goals/integration.test.ts +329 -0
- package/src/goals/nl-builder.test.ts +220 -0
- package/src/goals/nl-builder.ts +256 -0
- package/src/goals/rhythm.test.ts +177 -0
- package/src/goals/rhythm.ts +275 -0
- package/src/goals/service.test.ts +135 -0
- package/src/goals/service.ts +348 -0
- package/src/goals/types.ts +106 -0
- package/src/goals/workflow-bridge.ts +96 -0
- package/src/integrations/google-api.ts +134 -0
- package/src/integrations/google-auth.ts +175 -0
- package/src/llm/README.md +291 -0
- package/src/llm/anthropic.ts +386 -0
- package/src/llm/gemini.ts +371 -0
- package/src/llm/index.ts +19 -0
- package/src/llm/manager.ts +153 -0
- package/src/llm/ollama.ts +307 -0
- package/src/llm/openai.ts +350 -0
- package/src/llm/provider.test.ts +231 -0
- package/src/llm/provider.ts +60 -0
- package/src/llm/test.ts +87 -0
- package/src/observers/README.md +278 -0
- package/src/observers/calendar.ts +113 -0
- package/src/observers/clipboard.ts +136 -0
- package/src/observers/email.ts +109 -0
- package/src/observers/example.ts +58 -0
- package/src/observers/file-watcher.ts +124 -0
- package/src/observers/index.ts +159 -0
- package/src/observers/notifications.ts +197 -0
- package/src/observers/observers.test.ts +203 -0
- package/src/observers/processes.ts +225 -0
- package/src/personality/README.md +61 -0
- package/src/personality/adapter.ts +196 -0
- package/src/personality/index.ts +20 -0
- package/src/personality/learner.ts +209 -0
- package/src/personality/model.ts +132 -0
- package/src/personality/personality.test.ts +236 -0
- package/src/roles/README.md +252 -0
- package/src/roles/authority.ts +119 -0
- package/src/roles/example-usage.ts +198 -0
- package/src/roles/index.ts +42 -0
- package/src/roles/loader.ts +143 -0
- package/src/roles/prompt-builder.ts +194 -0
- package/src/roles/test-multi.ts +102 -0
- package/src/roles/test-role.yaml +77 -0
- package/src/roles/test-utils.ts +93 -0
- package/src/roles/test.ts +106 -0
- package/src/roles/tool-guide.ts +190 -0
- package/src/roles/types.ts +36 -0
- package/src/roles/utils.ts +200 -0
- package/src/scripts/google-setup.ts +168 -0
- package/src/sidecar/connection.ts +179 -0
- package/src/sidecar/index.ts +6 -0
- package/src/sidecar/manager.ts +542 -0
- package/src/sidecar/protocol.ts +85 -0
- package/src/sidecar/rpc.ts +161 -0
- package/src/sidecar/scheduler.ts +136 -0
- package/src/sidecar/types.ts +112 -0
- package/src/sidecar/validator.ts +144 -0
- package/src/vault/README.md +110 -0
- package/src/vault/awareness.ts +341 -0
- package/src/vault/commitments.ts +299 -0
- package/src/vault/content-pipeline.ts +260 -0
- package/src/vault/conversations.ts +173 -0
- package/src/vault/entities.ts +180 -0
- package/src/vault/extractor.test.ts +356 -0
- package/src/vault/extractor.ts +345 -0
- package/src/vault/facts.ts +190 -0
- package/src/vault/goals.ts +477 -0
- package/src/vault/index.ts +87 -0
- package/src/vault/keychain.ts +99 -0
- package/src/vault/observations.ts +115 -0
- package/src/vault/relationships.ts +178 -0
- package/src/vault/retrieval.test.ts +126 -0
- package/src/vault/retrieval.ts +227 -0
- package/src/vault/schema.ts +658 -0
- package/src/vault/settings.ts +38 -0
- package/src/vault/vectors.ts +92 -0
- package/src/vault/workflows.ts +403 -0
- package/src/workflows/auto-suggest.ts +290 -0
- package/src/workflows/engine.ts +366 -0
- package/src/workflows/events.ts +24 -0
- package/src/workflows/executor.ts +207 -0
- package/src/workflows/nl-builder.ts +198 -0
- package/src/workflows/nodes/actions/agent-task.ts +73 -0
- package/src/workflows/nodes/actions/calendar-action.ts +85 -0
- package/src/workflows/nodes/actions/code-execution.ts +73 -0
- package/src/workflows/nodes/actions/discord.ts +77 -0
- package/src/workflows/nodes/actions/file-write.ts +73 -0
- package/src/workflows/nodes/actions/gmail.ts +69 -0
- package/src/workflows/nodes/actions/http-request.ts +117 -0
- package/src/workflows/nodes/actions/notification.ts +85 -0
- package/src/workflows/nodes/actions/run-tool.ts +55 -0
- package/src/workflows/nodes/actions/send-message.ts +82 -0
- package/src/workflows/nodes/actions/shell-command.ts +76 -0
- package/src/workflows/nodes/actions/telegram.ts +60 -0
- package/src/workflows/nodes/builtin.ts +119 -0
- package/src/workflows/nodes/error/error-handler.ts +37 -0
- package/src/workflows/nodes/error/fallback.ts +47 -0
- package/src/workflows/nodes/error/retry.ts +82 -0
- package/src/workflows/nodes/logic/delay.ts +42 -0
- package/src/workflows/nodes/logic/if-else.ts +41 -0
- package/src/workflows/nodes/logic/loop.ts +90 -0
- package/src/workflows/nodes/logic/merge.ts +38 -0
- package/src/workflows/nodes/logic/race.ts +40 -0
- package/src/workflows/nodes/logic/switch.ts +59 -0
- package/src/workflows/nodes/logic/template-render.ts +53 -0
- package/src/workflows/nodes/logic/variable-get.ts +37 -0
- package/src/workflows/nodes/logic/variable-set.ts +59 -0
- package/src/workflows/nodes/registry.ts +99 -0
- package/src/workflows/nodes/transform/aggregate.ts +99 -0
- package/src/workflows/nodes/transform/csv-parse.ts +70 -0
- package/src/workflows/nodes/transform/json-parse.ts +63 -0
- package/src/workflows/nodes/transform/map-filter.ts +84 -0
- package/src/workflows/nodes/transform/regex-match.ts +89 -0
- package/src/workflows/nodes/triggers/calendar.ts +33 -0
- package/src/workflows/nodes/triggers/clipboard.ts +32 -0
- package/src/workflows/nodes/triggers/cron.ts +40 -0
- package/src/workflows/nodes/triggers/email.ts +40 -0
- package/src/workflows/nodes/triggers/file-change.ts +45 -0
- package/src/workflows/nodes/triggers/git.ts +46 -0
- package/src/workflows/nodes/triggers/manual.ts +23 -0
- package/src/workflows/nodes/triggers/poll.ts +81 -0
- package/src/workflows/nodes/triggers/process.ts +44 -0
- package/src/workflows/nodes/triggers/screen-event.ts +37 -0
- package/src/workflows/nodes/triggers/webhook.ts +39 -0
- package/src/workflows/safe-eval.ts +139 -0
- package/src/workflows/template.ts +118 -0
- package/src/workflows/triggers/cron.ts +311 -0
- package/src/workflows/triggers/manager.ts +285 -0
- package/src/workflows/triggers/observer-bridge.ts +172 -0
- package/src/workflows/triggers/poller.ts +201 -0
- package/src/workflows/triggers/screen-condition.ts +218 -0
- package/src/workflows/triggers/triggers.test.ts +740 -0
- package/src/workflows/triggers/webhook.ts +191 -0
- package/src/workflows/types.ts +133 -0
- package/src/workflows/variables.ts +72 -0
- package/src/workflows/workflows.test.ts +383 -0
- package/src/workflows/yaml.ts +104 -0
- package/ui/dist/index-j75njzc1.css +1199 -0
- package/ui/dist/index-p2zh407q.js +80603 -0
- package/ui/dist/index.html +13 -0
- package/ui/public/openwakeword/models/embedding_model.onnx +0 -0
- package/ui/public/openwakeword/models/hey_jarvis_v0.1.onnx +0 -0
- package/ui/public/openwakeword/models/melspectrogram.onnx +0 -0
- package/ui/public/openwakeword/models/silero_vad.onnx +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.mjs +106 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.mjs +59 -0
- package/ui/public/ort/ort-wasm-simd-threaded.wasm +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach } from 'bun:test';
|
|
2
|
+
import { initDatabase } from '../vault/schema.ts';
|
|
3
|
+
import { ContextTracker } from './context-tracker.ts';
|
|
4
|
+
import { SuggestionEngine } from './suggestion-engine.ts';
|
|
5
|
+
import { ContextGraph } from './context-graph.ts';
|
|
6
|
+
import type { AwarenessConfig } from '../config/types.ts';
|
|
7
|
+
import type { AwarenessEvent, ScreenContext } from './types.ts';
|
|
8
|
+
import {
|
|
9
|
+
createCapture,
|
|
10
|
+
getCapture,
|
|
11
|
+
getRecentCaptures,
|
|
12
|
+
getCapturesInRange,
|
|
13
|
+
getAppUsageStats,
|
|
14
|
+
createSession,
|
|
15
|
+
getSession,
|
|
16
|
+
updateSession,
|
|
17
|
+
endSession,
|
|
18
|
+
getRecentSessions,
|
|
19
|
+
incrementSessionCaptureCount,
|
|
20
|
+
createSuggestion,
|
|
21
|
+
getRecentSuggestions,
|
|
22
|
+
markSuggestionDismissed,
|
|
23
|
+
markSuggestionActedOn,
|
|
24
|
+
getSuggestionStats,
|
|
25
|
+
getSuggestionCountSince,
|
|
26
|
+
} from '../vault/awareness.ts';
|
|
27
|
+
|
|
28
|
+
const testConfig: AwarenessConfig = {
|
|
29
|
+
enabled: true,
|
|
30
|
+
capture_interval_ms: 5000,
|
|
31
|
+
min_change_threshold: 0.02,
|
|
32
|
+
cloud_vision_enabled: false,
|
|
33
|
+
cloud_vision_cooldown_ms: 30000,
|
|
34
|
+
stuck_threshold_ms: 5000, // 5s for tests
|
|
35
|
+
suggestion_rate_limit_ms: 100, // fast for tests
|
|
36
|
+
retention: { full_hours: 1, key_moment_hours: 24 },
|
|
37
|
+
capture_dir: '/tmp/jarvis-test-captures',
|
|
38
|
+
struggle_grace_ms: 120000,
|
|
39
|
+
struggle_cooldown_ms: 180000,
|
|
40
|
+
overlay_autolaunch: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('Vault — Screen Captures', () => {
|
|
44
|
+
beforeEach(() => initDatabase(':memory:'));
|
|
45
|
+
|
|
46
|
+
test('createCapture + getCapture', () => {
|
|
47
|
+
const row = createCapture({
|
|
48
|
+
timestamp: Date.now(),
|
|
49
|
+
pixelChangePct: 0.15,
|
|
50
|
+
appName: 'VS Code',
|
|
51
|
+
windowTitle: 'index.ts - jarvis - Visual Studio Code',
|
|
52
|
+
ocrText: 'function hello() { return "world"; }',
|
|
53
|
+
});
|
|
54
|
+
expect(row.id).toBeTruthy();
|
|
55
|
+
expect(row.app_name).toBe('VS Code');
|
|
56
|
+
expect(row.pixel_change_pct).toBe(0.15);
|
|
57
|
+
|
|
58
|
+
const fetched = getCapture(row.id);
|
|
59
|
+
expect(fetched).not.toBeNull();
|
|
60
|
+
expect(fetched!.ocr_text).toContain('hello');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('getRecentCaptures with app filter', () => {
|
|
64
|
+
createCapture({ timestamp: Date.now() - 2000, pixelChangePct: 0.1, appName: 'Chrome' });
|
|
65
|
+
createCapture({ timestamp: Date.now() - 1000, pixelChangePct: 0.2, appName: 'VS Code' });
|
|
66
|
+
createCapture({ timestamp: Date.now(), pixelChangePct: 0.3, appName: 'Chrome' });
|
|
67
|
+
|
|
68
|
+
const all = getRecentCaptures(10);
|
|
69
|
+
expect(all.length).toBe(3);
|
|
70
|
+
|
|
71
|
+
const chromeOnly = getRecentCaptures(10, 'Chrome');
|
|
72
|
+
expect(chromeOnly.length).toBe(2);
|
|
73
|
+
expect(chromeOnly.every(c => c.app_name === 'Chrome')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('getCapturesInRange', () => {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
createCapture({ timestamp: now - 60000, pixelChangePct: 0.1, appName: 'A' });
|
|
79
|
+
createCapture({ timestamp: now - 30000, pixelChangePct: 0.2, appName: 'B' });
|
|
80
|
+
createCapture({ timestamp: now, pixelChangePct: 0.3, appName: 'C' });
|
|
81
|
+
|
|
82
|
+
const range = getCapturesInRange(now - 40000, now - 10000);
|
|
83
|
+
expect(range.length).toBe(1);
|
|
84
|
+
expect(range[0]!.app_name).toBe('B');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('getAppUsageStats', () => {
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
for (let i = 0; i < 5; i++) {
|
|
90
|
+
createCapture({ timestamp: now - i * 1000, pixelChangePct: 0.1, appName: 'Chrome' });
|
|
91
|
+
}
|
|
92
|
+
for (let i = 0; i < 3; i++) {
|
|
93
|
+
createCapture({ timestamp: now - i * 1000, pixelChangePct: 0.1, appName: 'VS Code' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stats = getAppUsageStats(now - 10000, now + 1000);
|
|
97
|
+
expect(stats.length).toBe(2);
|
|
98
|
+
expect(stats[0]!.app).toBe('Chrome');
|
|
99
|
+
expect(stats[0]!.captureCount).toBe(5);
|
|
100
|
+
expect(stats[1]!.app).toBe('VS Code');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('Vault — Awareness Sessions', () => {
|
|
105
|
+
beforeEach(() => initDatabase(':memory:'));
|
|
106
|
+
|
|
107
|
+
test('create + get + update + end session', () => {
|
|
108
|
+
const session = createSession({ startedAt: Date.now(), apps: ['Chrome', 'VS Code'] });
|
|
109
|
+
expect(session.id).toBeTruthy();
|
|
110
|
+
expect(session.ended_at).toBeNull();
|
|
111
|
+
|
|
112
|
+
const fetched = getSession(session.id);
|
|
113
|
+
expect(fetched).not.toBeNull();
|
|
114
|
+
expect(JSON.parse(fetched!.apps)).toEqual(['Chrome', 'VS Code']);
|
|
115
|
+
|
|
116
|
+
updateSession(session.id, { topic: 'Coding session', capture_count: 10 });
|
|
117
|
+
const updated = getSession(session.id);
|
|
118
|
+
expect(updated!.topic).toBe('Coding session');
|
|
119
|
+
expect(updated!.capture_count).toBe(10);
|
|
120
|
+
|
|
121
|
+
endSession(session.id, 'Productive coding');
|
|
122
|
+
const ended = getSession(session.id);
|
|
123
|
+
expect(ended!.ended_at).not.toBeNull();
|
|
124
|
+
expect(ended!.summary).toBe('Productive coding');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('incrementSessionCaptureCount', () => {
|
|
128
|
+
const session = createSession({ startedAt: Date.now() });
|
|
129
|
+
incrementSessionCaptureCount(session.id);
|
|
130
|
+
incrementSessionCaptureCount(session.id);
|
|
131
|
+
incrementSessionCaptureCount(session.id);
|
|
132
|
+
|
|
133
|
+
const updated = getSession(session.id);
|
|
134
|
+
expect(updated!.capture_count).toBe(3);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('getRecentSessions', () => {
|
|
138
|
+
createSession({ startedAt: Date.now() - 3000 });
|
|
139
|
+
createSession({ startedAt: Date.now() - 2000 });
|
|
140
|
+
createSession({ startedAt: Date.now() - 1000 });
|
|
141
|
+
|
|
142
|
+
const sessions = getRecentSessions(2);
|
|
143
|
+
expect(sessions.length).toBe(2);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('Vault — Awareness Suggestions', () => {
|
|
148
|
+
beforeEach(() => initDatabase(':memory:'));
|
|
149
|
+
|
|
150
|
+
test('create + dismiss + act on suggestions', () => {
|
|
151
|
+
const s = createSuggestion({
|
|
152
|
+
type: 'error',
|
|
153
|
+
title: 'Error in VS Code',
|
|
154
|
+
body: 'TypeScript compilation error detected',
|
|
155
|
+
context: { appName: 'VS Code' },
|
|
156
|
+
});
|
|
157
|
+
expect(s.id).toBeTruthy();
|
|
158
|
+
expect(s.dismissed).toBe(0);
|
|
159
|
+
expect(s.acted_on).toBe(0);
|
|
160
|
+
|
|
161
|
+
markSuggestionDismissed(s.id);
|
|
162
|
+
const recent = getRecentSuggestions(1);
|
|
163
|
+
expect(recent[0]!.dismissed).toBe(1);
|
|
164
|
+
|
|
165
|
+
const s2 = createSuggestion({ type: 'stuck', title: 'Stuck', body: 'You seem stuck' });
|
|
166
|
+
markSuggestionActedOn(s2.id);
|
|
167
|
+
const all = getRecentSuggestions(10);
|
|
168
|
+
expect(all.find(x => x.id === s2.id)!.acted_on).toBe(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('getSuggestionStats', () => {
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
createSuggestion({ type: 'error', title: 'E1', body: 'b' });
|
|
174
|
+
const s2 = createSuggestion({ type: 'stuck', title: 'E2', body: 'b' });
|
|
175
|
+
markSuggestionActedOn(s2.id);
|
|
176
|
+
|
|
177
|
+
const stats = getSuggestionStats(now - 10000, now + 10000);
|
|
178
|
+
expect(stats.total).toBe(2);
|
|
179
|
+
expect(stats.actedOn).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('getSuggestionCountSince', () => {
|
|
183
|
+
const before = Date.now() - 1000;
|
|
184
|
+
createSuggestion({ type: 'general', title: 'T', body: 'B' });
|
|
185
|
+
createSuggestion({ type: 'general', title: 'T2', body: 'B2' });
|
|
186
|
+
|
|
187
|
+
expect(getSuggestionCountSince(before)).toBe(2);
|
|
188
|
+
expect(getSuggestionCountSince(Date.now() + 10000)).toBe(0);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('ContextTracker', () => {
|
|
193
|
+
beforeEach(() => initDatabase(':memory:'));
|
|
194
|
+
|
|
195
|
+
test('detects context changes', () => {
|
|
196
|
+
const tracker = new ContextTracker(testConfig);
|
|
197
|
+
|
|
198
|
+
// First capture
|
|
199
|
+
const r1 = tracker.processCapture('cap-1', 'some text', 'index.ts - VS Code');
|
|
200
|
+
expect(r1.context.appName).toBe('VS Code');
|
|
201
|
+
expect(r1.events.some(e => e.type === 'session_started')).toBe(true);
|
|
202
|
+
|
|
203
|
+
// Same app — no context change
|
|
204
|
+
const r2 = tracker.processCapture('cap-2', 'more text', 'utils.ts - VS Code');
|
|
205
|
+
expect(r2.context.appName).toBe('VS Code');
|
|
206
|
+
|
|
207
|
+
// Different app — context change
|
|
208
|
+
const r3 = tracker.processCapture('cap-3', 'google.com', 'Google - Chrome');
|
|
209
|
+
expect(r3.context.appName).toBe('Chrome');
|
|
210
|
+
expect(r3.events.some(e => e.type === 'context_changed')).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('detects errors in OCR text', () => {
|
|
214
|
+
const tracker = new ContextTracker(testConfig);
|
|
215
|
+
|
|
216
|
+
const r = tracker.processCapture('cap-1', 'Compilation error: module not found. Build failed at line 42', 'app.js - VS Code');
|
|
217
|
+
expect(r.events.some(e => e.type === 'error_detected')).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('detects stuck state', () => {
|
|
221
|
+
const tracker = new ContextTracker(testConfig);
|
|
222
|
+
|
|
223
|
+
// First capture
|
|
224
|
+
tracker.processCapture('cap-1', 'same text here', 'Page - Browser');
|
|
225
|
+
|
|
226
|
+
// Simulate time passing (>5s with same text)
|
|
227
|
+
const originalNow = Date.now;
|
|
228
|
+
Date.now = () => originalNow() + 6000;
|
|
229
|
+
|
|
230
|
+
const r2 = tracker.processCapture('cap-2', 'same text here', 'Page - Browser');
|
|
231
|
+
expect(r2.events.some(e => e.type === 'stuck_detected')).toBe(true);
|
|
232
|
+
|
|
233
|
+
Date.now = originalNow;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('extracts app name from window title', () => {
|
|
237
|
+
const tracker = new ContextTracker(testConfig);
|
|
238
|
+
|
|
239
|
+
const r1 = tracker.processCapture('1', '', 'index.ts - Visual Studio Code');
|
|
240
|
+
expect(r1.context.appName).toBe('Visual Studio Code');
|
|
241
|
+
|
|
242
|
+
const r2 = tracker.processCapture('2', '', 'Google - Mozilla Firefox');
|
|
243
|
+
expect(r2.context.appName).toBe('Mozilla Firefox');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('SuggestionEngine', () => {
|
|
248
|
+
beforeEach(() => initDatabase(':memory:'));
|
|
249
|
+
|
|
250
|
+
test('generates error suggestion', async () => {
|
|
251
|
+
const engine = new SuggestionEngine(0); // no rate limit for test
|
|
252
|
+
|
|
253
|
+
const context: ScreenContext = {
|
|
254
|
+
captureId: 'cap-1',
|
|
255
|
+
timestamp: Date.now(),
|
|
256
|
+
appName: 'VS Code',
|
|
257
|
+
windowTitle: 'test.ts - VS Code',
|
|
258
|
+
url: null,
|
|
259
|
+
filePath: null,
|
|
260
|
+
ocrText: 'TypeError: undefined',
|
|
261
|
+
sessionId: 'sess-1',
|
|
262
|
+
isSignificantChange: false,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const events: AwarenessEvent[] = [{
|
|
266
|
+
type: 'error_detected',
|
|
267
|
+
data: { errorText: 'TypeError', errorContext: 'undefined is not a function', appName: 'VS Code' },
|
|
268
|
+
timestamp: Date.now(),
|
|
269
|
+
}];
|
|
270
|
+
|
|
271
|
+
const suggestion = await engine.evaluate(context, events);
|
|
272
|
+
expect(suggestion).not.toBeNull();
|
|
273
|
+
expect(suggestion!.type).toBe('error');
|
|
274
|
+
expect(suggestion!.title).toContain('VS Code');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('deduplicates suggestions', async () => {
|
|
278
|
+
const engine = new SuggestionEngine(0);
|
|
279
|
+
|
|
280
|
+
const context: ScreenContext = {
|
|
281
|
+
captureId: 'cap-1',
|
|
282
|
+
timestamp: Date.now(),
|
|
283
|
+
appName: 'VS Code',
|
|
284
|
+
windowTitle: 'test.ts - VS Code',
|
|
285
|
+
url: null,
|
|
286
|
+
filePath: null,
|
|
287
|
+
ocrText: 'error',
|
|
288
|
+
sessionId: 'sess-1',
|
|
289
|
+
isSignificantChange: false,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const events: AwarenessEvent[] = [{
|
|
293
|
+
type: 'error_detected',
|
|
294
|
+
data: { errorText: 'error', errorContext: 'some error', appName: 'VS Code' },
|
|
295
|
+
timestamp: Date.now(),
|
|
296
|
+
}];
|
|
297
|
+
|
|
298
|
+
const s1 = await engine.evaluate(context, events);
|
|
299
|
+
expect(s1).not.toBeNull();
|
|
300
|
+
|
|
301
|
+
// Same suggestion should be deduped
|
|
302
|
+
const s2 = await engine.evaluate(context, events);
|
|
303
|
+
expect(s2).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('ContextGraph', () => {
|
|
308
|
+
beforeEach(() => initDatabase(':memory:'));
|
|
309
|
+
|
|
310
|
+
test('creates app entity for new apps', () => {
|
|
311
|
+
const graph = new ContextGraph();
|
|
312
|
+
const { searchEntitiesByName } = require('../vault/entities.ts');
|
|
313
|
+
|
|
314
|
+
const context: ScreenContext = {
|
|
315
|
+
captureId: 'cap-1',
|
|
316
|
+
timestamp: Date.now(),
|
|
317
|
+
appName: 'Visual Studio Code',
|
|
318
|
+
windowTitle: 'test.ts - Visual Studio Code',
|
|
319
|
+
url: null,
|
|
320
|
+
filePath: null,
|
|
321
|
+
ocrText: 'some code here',
|
|
322
|
+
sessionId: 'sess-1',
|
|
323
|
+
isSignificantChange: false,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
graph.linkCaptureToEntities(context);
|
|
327
|
+
|
|
328
|
+
const entities = searchEntitiesByName('Visual Studio Code');
|
|
329
|
+
expect(entities.length).toBeGreaterThan(0);
|
|
330
|
+
expect(entities[0].type).toBe('tool');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture Engine — Continuous Desktop Screenshot Capture
|
|
3
|
+
*
|
|
4
|
+
* Implements the Observer interface. Captures the full desktop at configurable
|
|
5
|
+
* intervals, computes pixel-diff to skip unchanged frames, manages disk storage
|
|
6
|
+
* with tiered retention, and adapts capture frequency based on system load.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, existsSync, unlinkSync, readdirSync, statSync, rmdirSync } from 'node:fs';
|
|
10
|
+
import { writeFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import type { Observer, ObserverEventHandler } from '../observers/index.ts';
|
|
14
|
+
import type { AwarenessConfig } from '../config/types.ts';
|
|
15
|
+
import type { DesktopController } from '../actions/app-control/desktop-controller.ts';
|
|
16
|
+
import { generateId } from '../vault/schema.ts';
|
|
17
|
+
import { deleteCapturesBefore } from '../vault/awareness.ts';
|
|
18
|
+
import type { CaptureFrame } from './types.ts';
|
|
19
|
+
|
|
20
|
+
let sharp: any = null;
|
|
21
|
+
try {
|
|
22
|
+
sharp = require('sharp');
|
|
23
|
+
} catch { /* sharp not available — thumbnails disabled */ }
|
|
24
|
+
|
|
25
|
+
export class CaptureEngine implements Observer {
|
|
26
|
+
name = 'awareness-capture';
|
|
27
|
+
|
|
28
|
+
private config: AwarenessConfig;
|
|
29
|
+
private desktop: DesktopController;
|
|
30
|
+
private handler: ObserverEventHandler | null = null;
|
|
31
|
+
private running = false;
|
|
32
|
+
private captureTimer: ReturnType<typeof setInterval> | null = null;
|
|
33
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
private previousBuffer: Buffer | null = null;
|
|
35
|
+
private latestFrame: CaptureFrame | null = null;
|
|
36
|
+
private captureDir: string;
|
|
37
|
+
private currentIntervalMs: number;
|
|
38
|
+
|
|
39
|
+
constructor(config: AwarenessConfig, desktop: DesktopController) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
this.desktop = desktop;
|
|
42
|
+
this.captureDir = config.capture_dir.replace(/^~/, os.homedir());
|
|
43
|
+
this.currentIntervalMs = config.capture_interval_ms;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async start(): Promise<void> {
|
|
47
|
+
if (this.running) return;
|
|
48
|
+
this.running = true;
|
|
49
|
+
|
|
50
|
+
// Ensure capture directory exists
|
|
51
|
+
mkdirSync(this.captureDir, { recursive: true });
|
|
52
|
+
|
|
53
|
+
console.log(`[CaptureEngine] Started — interval: ${this.currentIntervalMs}ms, dir: ${this.captureDir}`);
|
|
54
|
+
|
|
55
|
+
// Start capture loop
|
|
56
|
+
this.captureTimer = setInterval(() => this.captureLoop(), this.currentIntervalMs);
|
|
57
|
+
|
|
58
|
+
// Start retention cleanup every 10 minutes
|
|
59
|
+
this.cleanupTimer = setInterval(() => this.cleanupRetention(), 10 * 60 * 1000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async stop(): Promise<void> {
|
|
63
|
+
if (!this.running) return;
|
|
64
|
+
this.running = false;
|
|
65
|
+
|
|
66
|
+
if (this.captureTimer) {
|
|
67
|
+
clearInterval(this.captureTimer);
|
|
68
|
+
this.captureTimer = null;
|
|
69
|
+
}
|
|
70
|
+
if (this.cleanupTimer) {
|
|
71
|
+
clearInterval(this.cleanupTimer);
|
|
72
|
+
this.cleanupTimer = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log('[CaptureEngine] Stopped');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
isRunning(): boolean {
|
|
79
|
+
return this.running;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
onEvent(handler: ObserverEventHandler): void {
|
|
83
|
+
this.handler = handler;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getLatestFrame(): CaptureFrame | null {
|
|
87
|
+
return this.latestFrame;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getPreviousBuffer(): Buffer | null {
|
|
91
|
+
return this.previousBuffer;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private capturing = false;
|
|
95
|
+
private connectRetries = 0;
|
|
96
|
+
private readonly MAX_CONNECT_RETRIES = 3;
|
|
97
|
+
|
|
98
|
+
private async captureLoop(): Promise<void> {
|
|
99
|
+
if (!this.running || this.capturing) return;
|
|
100
|
+
this.capturing = true;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
// Adaptive throttle: check system load (use CPU count as baseline)
|
|
104
|
+
const cpuCount = os.cpus().length;
|
|
105
|
+
const load = os.loadavg()[0];
|
|
106
|
+
const loadThreshold = Math.max(cpuCount * 1.5, 8);
|
|
107
|
+
if (load! > loadThreshold) return;
|
|
108
|
+
|
|
109
|
+
// Connect to sidecar with timeout
|
|
110
|
+
if (!this.desktop.connected) {
|
|
111
|
+
if (this.connectRetries >= this.MAX_CONNECT_RETRIES) {
|
|
112
|
+
// Stop retrying — sidecar isn't available
|
|
113
|
+
if (this.connectRetries === this.MAX_CONNECT_RETRIES) {
|
|
114
|
+
console.error('[CaptureEngine] Sidecar unavailable after 3 attempts — stopping captures');
|
|
115
|
+
this.connectRetries++;
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const connectPromise = this.desktop.connect();
|
|
122
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
123
|
+
setTimeout(() => reject(new Error('Sidecar connect timeout (10s)')), 10000)
|
|
124
|
+
);
|
|
125
|
+
await Promise.race([connectPromise, timeoutPromise]);
|
|
126
|
+
this.connectRetries = 0;
|
|
127
|
+
console.log('[CaptureEngine] Sidecar connected');
|
|
128
|
+
} catch (err) {
|
|
129
|
+
this.connectRetries++;
|
|
130
|
+
console.error(`[CaptureEngine] Sidecar connect failed (attempt ${this.connectRetries}/${this.MAX_CONNECT_RETRIES}):`, err instanceof Error ? err.message : err);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const imageBuffer = await this.desktop.captureScreen();
|
|
136
|
+
|
|
137
|
+
// Pixel diff against previous frame
|
|
138
|
+
const changePct = this.computePixelDiff(imageBuffer);
|
|
139
|
+
|
|
140
|
+
if (changePct < this.config.min_change_threshold && this.previousBuffer) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const id = generateId();
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
|
|
147
|
+
// Save to disk
|
|
148
|
+
const imagePath = this.saveCapture(imageBuffer, now);
|
|
149
|
+
|
|
150
|
+
// Generate thumbnail (non-blocking, best-effort)
|
|
151
|
+
const thumbnailPath = await this.generateThumbnail(imagePath);
|
|
152
|
+
|
|
153
|
+
const frame: CaptureFrame = {
|
|
154
|
+
id,
|
|
155
|
+
timestamp: now,
|
|
156
|
+
imageBuffer,
|
|
157
|
+
pixelChangePct: changePct,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
this.latestFrame = frame;
|
|
161
|
+
this.previousBuffer = imageBuffer;
|
|
162
|
+
|
|
163
|
+
// Emit event for processing pipeline
|
|
164
|
+
if (this.handler) {
|
|
165
|
+
this.handler({
|
|
166
|
+
type: 'screen_capture',
|
|
167
|
+
data: {
|
|
168
|
+
captureId: id,
|
|
169
|
+
pixelChangePct: changePct,
|
|
170
|
+
imagePath,
|
|
171
|
+
thumbnailPath,
|
|
172
|
+
imageBuffer,
|
|
173
|
+
},
|
|
174
|
+
timestamp: now,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error('[CaptureEngine] Capture failed:', err instanceof Error ? err.message : err);
|
|
179
|
+
// If socket died, reset connection state
|
|
180
|
+
if (err instanceof Error && (err.message.includes('ECONNREFUSED') || err.message.includes('destroyed') || err.message.includes('closed'))) {
|
|
181
|
+
try { await this.desktop.disconnect(); } catch { /* ignore */ }
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
this.capturing = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Fast pixel diff using sampled byte comparison.
|
|
190
|
+
* Compares every 100th byte of the raw buffer. Returns percentage of changed samples (0.0-1.0).
|
|
191
|
+
*/
|
|
192
|
+
private computePixelDiff(current: Buffer): number {
|
|
193
|
+
if (!this.previousBuffer) return 1.0; // First frame — always significant
|
|
194
|
+
if (current.length !== this.previousBuffer.length) return 1.0; // Size changed — significant
|
|
195
|
+
|
|
196
|
+
const step = 100;
|
|
197
|
+
let changed = 0;
|
|
198
|
+
let total = 0;
|
|
199
|
+
|
|
200
|
+
for (let i = 0; i < current.length; i += step) {
|
|
201
|
+
total++;
|
|
202
|
+
if (current[i] !== this.previousBuffer[i]) {
|
|
203
|
+
changed++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return total > 0 ? changed / total : 1.0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Save PNG to disk in date-organized directory.
|
|
212
|
+
* Returns the file path.
|
|
213
|
+
*/
|
|
214
|
+
private saveCapture(imageBuffer: Buffer, timestamp: number): string {
|
|
215
|
+
const date = new Date(timestamp);
|
|
216
|
+
const dateDir = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
217
|
+
const fileName = `${String(date.getHours()).padStart(2, '0')}-${String(date.getMinutes()).padStart(2, '0')}-${String(date.getSeconds()).padStart(2, '0')}.png`;
|
|
218
|
+
|
|
219
|
+
const dir = path.join(this.captureDir, dateDir);
|
|
220
|
+
mkdirSync(dir, { recursive: true });
|
|
221
|
+
|
|
222
|
+
const filePath = path.join(dir, fileName);
|
|
223
|
+
writeFileSync(filePath, imageBuffer);
|
|
224
|
+
|
|
225
|
+
return filePath;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Generate a 200px-wide JPEG thumbnail from a full PNG capture.
|
|
230
|
+
* Uses sharp if available. Returns thumbnail path or null.
|
|
231
|
+
*/
|
|
232
|
+
private async generateThumbnail(fullImagePath: string): Promise<string | null> {
|
|
233
|
+
if (!sharp) return null;
|
|
234
|
+
|
|
235
|
+
const thumbPath = fullImagePath.replace(/\.png$/, '-thumb.jpg');
|
|
236
|
+
try {
|
|
237
|
+
await sharp(fullImagePath).resize(200).jpeg({ quality: 60 }).toFile(thumbPath);
|
|
238
|
+
return thumbPath;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Clean up old captures based on tiered retention config.
|
|
246
|
+
* - 'full' tier: delete images + DB rows older than full_hours
|
|
247
|
+
* - 'key_moment' tier: delete images + DB rows older than key_moment_hours
|
|
248
|
+
* Disk files older than the max retention window are removed as orphans.
|
|
249
|
+
*/
|
|
250
|
+
private cleanupRetention(): void {
|
|
251
|
+
try {
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
const fullCutoff = now - (this.config.retention.full_hours * 60 * 60 * 1000);
|
|
254
|
+
const keyMomentCutoff = now - (this.config.retention.key_moment_hours * 60 * 60 * 1000);
|
|
255
|
+
|
|
256
|
+
// Delete DB rows by tier
|
|
257
|
+
let fullDeleted = 0;
|
|
258
|
+
let keyDeleted = 0;
|
|
259
|
+
try {
|
|
260
|
+
fullDeleted = deleteCapturesBefore(fullCutoff, 'full');
|
|
261
|
+
keyDeleted = deleteCapturesBefore(keyMomentCutoff, 'key_moment');
|
|
262
|
+
} catch { /* DB may not be initialized in tests */ }
|
|
263
|
+
|
|
264
|
+
// Clean up orphan files on disk
|
|
265
|
+
if (!existsSync(this.captureDir)) return;
|
|
266
|
+
|
|
267
|
+
const dateDirs = readdirSync(this.captureDir);
|
|
268
|
+
for (const dateDir of dateDirs) {
|
|
269
|
+
const dirPath = path.join(this.captureDir, dateDir);
|
|
270
|
+
try {
|
|
271
|
+
const stat = statSync(dirPath);
|
|
272
|
+
if (!stat.isDirectory()) continue;
|
|
273
|
+
|
|
274
|
+
const files = readdirSync(dirPath);
|
|
275
|
+
let remaining = files.length;
|
|
276
|
+
|
|
277
|
+
for (const file of files) {
|
|
278
|
+
const filePath = path.join(dirPath, file);
|
|
279
|
+
try {
|
|
280
|
+
const fileStat = statSync(filePath);
|
|
281
|
+
// Delete files older than max retention (key_moment_hours)
|
|
282
|
+
if (fileStat.mtimeMs < keyMomentCutoff) {
|
|
283
|
+
unlinkSync(filePath);
|
|
284
|
+
remaining--;
|
|
285
|
+
}
|
|
286
|
+
} catch { /* file already gone */ }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Remove empty date directories
|
|
290
|
+
if (remaining === 0) {
|
|
291
|
+
try {
|
|
292
|
+
if (readdirSync(dirPath).length === 0) rmdirSync(dirPath);
|
|
293
|
+
} catch { /* ignore */ }
|
|
294
|
+
}
|
|
295
|
+
} catch { /* skip */ }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (fullDeleted > 0 || keyDeleted > 0) {
|
|
299
|
+
console.log(`[CaptureEngine] Retention cleanup: ${fullDeleted} full, ${keyDeleted} key_moment captures deleted`);
|
|
300
|
+
}
|
|
301
|
+
} catch (err) {
|
|
302
|
+
console.error('[CaptureEngine] Retention cleanup error:', err instanceof Error ? err.message : err);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|