screenhand 0.1.1 → 0.2.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.
Files changed (177) hide show
  1. package/README.md +458 -93
  2. package/dist/.audit-log.jsonl +55 -0
  3. package/dist/.screenhand/memory/.lock +1 -0
  4. package/dist/.screenhand/memory/actions.jsonl +85 -0
  5. package/dist/.screenhand/memory/errors.jsonl +5 -0
  6. package/dist/.screenhand/memory/errors.jsonl.bak +4 -0
  7. package/dist/.screenhand/memory/state.json +35 -0
  8. package/dist/.screenhand/memory/state.json.bak +35 -0
  9. package/dist/.screenhand/memory/strategies.jsonl +12 -0
  10. package/dist/agent/cli.js +73 -0
  11. package/dist/agent/loop.js +258 -0
  12. package/dist/config.js +9 -0
  13. package/dist/index.js +56 -0
  14. package/dist/logging/timeline-logger.js +29 -0
  15. package/dist/mcp/mcp-stdio-server.js +448 -0
  16. package/dist/mcp/server.js +347 -0
  17. package/dist/mcp-desktop.js +2731 -0
  18. package/dist/mcp-entry.js +59 -0
  19. package/dist/memory/recall.js +160 -0
  20. package/dist/memory/research.js +98 -0
  21. package/dist/memory/seeds.js +89 -0
  22. package/dist/memory/session.js +161 -0
  23. package/dist/memory/store.js +391 -0
  24. package/dist/memory/types.js +4 -0
  25. package/dist/monitor/codex-monitor.js +377 -0
  26. package/dist/monitor/task-queue.js +84 -0
  27. package/dist/monitor/types.js +49 -0
  28. package/dist/native/bridge-client.js +174 -0
  29. package/dist/native/macos-bridge-client.js +5 -0
  30. package/dist/npm-publish-helper.js +117 -0
  31. package/dist/npm-token-cdp.js +113 -0
  32. package/dist/npm-token-create.js +135 -0
  33. package/dist/npm-token-finish.js +126 -0
  34. package/dist/playbook/engine.js +193 -0
  35. package/dist/playbook/index.js +4 -0
  36. package/dist/playbook/recorder.js +519 -0
  37. package/dist/playbook/runner.js +392 -0
  38. package/dist/playbook/store.js +166 -0
  39. package/dist/playbook/types.js +4 -0
  40. package/dist/runtime/accessibility-adapter.js +377 -0
  41. package/dist/runtime/app-adapter.js +48 -0
  42. package/dist/runtime/applescript-adapter.js +283 -0
  43. package/dist/runtime/ax-role-map.js +80 -0
  44. package/dist/runtime/browser-adapter.js +36 -0
  45. package/dist/runtime/cdp-chrome-adapter.js +505 -0
  46. package/dist/runtime/composite-adapter.js +205 -0
  47. package/dist/runtime/executor.js +250 -0
  48. package/dist/runtime/locator-cache.js +12 -0
  49. package/dist/runtime/planning-loop.js +47 -0
  50. package/dist/runtime/service.js +372 -0
  51. package/dist/runtime/session-manager.js +28 -0
  52. package/dist/runtime/state-observer.js +105 -0
  53. package/dist/runtime/vision-adapter.js +208 -0
  54. package/dist/scripts/codex-monitor-daemon.js +335 -0
  55. package/dist/scripts/supervisor-daemon.js +272 -0
  56. package/dist/scripts/worker-daemon.js +228 -0
  57. package/dist/src/agent/cli.js +82 -0
  58. package/dist/src/agent/loop.js +274 -0
  59. package/{src/config.ts → dist/src/config.js} +5 -10
  60. package/{src/index.ts → dist/src/index.js} +32 -52
  61. package/dist/src/jobs/manager.js +237 -0
  62. package/dist/src/jobs/runner.js +683 -0
  63. package/dist/src/jobs/store.js +102 -0
  64. package/dist/src/jobs/types.js +30 -0
  65. package/dist/src/jobs/worker.js +97 -0
  66. package/dist/src/logging/timeline-logger.js +45 -0
  67. package/dist/src/mcp/mcp-stdio-server.js +464 -0
  68. package/dist/src/mcp/server.js +363 -0
  69. package/dist/src/mcp-entry.js +60 -0
  70. package/dist/src/memory/recall.js +170 -0
  71. package/dist/src/memory/research.js +104 -0
  72. package/dist/src/memory/seeds.js +101 -0
  73. package/dist/src/memory/service.js +421 -0
  74. package/dist/src/memory/session.js +169 -0
  75. package/dist/src/memory/store.js +422 -0
  76. package/dist/src/memory/types.js +17 -0
  77. package/dist/src/monitor/codex-monitor.js +382 -0
  78. package/dist/src/monitor/task-queue.js +97 -0
  79. package/dist/src/monitor/types.js +62 -0
  80. package/dist/src/native/bridge-client.js +190 -0
  81. package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
  82. package/dist/src/playbook/engine.js +201 -0
  83. package/dist/src/playbook/index.js +20 -0
  84. package/dist/src/playbook/recorder.js +535 -0
  85. package/dist/src/playbook/runner.js +408 -0
  86. package/dist/src/playbook/store.js +183 -0
  87. package/dist/src/playbook/types.js +17 -0
  88. package/dist/src/runtime/accessibility-adapter.js +393 -0
  89. package/dist/src/runtime/app-adapter.js +64 -0
  90. package/dist/src/runtime/applescript-adapter.js +299 -0
  91. package/dist/src/runtime/ax-role-map.js +96 -0
  92. package/dist/src/runtime/browser-adapter.js +52 -0
  93. package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
  94. package/dist/src/runtime/composite-adapter.js +221 -0
  95. package/dist/src/runtime/execution-contract.js +159 -0
  96. package/dist/src/runtime/executor.js +266 -0
  97. package/{src/runtime/locator-cache.ts → dist/src/runtime/locator-cache.js} +10 -15
  98. package/dist/src/runtime/planning-loop.js +63 -0
  99. package/dist/src/runtime/service.js +388 -0
  100. package/dist/src/runtime/session-manager.js +60 -0
  101. package/dist/src/runtime/state-observer.js +121 -0
  102. package/dist/src/runtime/vision-adapter.js +224 -0
  103. package/dist/src/supervisor/locks.js +186 -0
  104. package/dist/src/supervisor/supervisor.js +403 -0
  105. package/dist/src/supervisor/types.js +30 -0
  106. package/dist/src/test-mcp-protocol.js +154 -0
  107. package/dist/src/types.js +17 -0
  108. package/dist/src/util/atomic-write.js +118 -0
  109. package/dist/test-mcp-protocol.js +138 -0
  110. package/dist/types.js +1 -0
  111. package/package.json +18 -4
  112. package/.claude/commands/automate.md +0 -28
  113. package/.claude/commands/debug-ui.md +0 -19
  114. package/.claude/commands/screenshot.md +0 -15
  115. package/.github/FUNDING.yml +0 -1
  116. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
  117. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  118. package/.mcp.json +0 -8
  119. package/DESKTOP_MCP_GUIDE.md +0 -92
  120. package/SECURITY.md +0 -44
  121. package/docs/architecture.md +0 -47
  122. package/install-skills.sh +0 -19
  123. package/mcp-bridge.ts +0 -271
  124. package/mcp-desktop.ts +0 -1221
  125. package/native/macos-bridge/Package.swift +0 -21
  126. package/native/macos-bridge/Sources/AccessibilityBridge.swift +0 -261
  127. package/native/macos-bridge/Sources/AppManagement.swift +0 -129
  128. package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +0 -242
  129. package/native/macos-bridge/Sources/ObserverBridge.swift +0 -120
  130. package/native/macos-bridge/Sources/VisionBridge.swift +0 -80
  131. package/native/macos-bridge/Sources/main.swift +0 -345
  132. package/native/windows-bridge/AppManagement.cs +0 -234
  133. package/native/windows-bridge/InputBridge.cs +0 -436
  134. package/native/windows-bridge/Program.cs +0 -265
  135. package/native/windows-bridge/ScreenCapture.cs +0 -329
  136. package/native/windows-bridge/UIAutomationBridge.cs +0 -571
  137. package/native/windows-bridge/WindowsBridge.csproj +0 -17
  138. package/playbooks/devpost.json +0 -186
  139. package/playbooks/instagram.json +0 -41
  140. package/playbooks/instagram_v2.json +0 -201
  141. package/playbooks/x_v1.json +0 -211
  142. package/scripts/devpost-live-loop.mjs +0 -421
  143. package/src/logging/timeline-logger.ts +0 -55
  144. package/src/mcp/server.ts +0 -449
  145. package/src/memory/recall.ts +0 -191
  146. package/src/memory/research.ts +0 -146
  147. package/src/memory/seeds.ts +0 -123
  148. package/src/memory/session.ts +0 -201
  149. package/src/memory/store.ts +0 -434
  150. package/src/memory/types.ts +0 -69
  151. package/src/native/bridge-client.ts +0 -239
  152. package/src/runtime/accessibility-adapter.ts +0 -487
  153. package/src/runtime/app-adapter.ts +0 -169
  154. package/src/runtime/applescript-adapter.ts +0 -376
  155. package/src/runtime/ax-role-map.ts +0 -102
  156. package/src/runtime/browser-adapter.ts +0 -129
  157. package/src/runtime/cdp-chrome-adapter.ts +0 -676
  158. package/src/runtime/composite-adapter.ts +0 -274
  159. package/src/runtime/executor.ts +0 -396
  160. package/src/runtime/planning-loop.ts +0 -81
  161. package/src/runtime/service.ts +0 -448
  162. package/src/runtime/session-manager.ts +0 -50
  163. package/src/runtime/state-observer.ts +0 -136
  164. package/src/runtime/vision-adapter.ts +0 -297
  165. package/src/types.ts +0 -297
  166. package/tests/bridge-client.test.ts +0 -176
  167. package/tests/browser-stealth.test.ts +0 -210
  168. package/tests/composite-adapter.test.ts +0 -64
  169. package/tests/mcp-server.test.ts +0 -151
  170. package/tests/memory-recall.test.ts +0 -339
  171. package/tests/memory-research.test.ts +0 -159
  172. package/tests/memory-seeds.test.ts +0 -120
  173. package/tests/memory-store.test.ts +0 -392
  174. package/tests/types.test.ts +0 -92
  175. package/tsconfig.check.json +0 -17
  176. package/tsconfig.json +0 -19
  177. package/vitest.config.ts +0 -8
@@ -1,159 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import os from "node:os";
5
- import { backgroundResearch } from "../src/memory/research.js";
6
- import { MemoryStore } from "../src/memory/store.js";
7
-
8
- let tmpDir: string;
9
- let store: MemoryStore;
10
-
11
- beforeEach(() => {
12
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "research-test-"));
13
- store = new MemoryStore(tmpDir);
14
- store.init();
15
- });
16
-
17
- afterEach(() => {
18
- vi.restoreAllMocks();
19
- fs.rmSync(tmpDir, { recursive: true, force: true });
20
- });
21
-
22
- describe("backgroundResearch", () => {
23
- it("returns immediately (non-blocking)", () => {
24
- const start = Date.now();
25
- // Mock fetch to prevent actual network calls
26
- vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("no network"));
27
- backgroundResearch(store, "launch", { bundleId: "com.test" }, "app not found");
28
- const elapsed = Date.now() - start;
29
- expect(elapsed).toBeLessThan(5);
30
- });
31
-
32
- it("does not throw on any input", () => {
33
- vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("no network"));
34
- expect(() => backgroundResearch(store, "launch", {}, "error")).not.toThrow();
35
- expect(() => backgroundResearch(store, "", {}, "")).not.toThrow();
36
- expect(() => backgroundResearch(store, "key", { combo: "cmd+s" }, "failed")).not.toThrow();
37
- });
38
-
39
- it("saves resolution to error cache (mock fetch)", async () => {
40
- const mockResponse = {
41
- ok: true,
42
- json: async () => ({
43
- AbstractText: "Try launching the app with the correct bundle ID from the Applications folder.",
44
- }),
45
- };
46
- vi.spyOn(globalThis, "fetch").mockResolvedValue(mockResponse as Response);
47
-
48
- // Delete API key so it falls through to DuckDuckGo path
49
- const origKey = process.env["ANTHROPIC_API_KEY"];
50
- delete process.env["ANTHROPIC_API_KEY"];
51
-
52
- backgroundResearch(store, "launch", { bundleId: "com.test" }, "app not found");
53
-
54
- // Wait for the async work to complete
55
- await new Promise((r) => setTimeout(r, 100));
56
-
57
- const errors = store.readErrors();
58
- // Should have the seed error (from backgroundResearch) plus any existing
59
- const researchErrors = errors.filter((e) => e.resolution !== null);
60
- expect(researchErrors.length).toBeGreaterThan(0);
61
- expect(researchErrors[0]!.resolution).toContain("bundle ID");
62
-
63
- // Restore
64
- if (origKey !== undefined) process.env["ANTHROPIC_API_KEY"] = origKey;
65
- });
66
-
67
- it("saves resolution as strategy too (mock fetch)", async () => {
68
- const mockResponse = {
69
- ok: true,
70
- json: async () => ({
71
- AbstractText: "Use the correct app identifier to launch applications on macOS.",
72
- }),
73
- };
74
- vi.spyOn(globalThis, "fetch").mockResolvedValue(mockResponse as Response);
75
-
76
- const origKey = process.env["ANTHROPIC_API_KEY"];
77
- delete process.env["ANTHROPIC_API_KEY"];
78
-
79
- const initialCount = store.readStrategies().length;
80
- backgroundResearch(store, "launch", { bundleId: "com.test" }, "app not found");
81
-
82
- await new Promise((r) => setTimeout(r, 100));
83
-
84
- const strategies = store.readStrategies();
85
- expect(strategies.length).toBeGreaterThan(initialCount);
86
- const researchStrategy = strategies.find((s) => s.id.startsWith("str_research_"));
87
- expect(researchStrategy).toBeDefined();
88
- expect(researchStrategy!.tags).toContain("research");
89
-
90
- if (origKey !== undefined) process.env["ANTHROPIC_API_KEY"] = origKey;
91
- });
92
-
93
- it("handles fetch failure gracefully", async () => {
94
- vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error"));
95
-
96
- const origKey = process.env["ANTHROPIC_API_KEY"];
97
- delete process.env["ANTHROPIC_API_KEY"];
98
-
99
- const initialErrors = store.readErrors().length;
100
- backgroundResearch(store, "launch", {}, "test error");
101
-
102
- await new Promise((r) => setTimeout(r, 100));
103
-
104
- // No new errors should be added since fetch failed
105
- const errors = store.readErrors();
106
- expect(errors.length).toBe(initialErrors);
107
-
108
- if (origKey !== undefined) process.env["ANTHROPIC_API_KEY"] = origKey;
109
- });
110
-
111
- it("falls back to DuckDuckGo when no API key", async () => {
112
- const origKey = process.env["ANTHROPIC_API_KEY"];
113
- delete process.env["ANTHROPIC_API_KEY"];
114
-
115
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
116
- ok: true,
117
- json: async () => ({
118
- AbstractText: "DuckDuckGo answer for macOS automation fix.",
119
- }),
120
- } as Response);
121
-
122
- backgroundResearch(store, "focus", { bundleId: "com.test" }, "app not running");
123
-
124
- await new Promise((r) => setTimeout(r, 100));
125
-
126
- // Should have called DuckDuckGo (api.duckduckgo.com), not Anthropic
127
- expect(fetchSpy).toHaveBeenCalledTimes(1);
128
- const calledUrl = fetchSpy.mock.calls[0]![0] as string;
129
- expect(calledUrl).toContain("duckduckgo.com");
130
-
131
- if (origKey !== undefined) process.env["ANTHROPIC_API_KEY"] = origKey;
132
- });
133
-
134
- it("tries Claude API first when API key is set", async () => {
135
- const origKey = process.env["ANTHROPIC_API_KEY"];
136
- process.env["ANTHROPIC_API_KEY"] = "test-key-123";
137
-
138
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
139
- ok: true,
140
- json: async () => ({
141
- content: [{ type: "text", text: "Use the correct bundle identifier for the application." }],
142
- }),
143
- } as Response);
144
-
145
- backgroundResearch(store, "launch", {}, "app not found");
146
-
147
- await new Promise((r) => setTimeout(r, 100));
148
-
149
- expect(fetchSpy).toHaveBeenCalled();
150
- const calledUrl = fetchSpy.mock.calls[0]![0] as string;
151
- expect(calledUrl).toContain("anthropic.com");
152
-
153
- if (origKey !== undefined) {
154
- process.env["ANTHROPIC_API_KEY"] = origKey;
155
- } else {
156
- delete process.env["ANTHROPIC_API_KEY"];
157
- }
158
- });
159
- });
@@ -1,120 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import os from "node:os";
5
- import { SEED_STRATEGIES } from "../src/memory/seeds.js";
6
- import { MemoryStore } from "../src/memory/store.js";
7
- import { RecallEngine } from "../src/memory/recall.js";
8
-
9
- let tmpDir: string;
10
-
11
- beforeEach(() => {
12
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seeds-test-"));
13
- });
14
-
15
- afterEach(() => {
16
- fs.rmSync(tmpDir, { recursive: true, force: true });
17
- });
18
-
19
- describe("SEED_STRATEGIES structure", () => {
20
- it("all seeds have valid required fields", () => {
21
- for (const s of SEED_STRATEGIES) {
22
- expect(s.id).toBeTruthy();
23
- expect(s.task).toBeTruthy();
24
- expect(s.steps.length).toBeGreaterThan(0);
25
- expect(s.tags.length).toBeGreaterThan(0);
26
- expect(s.fingerprint).toBeTruthy();
27
- expect(typeof s.successCount).toBe("number");
28
- expect(typeof s.failCount).toBe("number");
29
- expect(s.lastUsed).toBeTruthy();
30
- for (const step of s.steps) {
31
- expect(step.tool).toBeTruthy();
32
- expect(step.params).toBeDefined();
33
- }
34
- }
35
- });
36
-
37
- it("all seeds have successCount of 10", () => {
38
- for (const s of SEED_STRATEGIES) {
39
- expect(s.successCount).toBe(10);
40
- }
41
- });
42
-
43
- it("has no duplicate fingerprints", () => {
44
- const fingerprints = SEED_STRATEGIES.map((s) => s.fingerprint);
45
- const unique = new Set(fingerprints);
46
- // Some seeds may share tool sequences (e.g. focus→key), so check IDs are unique
47
- const ids = SEED_STRATEGIES.map((s) => s.id);
48
- expect(new Set(ids).size).toBe(ids.length);
49
- });
50
-
51
- it("has approximately 12 seed strategies", () => {
52
- expect(SEED_STRATEGIES.length).toBeGreaterThanOrEqual(10);
53
- expect(SEED_STRATEGIES.length).toBeLessThanOrEqual(15);
54
- });
55
- });
56
-
57
- describe("Store seed loading", () => {
58
- it("loads seeds on first init (empty directory)", () => {
59
- const store = new MemoryStore(tmpDir);
60
- store.init();
61
- const strategies = store.readStrategies();
62
- expect(strategies.length).toBe(SEED_STRATEGIES.length);
63
- expect(strategies[0]!.id).toMatch(/^seed_/);
64
- });
65
-
66
- it("skips seeds when strategies already exist on disk", async () => {
67
- // First init — seeds are loaded and persisted
68
- const store1 = new MemoryStore(tmpDir);
69
- store1.init();
70
-
71
- // Add a custom strategy
72
- store1.appendStrategy({
73
- id: "str_custom",
74
- task: "custom task",
75
- steps: [{ tool: "screenshot", params: {} }],
76
- totalDurationMs: 100,
77
- successCount: 5,
78
- failCount: 0,
79
- lastUsed: new Date().toISOString(),
80
- tags: ["custom"],
81
- fingerprint: "screenshot",
82
- });
83
-
84
- // Wait for async write to persist
85
- await new Promise((r) => setTimeout(r, 50));
86
-
87
- // Create a new store instance — should NOT re-add seeds
88
- const store2 = new MemoryStore(tmpDir);
89
- store2.init();
90
- const strategies = store2.readStrategies();
91
- // Should have seeds + custom, not double seeds
92
- expect(strategies.length).toBe(SEED_STRATEGIES.length + 1);
93
- });
94
-
95
- it("seeds are searchable via RecallEngine", () => {
96
- const store = new MemoryStore(tmpDir);
97
- store.init();
98
- const engine = new RecallEngine(store);
99
-
100
- const results = engine.recallStrategies("take photo", 5);
101
- expect(results.length).toBeGreaterThan(0);
102
- expect(results[0]!.task).toContain("Photo Booth");
103
- });
104
-
105
- it("seeds survive reload (persisted to disk)", async () => {
106
- const store1 = new MemoryStore(tmpDir);
107
- store1.init();
108
-
109
- // Give async write a moment
110
- await new Promise((r) => setTimeout(r, 50));
111
-
112
- // Check the file was written
113
- const memDir = path.join(tmpDir, ".screenhand", "memory");
114
- const strategiesFile = path.join(memDir, "strategies.jsonl");
115
- expect(fs.existsSync(strategiesFile)).toBe(true);
116
-
117
- const lines = fs.readFileSync(strategiesFile, "utf-8").trim().split("\n");
118
- expect(lines.length).toBe(SEED_STRATEGIES.length);
119
- });
120
- });
@@ -1,392 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import os from "node:os";
5
- import { MemoryStore } from "../src/memory/store.js";
6
- import type { ActionEntry, Strategy, ErrorPattern } from "../src/memory/types.js";
7
-
8
- let tmpDir: string;
9
- let store: MemoryStore;
10
-
11
- function makeAction(overrides: Partial<ActionEntry> = {}): ActionEntry {
12
- return {
13
- id: "a_test" + Math.random().toString(36).slice(2, 6),
14
- timestamp: new Date().toISOString(),
15
- sessionId: "s_test",
16
- tool: "apps",
17
- params: {},
18
- durationMs: 50,
19
- success: true,
20
- result: "ok",
21
- error: null,
22
- ...overrides,
23
- };
24
- }
25
-
26
- function makeStrategy(overrides: Partial<Strategy> = {}): Strategy {
27
- const steps = overrides.steps ?? [{ tool: "apps", params: {} }];
28
- return {
29
- id: "str_test" + Math.random().toString(36).slice(2, 6),
30
- task: "test task",
31
- steps,
32
- totalDurationMs: 50,
33
- successCount: 1,
34
- failCount: 0,
35
- lastUsed: new Date().toISOString(),
36
- tags: ["test"],
37
- fingerprint: steps.map((s) => s.tool).join("→"),
38
- ...overrides,
39
- };
40
- }
41
-
42
- function makeError(overrides: Partial<ErrorPattern> = {}): ErrorPattern {
43
- return {
44
- id: "err_test" + Math.random().toString(36).slice(2, 6),
45
- tool: "launch",
46
- params: { bundleId: "com.test.App" },
47
- error: "timed out",
48
- resolution: null,
49
- occurrences: 1,
50
- lastSeen: new Date().toISOString(),
51
- ...overrides,
52
- };
53
- }
54
-
55
- /** Wait for async file writes to flush */
56
- function waitForFlush(): Promise<void> {
57
- return new Promise((r) => setTimeout(r, 50));
58
- }
59
-
60
- beforeEach(() => {
61
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "screenhand-test-"));
62
- // Pre-create memory dir so seeds don't auto-load (not first boot)
63
- fs.mkdirSync(path.join(tmpDir, ".screenhand", "memory"), { recursive: true });
64
- store = new MemoryStore(tmpDir);
65
- store.init();
66
- });
67
-
68
- afterEach(() => {
69
- // Remove lock file first, then clean up
70
- const lockPath = path.join(tmpDir, ".screenhand", "memory", ".lock");
71
- try { fs.unlinkSync(lockPath); } catch { /* ignore */ }
72
- fs.rmSync(tmpDir, { recursive: true, force: true });
73
- });
74
-
75
- describe("MemoryStore", () => {
76
- describe("actions", () => {
77
- it("appends actions and updates in-memory stats", () => {
78
- store.appendAction(makeAction({ tool: "apps" }));
79
- store.appendAction(makeAction({ tool: "focus" }));
80
-
81
- const stats = store.getStats();
82
- expect(stats.totalActions).toBe(2);
83
- expect(stats.topTools).toContainEqual({ tool: "apps", count: 1 });
84
- expect(stats.topTools).toContainEqual({ tool: "focus", count: 1 });
85
- });
86
-
87
- it("tracks success rate in-memory", () => {
88
- store.appendAction(makeAction({ success: true }));
89
- store.appendAction(makeAction({ success: true }));
90
- store.appendAction(makeAction({ success: false }));
91
-
92
- expect(store.getStats().successRate).toBeCloseTo(2 / 3);
93
- });
94
-
95
- it("writes to disk asynchronously", async () => {
96
- store.appendAction(makeAction({ tool: "apps" }));
97
- // Wait for 100ms debounce + some buffer
98
- await new Promise((r) => setTimeout(r, 200));
99
-
100
- const fp = path.join(tmpDir, ".screenhand", "memory", "actions.jsonl");
101
- expect(fs.existsSync(fp)).toBe(true);
102
- const content = fs.readFileSync(fp, "utf-8").trim();
103
- expect(content.split("\n")).toHaveLength(1);
104
- });
105
-
106
- it("returns empty stats when no actions", () => {
107
- expect(store.getStats().totalActions).toBe(0);
108
- expect(store.getStats().successRate).toBe(0);
109
- });
110
- });
111
-
112
- describe("strategies (cached)", () => {
113
- it("appends and reads from cache", () => {
114
- store.appendStrategy(makeStrategy({ task: "task A" }));
115
- store.appendStrategy(makeStrategy({ task: "task B" }));
116
-
117
- const strategies = store.readStrategies();
118
- expect(strategies).toHaveLength(2);
119
- });
120
-
121
- it("deduplicates by task name and increments successCount", () => {
122
- store.appendStrategy(makeStrategy({ task: "take a photo", successCount: 1 }));
123
- store.appendStrategy(makeStrategy({ task: "take a photo", successCount: 1 }));
124
-
125
- const strategies = store.readStrategies();
126
- expect(strategies).toHaveLength(1);
127
- expect(strategies[0]!.successCount).toBe(2);
128
- });
129
-
130
- it("persists to disk asynchronously", async () => {
131
- store.appendStrategy(makeStrategy({ task: "persist test" }));
132
- await waitForFlush();
133
-
134
- const fp = path.join(tmpDir, ".screenhand", "memory", "strategies.jsonl");
135
- const content = fs.readFileSync(fp, "utf-8").trim();
136
- expect(content).toContain("persist test");
137
- });
138
-
139
- it("survives re-init (loads from disk)", async () => {
140
- store.appendStrategy(makeStrategy({ task: "survive reload" }));
141
- await waitForFlush();
142
-
143
- // Create a new store pointing at the same dir
144
- const store2 = new MemoryStore(tmpDir);
145
- store2.init();
146
- expect(store2.readStrategies()).toHaveLength(1);
147
- expect(store2.readStrategies()[0]!.task).toBe("survive reload");
148
- });
149
- });
150
-
151
- describe("errors (cached)", () => {
152
- it("appends and reads from cache", () => {
153
- store.appendError(makeError());
154
- const errors = store.readErrors();
155
- expect(errors).toHaveLength(1);
156
- expect(errors[0]!.tool).toBe("launch");
157
- });
158
-
159
- it("deduplicates by tool+error and increments occurrences", () => {
160
- store.appendError(makeError({ tool: "launch", error: "timed out" }));
161
- store.appendError(makeError({ tool: "launch", error: "timed out" }));
162
-
163
- const errors = store.readErrors();
164
- expect(errors).toHaveLength(1);
165
- expect(errors[0]!.occurrences).toBe(2);
166
- });
167
-
168
- it("preserves existing resolution when new one is null", () => {
169
- store.appendError(makeError({ tool: "launch", error: "timeout", resolution: "use focus()" }));
170
- store.appendError(makeError({ tool: "launch", error: "timeout", resolution: null }));
171
-
172
- const errors = store.readErrors();
173
- expect(errors[0]!.resolution).toBe("use focus()");
174
- });
175
- });
176
-
177
- describe("stats", () => {
178
- it("returns correct stats from in-memory counters", () => {
179
- store.appendAction(makeAction({ tool: "apps", success: true }));
180
- store.appendAction(makeAction({ tool: "apps", success: true }));
181
- store.appendAction(makeAction({ tool: "focus", success: false }));
182
- store.appendStrategy(makeStrategy());
183
- store.appendError(makeError());
184
-
185
- const stats = store.getStats();
186
- expect(stats.totalActions).toBe(3);
187
- expect(stats.totalStrategies).toBe(1);
188
- expect(stats.totalErrors).toBe(1);
189
- expect(stats.successRate).toBeCloseTo(2 / 3);
190
- expect(stats.topTools[0]!.tool).toBe("apps");
191
- expect(stats.topTools[0]!.count).toBe(2);
192
- });
193
- });
194
-
195
- describe("fingerprint index", () => {
196
- it("looks up strategy by fingerprint in O(1)", () => {
197
- const s = makeStrategy({ task: "photo", steps: [{ tool: "apps", params: {} }, { tool: "focus", params: {} }] });
198
- store.appendStrategy(s);
199
-
200
- const found = store.lookupByFingerprint("apps→focus");
201
- expect(found).not.toBeUndefined();
202
- expect(found!.task).toBe("photo");
203
- });
204
-
205
- it("returns undefined for unknown fingerprint", () => {
206
- expect(store.lookupByFingerprint("nonexistent→tools")).toBeUndefined();
207
- });
208
-
209
- it("rebuilds index on init from disk", async () => {
210
- store.appendStrategy(makeStrategy({ task: "persisted", steps: [{ tool: "launch", params: {} }, { tool: "ui_press", params: {} }] }));
211
- await waitForFlush();
212
-
213
- const store2 = new MemoryStore(tmpDir);
214
- store2.init();
215
- const found = store2.lookupByFingerprint("launch→ui_press");
216
- expect(found).not.toBeUndefined();
217
- expect(found!.task).toBe("persisted");
218
- });
219
- });
220
-
221
- describe("feedback loop", () => {
222
- it("increments successCount on positive outcome", () => {
223
- const s = makeStrategy({ task: "test feedback", steps: [{ tool: "apps", params: {} }] });
224
- store.appendStrategy(s);
225
-
226
- store.recordStrategyOutcome("apps", true);
227
- const strategies = store.readStrategies();
228
- expect(strategies[0]!.successCount).toBe(2);
229
- });
230
-
231
- it("increments failCount on negative outcome", () => {
232
- const s = makeStrategy({ task: "test feedback", steps: [{ tool: "apps", params: {} }] });
233
- store.appendStrategy(s);
234
-
235
- store.recordStrategyOutcome("apps", false);
236
- const strategies = store.readStrategies();
237
- expect(strategies[0]!.failCount).toBe(1);
238
- });
239
-
240
- it("does nothing for unknown fingerprint", () => {
241
- store.recordStrategyOutcome("unknown→fp", true);
242
- expect(store.readStrategies()).toHaveLength(0);
243
- });
244
- });
245
-
246
- describe("corrupted JSONL recovery", () => {
247
- it("skips corrupted lines and parses valid ones", async () => {
248
- // Manually write a file with a corrupted line
249
- store.appendStrategy(makeStrategy({ task: "good entry" }));
250
- await waitForFlush();
251
-
252
- const fp = path.join(tmpDir, ".screenhand", "memory", "strategies.jsonl");
253
- // Append a corrupted line
254
- fs.appendFileSync(fp, "NOT VALID JSON\n");
255
- // Append another valid line manually
256
- const valid = makeStrategy({ task: "after corruption" });
257
- fs.appendFileSync(fp, JSON.stringify(valid) + "\n");
258
-
259
- // Re-init from disk
260
- const store2 = new MemoryStore(tmpDir);
261
- store2.init();
262
- const strategies = store2.readStrategies();
263
- // Should have both valid entries, skipping the corrupted one
264
- expect(strategies.length).toBe(2);
265
- expect(strategies[0]!.task).toBe("good entry");
266
- expect(strategies[1]!.task).toBe("after corruption");
267
- });
268
-
269
- it("handles completely empty file", () => {
270
- const fp = path.join(tmpDir, ".screenhand", "memory", "strategies.jsonl");
271
- fs.mkdirSync(path.dirname(fp), { recursive: true });
272
- fs.writeFileSync(fp, "");
273
-
274
- const store2 = new MemoryStore(tmpDir);
275
- store2.init();
276
- expect(store2.readStrategies()).toEqual([]);
277
- });
278
-
279
- it("handles file with only whitespace/empty lines", () => {
280
- const fp = path.join(tmpDir, ".screenhand", "memory", "strategies.jsonl");
281
- fs.mkdirSync(path.dirname(fp), { recursive: true });
282
- fs.writeFileSync(fp, "\n\n \n\n");
283
-
284
- const store2 = new MemoryStore(tmpDir);
285
- store2.init();
286
- expect(store2.readStrategies()).toEqual([]);
287
- });
288
- });
289
-
290
- describe("LRU eviction", () => {
291
- it("evicts oldest strategies when exceeding 500 limit", () => {
292
- // Add 502 strategies with different timestamps
293
- for (let i = 0; i < 502; i++) {
294
- const date = new Date(Date.now() - (502 - i) * 1000); // oldest first
295
- store.appendStrategy(makeStrategy({
296
- task: `task_${i}`,
297
- lastUsed: date.toISOString(),
298
- steps: [{ tool: `tool_${i}`, params: {} }],
299
- fingerprint: `tool_${i}`,
300
- }));
301
- }
302
-
303
- const strategies = store.readStrategies();
304
- expect(strategies.length).toBeLessThanOrEqual(500);
305
- // Oldest entries (task_0, task_1) should have been evicted
306
- expect(strategies.find((s) => s.task === "task_0")).toBeUndefined();
307
- expect(strategies.find((s) => s.task === "task_1")).toBeUndefined();
308
- // Newest entries should remain
309
- expect(strategies.find((s) => s.task === "task_501")).not.toBeUndefined();
310
- });
311
-
312
- it("evicts oldest errors when exceeding 200 limit", () => {
313
- for (let i = 0; i < 202; i++) {
314
- const date = new Date(Date.now() - (202 - i) * 1000);
315
- store.appendError(makeError({
316
- tool: `tool_${i}`,
317
- error: `error_${i}`,
318
- lastSeen: date.toISOString(),
319
- }));
320
- }
321
-
322
- const errors = store.readErrors();
323
- expect(errors.length).toBeLessThanOrEqual(200);
324
- });
325
- });
326
-
327
- describe("file locking", () => {
328
- it("creates lock file on init", () => {
329
- const lockPath = path.join(tmpDir, ".screenhand", "memory", ".lock");
330
- expect(fs.existsSync(lockPath)).toBe(true);
331
- const content = fs.readFileSync(lockPath, "utf-8").trim();
332
- expect(parseInt(content, 10)).toBe(process.pid);
333
- });
334
-
335
- it("second store instance skips writes when locked", async () => {
336
- // store already holds the lock
337
- store.appendStrategy(makeStrategy({ task: "from first" }));
338
-
339
- // Second instance can't get lock
340
- const store2 = new MemoryStore(tmpDir);
341
- store2.init();
342
- store2.appendStrategy(makeStrategy({ task: "from second" }));
343
- await waitForFlush();
344
-
345
- // Second instance should have it in cache but not on disk
346
- expect(store2.readStrategies().find((s) => s.task === "from second")).not.toBeUndefined();
347
- });
348
- });
349
-
350
- describe("buffered writes", () => {
351
- it("batches multiple action writes", async () => {
352
- // Write 5 actions rapidly
353
- for (let i = 0; i < 5; i++) {
354
- store.appendAction(makeAction({ tool: `tool_${i}` }));
355
- }
356
-
357
- // Stats should be immediate (in-memory)
358
- expect(store.getStats().totalActions).toBe(5);
359
-
360
- // Wait for debounced flush
361
- await new Promise((r) => setTimeout(r, 150));
362
-
363
- const fp = path.join(tmpDir, ".screenhand", "memory", "actions.jsonl");
364
- const lines = fs.readFileSync(fp, "utf-8").trim().split("\n");
365
- expect(lines).toHaveLength(5);
366
- });
367
- });
368
-
369
- describe("clear", () => {
370
- it("clears specific category from cache and disk", () => {
371
- store.appendAction(makeAction());
372
- store.appendStrategy(makeStrategy());
373
- store.appendError(makeError());
374
-
375
- store.clear("actions");
376
- expect(store.getStats().totalActions).toBe(0);
377
- expect(store.readStrategies()).toHaveLength(1);
378
- expect(store.readErrors()).toHaveLength(1);
379
- });
380
-
381
- it("clears all", () => {
382
- store.appendAction(makeAction());
383
- store.appendStrategy(makeStrategy());
384
- store.appendError(makeError());
385
-
386
- store.clear("all");
387
- expect(store.getStats().totalActions).toBe(0);
388
- expect(store.readStrategies()).toEqual([]);
389
- expect(store.readErrors()).toEqual([]);
390
- });
391
- });
392
- });