screenhand 0.1.1 → 0.3.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 (241) hide show
  1. package/README.md +193 -109
  2. package/bin/darwin-arm64/macos-bridge +0 -0
  3. package/dist/mcp-desktop.js +5876 -0
  4. package/dist/scripts/codex-monitor-daemon.js +335 -0
  5. package/dist/scripts/export-help-center.js +112 -0
  6. package/dist/scripts/marketing-loop.js +117 -0
  7. package/dist/scripts/observer-daemon.js +288 -0
  8. package/dist/scripts/orchestrator-daemon.js +399 -0
  9. package/dist/scripts/supervisor-daemon.js +272 -0
  10. package/dist/scripts/threads-campaign.js +208 -0
  11. package/dist/scripts/worker-daemon.js +228 -0
  12. package/dist/src/agent/cli.js +82 -0
  13. package/dist/src/agent/loop.js +274 -0
  14. package/dist/src/community/fetcher.js +109 -0
  15. package/dist/src/community/index.js +6 -0
  16. package/dist/src/community/publisher.js +191 -0
  17. package/dist/src/community/remote-api.js +121 -0
  18. package/dist/src/community/types.js +3 -0
  19. package/dist/src/community/validator.js +95 -0
  20. package/{src/config.ts → dist/src/config.js} +5 -10
  21. package/dist/src/context-tracker.js +489 -0
  22. package/{src/index.ts → dist/src/index.js} +32 -52
  23. package/dist/src/ingestion/coverage-auditor.js +233 -0
  24. package/dist/src/ingestion/doc-parser.js +164 -0
  25. package/dist/src/ingestion/index.js +8 -0
  26. package/dist/src/ingestion/menu-scanner.js +152 -0
  27. package/dist/src/ingestion/reference-merger.js +186 -0
  28. package/dist/src/ingestion/shortcut-extractor.js +180 -0
  29. package/dist/src/ingestion/tutorial-extractor.js +170 -0
  30. package/dist/src/ingestion/types.js +3 -0
  31. package/dist/src/jobs/manager.js +305 -0
  32. package/dist/src/jobs/runner.js +806 -0
  33. package/dist/src/jobs/store.js +102 -0
  34. package/dist/src/jobs/types.js +30 -0
  35. package/dist/src/jobs/worker.js +97 -0
  36. package/dist/src/learning/engine.js +356 -0
  37. package/dist/src/learning/index.js +9 -0
  38. package/dist/src/learning/locator-policy.js +120 -0
  39. package/dist/src/learning/pattern-policy.js +89 -0
  40. package/dist/src/learning/recovery-policy.js +116 -0
  41. package/dist/src/learning/sensor-policy.js +115 -0
  42. package/dist/src/learning/timing-model.js +204 -0
  43. package/dist/src/learning/topology-policy.js +90 -0
  44. package/dist/src/learning/types.js +9 -0
  45. package/dist/src/logging/timeline-logger.js +48 -0
  46. package/dist/src/mcp/mcp-stdio-server.js +464 -0
  47. package/dist/src/mcp/server.js +363 -0
  48. package/dist/src/mcp-entry.js +60 -0
  49. package/dist/src/memory/playbook-seeds.js +200 -0
  50. package/dist/src/memory/recall.js +222 -0
  51. package/dist/src/memory/research.js +104 -0
  52. package/dist/src/memory/seeds.js +101 -0
  53. package/dist/src/memory/service.js +446 -0
  54. package/dist/src/memory/session.js +169 -0
  55. package/dist/src/memory/store.js +451 -0
  56. package/{src/runtime/locator-cache.ts → dist/src/memory/types.js} +1 -17
  57. package/dist/src/monitor/codex-monitor.js +382 -0
  58. package/dist/src/monitor/task-queue.js +97 -0
  59. package/dist/src/monitor/types.js +62 -0
  60. package/dist/src/native/bridge-client.js +412 -0
  61. package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
  62. package/dist/src/observer/state.js +199 -0
  63. package/dist/src/observer/types.js +43 -0
  64. package/dist/src/orchestrator/state.js +68 -0
  65. package/dist/src/orchestrator/types.js +22 -0
  66. package/dist/src/perception/ax-source.js +162 -0
  67. package/dist/src/perception/cdp-source.js +162 -0
  68. package/dist/src/perception/coordinator.js +771 -0
  69. package/dist/src/perception/frame-differ.js +287 -0
  70. package/dist/src/perception/index.js +22 -0
  71. package/dist/src/perception/manager.js +199 -0
  72. package/dist/src/perception/types.js +47 -0
  73. package/dist/src/perception/vision-source.js +399 -0
  74. package/dist/src/planner/deterministic.js +298 -0
  75. package/dist/src/planner/executor.js +870 -0
  76. package/dist/src/planner/goal-store.js +92 -0
  77. package/dist/src/planner/index.js +21 -0
  78. package/dist/src/planner/planner.js +520 -0
  79. package/dist/src/planner/tool-registry.js +71 -0
  80. package/dist/src/planner/types.js +22 -0
  81. package/dist/src/platform/explorer.js +213 -0
  82. package/dist/src/platform/help-center-markdown.js +527 -0
  83. package/dist/src/platform/learner.js +257 -0
  84. package/dist/src/playbook/engine.js +486 -0
  85. package/dist/src/playbook/index.js +20 -0
  86. package/dist/src/playbook/mcp-recorder.js +204 -0
  87. package/dist/src/playbook/recorder.js +536 -0
  88. package/dist/src/playbook/runner.js +408 -0
  89. package/dist/src/playbook/store.js +312 -0
  90. package/dist/src/playbook/types.js +17 -0
  91. package/dist/src/recovery/detectors.js +156 -0
  92. package/dist/src/recovery/engine.js +327 -0
  93. package/dist/src/recovery/index.js +20 -0
  94. package/dist/src/recovery/strategies.js +274 -0
  95. package/dist/src/recovery/types.js +20 -0
  96. package/dist/src/runtime/accessibility-adapter.js +430 -0
  97. package/dist/src/runtime/app-adapter.js +64 -0
  98. package/dist/src/runtime/applescript-adapter.js +305 -0
  99. package/dist/src/runtime/ax-role-map.js +96 -0
  100. package/dist/src/runtime/browser-adapter.js +52 -0
  101. package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
  102. package/dist/src/runtime/composite-adapter.js +221 -0
  103. package/dist/src/runtime/execution-contract.js +159 -0
  104. package/dist/src/runtime/executor.js +286 -0
  105. package/dist/src/runtime/locator-cache.js +50 -0
  106. package/dist/src/runtime/planning-loop.js +63 -0
  107. package/dist/src/runtime/service.js +432 -0
  108. package/dist/src/runtime/session-manager.js +63 -0
  109. package/dist/src/runtime/state-observer.js +121 -0
  110. package/dist/src/runtime/vision-adapter.js +225 -0
  111. package/dist/src/state/app-map-types.js +72 -0
  112. package/dist/src/state/app-map.js +1974 -0
  113. package/dist/src/state/entity-tracker.js +108 -0
  114. package/dist/src/state/fusion.js +96 -0
  115. package/dist/src/state/index.js +21 -0
  116. package/dist/src/state/ladder-generator.js +236 -0
  117. package/dist/src/state/persistence.js +156 -0
  118. package/dist/src/state/types.js +17 -0
  119. package/dist/src/state/world-model.js +1456 -0
  120. package/dist/src/supervisor/locks.js +186 -0
  121. package/dist/src/supervisor/supervisor.js +403 -0
  122. package/dist/src/supervisor/types.js +30 -0
  123. package/dist/src/test-mcp-protocol.js +154 -0
  124. package/dist/src/types.js +17 -0
  125. package/dist/src/util/atomic-write.js +133 -0
  126. package/dist/src/util/sanitize.js +146 -0
  127. package/dist-app-maps/com.figma.Desktop.json +959 -0
  128. package/dist-app-maps/com.hnc.Discord.json +1146 -0
  129. package/dist-app-maps/notion.id.json +2831 -0
  130. package/dist-playbooks/canva-screenhand-carousel.json +445 -0
  131. package/dist-playbooks/codex-desktop.json +76 -0
  132. package/dist-playbooks/competitor-research-stack.json +122 -0
  133. package/dist-playbooks/davinci-color-grade.json +153 -0
  134. package/dist-playbooks/davinci-edit-timeline.json +162 -0
  135. package/dist-playbooks/davinci-render.json +114 -0
  136. package/dist-playbooks/devto.json +52 -0
  137. package/dist-playbooks/discord.json +41 -0
  138. package/dist-playbooks/google-flow-create-project.json +59 -0
  139. package/dist-playbooks/google-flow-edit-image.json +90 -0
  140. package/dist-playbooks/google-flow-edit-video.json +90 -0
  141. package/dist-playbooks/google-flow-generate-image.json +68 -0
  142. package/dist-playbooks/google-flow-generate-video.json +191 -0
  143. package/dist-playbooks/google-flow-open-project.json +48 -0
  144. package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
  145. package/dist-playbooks/google-flow-search-assets.json +64 -0
  146. package/dist-playbooks/instagram.json +57 -0
  147. package/dist-playbooks/linkedin.json +52 -0
  148. package/dist-playbooks/n8n.json +43 -0
  149. package/dist-playbooks/reddit.json +52 -0
  150. package/dist-playbooks/threads.json +59 -0
  151. package/dist-playbooks/x-twitter.json +59 -0
  152. package/dist-playbooks/youtube.json +59 -0
  153. package/dist-references/canva.json +646 -0
  154. package/dist-references/codex-desktop.json +305 -0
  155. package/dist-references/davinci-resolve-keyboard.json +594 -0
  156. package/dist-references/davinci-resolve-menu-map.json +1139 -0
  157. package/dist-references/davinci-resolve-menus-batch1.json +116 -0
  158. package/dist-references/davinci-resolve-menus-batch2.json +372 -0
  159. package/dist-references/davinci-resolve-menus-batch3.json +330 -0
  160. package/dist-references/davinci-resolve-menus-batch4.json +297 -0
  161. package/dist-references/davinci-resolve-shortcuts.json +333 -0
  162. package/dist-references/devto.json +317 -0
  163. package/dist-references/discord.json +549 -0
  164. package/dist-references/figma.json +1186 -0
  165. package/dist-references/finder.json +146 -0
  166. package/dist-references/google-ads-transparency.json +95 -0
  167. package/dist-references/google-flow.json +649 -0
  168. package/dist-references/instagram.json +341 -0
  169. package/dist-references/linkedin.json +324 -0
  170. package/dist-references/meta-ad-library.json +86 -0
  171. package/dist-references/n8n.json +387 -0
  172. package/dist-references/notes.json +27 -0
  173. package/dist-references/notion.json +163 -0
  174. package/dist-references/reddit.json +341 -0
  175. package/dist-references/threads.json +337 -0
  176. package/dist-references/x-twitter.json +403 -0
  177. package/dist-references/youtube.json +373 -0
  178. package/native/macos-bridge/Package.swift +1 -0
  179. package/native/macos-bridge/Sources/AccessibilityBridge.swift +257 -36
  180. package/native/macos-bridge/Sources/AppManagement.swift +212 -2
  181. package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +348 -53
  182. package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
  183. package/native/macos-bridge/Sources/VisionBridge.swift +165 -7
  184. package/native/macos-bridge/Sources/main.swift +169 -16
  185. package/native/windows-bridge/Program.cs +5 -0
  186. package/native/windows-bridge/ScreenCapture.cs +124 -0
  187. package/package.json +29 -4
  188. package/scripts/postinstall.cjs +127 -0
  189. package/.claude/commands/automate.md +0 -28
  190. package/.claude/commands/debug-ui.md +0 -19
  191. package/.claude/commands/screenshot.md +0 -15
  192. package/.github/FUNDING.yml +0 -1
  193. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
  194. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  195. package/.mcp.json +0 -8
  196. package/DESKTOP_MCP_GUIDE.md +0 -92
  197. package/SECURITY.md +0 -44
  198. package/docs/architecture.md +0 -47
  199. package/install-skills.sh +0 -19
  200. package/mcp-bridge.ts +0 -271
  201. package/mcp-desktop.ts +0 -1221
  202. package/playbooks/instagram.json +0 -41
  203. package/playbooks/instagram_v2.json +0 -201
  204. package/playbooks/x_v1.json +0 -211
  205. package/scripts/devpost-live-loop.mjs +0 -421
  206. package/src/logging/timeline-logger.ts +0 -55
  207. package/src/mcp/server.ts +0 -449
  208. package/src/memory/recall.ts +0 -191
  209. package/src/memory/research.ts +0 -146
  210. package/src/memory/seeds.ts +0 -123
  211. package/src/memory/session.ts +0 -201
  212. package/src/memory/store.ts +0 -434
  213. package/src/memory/types.ts +0 -69
  214. package/src/native/bridge-client.ts +0 -239
  215. package/src/runtime/accessibility-adapter.ts +0 -487
  216. package/src/runtime/app-adapter.ts +0 -169
  217. package/src/runtime/applescript-adapter.ts +0 -376
  218. package/src/runtime/ax-role-map.ts +0 -102
  219. package/src/runtime/browser-adapter.ts +0 -129
  220. package/src/runtime/cdp-chrome-adapter.ts +0 -676
  221. package/src/runtime/composite-adapter.ts +0 -274
  222. package/src/runtime/executor.ts +0 -396
  223. package/src/runtime/planning-loop.ts +0 -81
  224. package/src/runtime/service.ts +0 -448
  225. package/src/runtime/session-manager.ts +0 -50
  226. package/src/runtime/state-observer.ts +0 -136
  227. package/src/runtime/vision-adapter.ts +0 -297
  228. package/src/types.ts +0 -297
  229. package/tests/bridge-client.test.ts +0 -176
  230. package/tests/browser-stealth.test.ts +0 -210
  231. package/tests/composite-adapter.test.ts +0 -64
  232. package/tests/mcp-server.test.ts +0 -151
  233. package/tests/memory-recall.test.ts +0 -339
  234. package/tests/memory-research.test.ts +0 -159
  235. package/tests/memory-seeds.test.ts +0 -120
  236. package/tests/memory-store.test.ts +0 -392
  237. package/tests/types.test.ts +0 -92
  238. package/tsconfig.check.json +0 -17
  239. package/tsconfig.json +0 -19
  240. package/vitest.config.ts +0 -8
  241. /package/{playbooks → dist-references}/devpost.json +0 -0
@@ -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
- });