groove-dev 0.27.111 → 0.27.113

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 (54) hide show
  1. package/TRAINING_DATA_v3.md +11 -0
  2. package/codex-test/offroad-nitro-racer/dist/assets/index-CuvdKK6U.js +44 -0
  3. package/codex-test/offroad-nitro-racer/dist/assets/index-DvHn2Thu.css +1 -0
  4. package/codex-test/offroad-nitro-racer/dist/index.html +23 -0
  5. package/codex-test/offroad-nitro-racer/index.html +21 -0
  6. package/codex-test/offroad-nitro-racer/package-lock.json +841 -0
  7. package/codex-test/offroad-nitro-racer/package.json +15 -0
  8. package/codex-test/offroad-nitro-racer/src/game/AI.ts +28 -0
  9. package/codex-test/offroad-nitro-racer/src/game/Audio.ts +63 -0
  10. package/codex-test/offroad-nitro-racer/src/game/Car.ts +247 -0
  11. package/codex-test/offroad-nitro-racer/src/game/Effects.ts +62 -0
  12. package/codex-test/offroad-nitro-racer/src/game/Game.ts +229 -0
  13. package/codex-test/offroad-nitro-racer/src/game/Input.ts +45 -0
  14. package/codex-test/offroad-nitro-racer/src/game/Renderer.ts +224 -0
  15. package/codex-test/offroad-nitro-racer/src/game/Track.ts +158 -0
  16. package/codex-test/offroad-nitro-racer/src/game/UI.ts +96 -0
  17. package/codex-test/offroad-nitro-racer/src/game/math.ts +42 -0
  18. package/codex-test/offroad-nitro-racer/src/main.ts +24 -0
  19. package/codex-test/offroad-nitro-racer/src/style.css +291 -0
  20. package/codex-test/offroad-nitro-racer/src/vite-env.d.ts +1 -0
  21. package/codex-test/offroad-nitro-racer/tsconfig.json +18 -0
  22. package/codex-test/offroad-nitro-racer/vite.config.ts +7 -0
  23. package/moe-training/client/consent.js +47 -55
  24. package/moe-training/client/parsers/codex.js +3 -3
  25. package/moe-training/client/parsers/gemini.js +2 -2
  26. package/moe-training/client/step-classifier.js +2 -2
  27. package/moe-training/test/client/consent.test.js +23 -20
  28. package/moe-training/test/client/step-classifier.test.js +63 -7
  29. package/node_modules/@groove-dev/cli/package.json +1 -1
  30. package/node_modules/@groove-dev/daemon/package.json +1 -1
  31. package/node_modules/@groove-dev/daemon/src/api.js +74 -69
  32. package/node_modules/@groove-dev/daemon/src/index.js +30 -18
  33. package/node_modules/@groove-dev/gui/dist/assets/{index-CHu5w3i3.js → index-BYh6iHqL.js} +3 -3
  34. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  35. package/node_modules/@groove-dev/gui/package.json +1 -1
  36. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +3 -1
  37. package/node_modules/@groove-dev/gui/src/stores/groove.js +15 -0
  38. package/node_modules/moe-training/client/consent.js +47 -55
  39. package/node_modules/moe-training/client/parsers/codex.js +3 -3
  40. package/node_modules/moe-training/client/parsers/gemini.js +2 -2
  41. package/node_modules/moe-training/client/step-classifier.js +2 -2
  42. package/node_modules/moe-training/test/client/consent.test.js +23 -20
  43. package/node_modules/moe-training/test/client/step-classifier.test.js +63 -7
  44. package/package.json +1 -1
  45. package/packages/cli/package.json +1 -1
  46. package/packages/daemon/package.json +1 -1
  47. package/packages/daemon/src/api.js +74 -69
  48. package/packages/daemon/src/index.js +30 -18
  49. package/packages/gui/dist/assets/{index-CHu5w3i3.js → index-BYh6iHqL.js} +3 -3
  50. package/packages/gui/dist/index.html +1 -1
  51. package/packages/gui/package.json +1 -1
  52. package/packages/gui/src/components/preview/preview-workspace.jsx +3 -1
  53. package/packages/gui/src/stores/groove.js +15 -0
  54. package/TRAINING_DATA_v2.md +0 -9
@@ -0,0 +1,291 @@
1
+ :root {
2
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
3
+ color: #fff7ed;
4
+ background: #140c08;
5
+ font-synthesis: none;
6
+ text-rendering: optimizeLegibility;
7
+ -webkit-font-smoothing: antialiased;
8
+ --amber: #f6b23c;
9
+ --cyan: #32f5ff;
10
+ --red: #f43f5e;
11
+ --panel: rgba(30, 18, 12, 0.82);
12
+ --panel-border: rgba(255, 240, 210, 0.22);
13
+ }
14
+
15
+ * {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ html,
20
+ body,
21
+ #app {
22
+ width: 100%;
23
+ height: 100%;
24
+ margin: 0;
25
+ overflow: hidden;
26
+ }
27
+
28
+ body {
29
+ min-width: 320px;
30
+ }
31
+
32
+ button {
33
+ font: inherit;
34
+ }
35
+
36
+ .shell {
37
+ position: relative;
38
+ width: 100vw;
39
+ height: 100vh;
40
+ isolation: isolate;
41
+ background: radial-gradient(circle at 50% 50%, #a7642c 0%, #3b2115 72%);
42
+ }
43
+
44
+ canvas {
45
+ position: absolute;
46
+ inset: 0;
47
+ width: 100%;
48
+ height: 100%;
49
+ image-rendering: auto;
50
+ }
51
+
52
+ .overlay {
53
+ position: absolute;
54
+ inset: 0;
55
+ z-index: 2;
56
+ pointer-events: none;
57
+ }
58
+
59
+ .overlay--active {
60
+ display: grid;
61
+ place-items: center;
62
+ padding: 24px;
63
+ pointer-events: auto;
64
+ background:
65
+ radial-gradient(circle at center, rgba(255, 170, 70, 0.12), transparent 38%),
66
+ linear-gradient(135deg, rgba(13, 8, 5, 0.52), rgba(13, 8, 5, 0.78));
67
+ backdrop-filter: blur(3px);
68
+ }
69
+
70
+ .panel {
71
+ width: min(620px, 94vw);
72
+ border: 2px solid var(--panel-border);
73
+ border-radius: 28px;
74
+ padding: 28px;
75
+ background: var(--panel);
76
+ box-shadow: 0 30px 90px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.18);
77
+ text-align: center;
78
+ }
79
+
80
+ .panel.hero {
81
+ transform: rotate(-1deg);
82
+ }
83
+
84
+ .panel.compact {
85
+ width: min(420px, 92vw);
86
+ }
87
+
88
+ .eyebrow {
89
+ margin: 0 0 10px;
90
+ color: var(--cyan);
91
+ font-size: 12px;
92
+ font-weight: 900;
93
+ letter-spacing: 0.22em;
94
+ text-transform: uppercase;
95
+ }
96
+
97
+ h1,
98
+ h2 {
99
+ margin: 0;
100
+ line-height: 0.9;
101
+ text-transform: uppercase;
102
+ text-shadow: 0 5px 0 #5a230f, 0 12px 30px rgba(0, 0, 0, 0.55);
103
+ }
104
+
105
+ h1 {
106
+ font-size: clamp(48px, 9vw, 92px);
107
+ }
108
+
109
+ h2 {
110
+ font-size: clamp(38px, 7vw, 64px);
111
+ }
112
+
113
+ .lede,
114
+ .panel p:not(.eyebrow) {
115
+ color: #ffe7c5;
116
+ font-size: 16px;
117
+ line-height: 1.6;
118
+ }
119
+
120
+ .controls-grid {
121
+ display: grid;
122
+ grid-template-columns: 1fr 1.2fr;
123
+ gap: 10px 16px;
124
+ margin: 24px auto;
125
+ max-width: 390px;
126
+ text-align: left;
127
+ }
128
+
129
+ .controls-grid span,
130
+ .result-row span {
131
+ color: #ffd8a3;
132
+ font-size: 12px;
133
+ font-weight: 800;
134
+ letter-spacing: 0.12em;
135
+ text-transform: uppercase;
136
+ }
137
+
138
+ .controls-grid strong,
139
+ .result-row strong {
140
+ color: #fff;
141
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
142
+ }
143
+
144
+ .primary {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ min-height: 48px;
149
+ padding: 0 22px;
150
+ border: 0;
151
+ border-radius: 999px;
152
+ color: #241006;
153
+ background: linear-gradient(180deg, #ffe08a, var(--amber));
154
+ box-shadow: 0 7px 0 #8b4314, 0 18px 30px rgba(0, 0, 0, 0.3);
155
+ font-weight: 1000;
156
+ letter-spacing: 0.06em;
157
+ text-transform: uppercase;
158
+ }
159
+
160
+ .result-row {
161
+ display: flex;
162
+ justify-content: space-between;
163
+ gap: 20px;
164
+ margin: 16px auto;
165
+ max-width: 340px;
166
+ padding: 14px 16px;
167
+ border: 1px solid rgba(255, 255, 255, 0.14);
168
+ border-radius: 16px;
169
+ background: rgba(0, 0, 0, 0.2);
170
+ }
171
+
172
+ .hud {
173
+ position: absolute;
174
+ left: 18px;
175
+ right: 18px;
176
+ display: flex;
177
+ gap: 10px;
178
+ align-items: center;
179
+ justify-content: center;
180
+ }
181
+
182
+ .hud--top {
183
+ top: 16px;
184
+ }
185
+
186
+ .hud-card {
187
+ min-width: 112px;
188
+ padding: 10px 12px;
189
+ border: 1px solid rgba(255, 235, 204, 0.24);
190
+ border-radius: 16px;
191
+ background: rgba(21, 12, 8, 0.68);
192
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.22);
193
+ backdrop-filter: blur(8px);
194
+ }
195
+
196
+ .hud-card span {
197
+ display: block;
198
+ color: #ffd8a3;
199
+ font-size: 10px;
200
+ font-weight: 900;
201
+ letter-spacing: 0.14em;
202
+ text-transform: uppercase;
203
+ }
204
+
205
+ .hud-card strong {
206
+ display: block;
207
+ margin-top: 2px;
208
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
209
+ font-size: clamp(16px, 3vw, 22px);
210
+ }
211
+
212
+ .nitro-stack {
213
+ position: absolute;
214
+ right: 22px;
215
+ bottom: 22px;
216
+ display: flex;
217
+ gap: 8px;
218
+ padding: 10px;
219
+ border-radius: 999px;
220
+ background: rgba(14, 10, 8, 0.68);
221
+ border: 1px solid rgba(255, 255, 255, 0.16);
222
+ }
223
+
224
+ .nitro-stack i {
225
+ width: 48px;
226
+ height: 16px;
227
+ border: 2px solid rgba(255, 255, 255, 0.35);
228
+ border-radius: 999px;
229
+ background: rgba(255, 255, 255, 0.12);
230
+ }
231
+
232
+ .nitro-stack i.full {
233
+ border-color: #a5f3fc;
234
+ background: linear-gradient(90deg, #0891b2, var(--cyan));
235
+ box-shadow: 0 0 18px rgba(50, 245, 255, 0.75);
236
+ }
237
+
238
+ .countdown {
239
+ position: absolute;
240
+ inset: 0;
241
+ display: grid;
242
+ place-items: center;
243
+ color: #fff;
244
+ font-size: clamp(84px, 20vw, 210px);
245
+ font-weight: 1000;
246
+ text-shadow: 0 10px 0 #6c2c10, 0 0 60px rgba(255, 181, 67, 0.8);
247
+ animation: pop 0.45s ease both;
248
+ }
249
+
250
+ .toast {
251
+ position: absolute;
252
+ left: 50%;
253
+ bottom: 34px;
254
+ transform: translateX(-50%);
255
+ padding: 12px 18px;
256
+ border-radius: 999px;
257
+ color: #261208;
258
+ background: #ffe08a;
259
+ box-shadow: 0 8px 0 #8b4314;
260
+ font-weight: 1000;
261
+ text-transform: uppercase;
262
+ }
263
+
264
+ @keyframes pop {
265
+ from {
266
+ transform: scale(0.7) rotate(-4deg);
267
+ opacity: 0;
268
+ }
269
+ to {
270
+ transform: scale(1) rotate(0deg);
271
+ opacity: 1;
272
+ }
273
+ }
274
+
275
+ @media (max-width: 720px) {
276
+ .hud {
277
+ left: 8px;
278
+ right: 8px;
279
+ flex-wrap: wrap;
280
+ justify-content: flex-start;
281
+ }
282
+
283
+ .hud-card {
284
+ min-width: calc(50% - 6px);
285
+ padding: 8px 10px;
286
+ }
287
+
288
+ .nitro-stack i {
289
+ width: 34px;
290
+ }
291
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "module": "ESNext",
6
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "moduleDetection": "force",
12
+ "noEmit": true,
13
+ "strict": true,
14
+ "noUnusedLocals": false,
15
+ "noUnusedParameters": false
16
+ },
17
+ "include": ["src"]
18
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vite';
2
+
3
+ const previewBase = process.env.GROOVE_PREVIEW_BASE || './';
4
+
5
+ export default defineConfig({
6
+ base: previewBase,
7
+ });
@@ -1,44 +1,46 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
- import Database from 'better-sqlite3';
4
3
  import { randomUUID } from 'node:crypto';
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
5
  import { join } from 'node:path';
7
6
  import { homedir } from 'node:os';
8
7
  import { CURRENT_CONSENT_VERSION } from '../shared/constants.js';
9
8
 
9
+ function ensureDir(filePath) {
10
+ const dir = filePath.replace(/[/\\][^/\\]+$/, '');
11
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
12
+ }
13
+
14
+ function readJSON(filePath) {
15
+ if (!existsSync(filePath)) return null;
16
+ try { return JSON.parse(readFileSync(filePath, 'utf-8')); } catch { return null; }
17
+ }
18
+
19
+ function writeJSON(filePath, data) {
20
+ ensureDir(filePath);
21
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
22
+ }
23
+
10
24
  export class ConsentManager {
11
- constructor(dbPath) {
12
- this._dbPath = dbPath || join(homedir(), '.groove', 'consent.db');
13
- const dir = this._dbPath.replace(/[/\\][^/\\]+$/, '');
14
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
15
- this._db = new Database(this._dbPath);
16
- this._db.pragma('journal_mode = WAL');
17
- this._db.exec(`
18
- CREATE TABLE IF NOT EXISTS consent_history (
19
- id INTEGER PRIMARY KEY AUTOINCREMENT,
20
- user_id TEXT NOT NULL,
21
- opted_in INTEGER NOT NULL,
22
- consent_version TEXT NOT NULL,
23
- metadata TEXT,
24
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
25
- )
26
- `);
25
+ constructor(consentPath) {
26
+ this._path = consentPath || join(homedir(), '.groove', 'consent.json');
27
27
  }
28
28
 
29
- recordConsent(userId, optedIn, consentVersion, metadata) {
30
- this._db.prepare(
31
- 'INSERT INTO consent_history (user_id, opted_in, consent_version, metadata) VALUES (?, ?, ?, ?)'
32
- ).run(userId, optedIn ? 1 : 0, consentVersion, metadata ? JSON.stringify(metadata) : null);
29
+ recordConsent(userId, optedIn, consentVersion) {
30
+ const data = {
31
+ user_id: userId,
32
+ opted_in: !!optedIn,
33
+ consent_version: consentVersion,
34
+ updated_at: new Date().toISOString(),
35
+ };
36
+ writeJSON(this._path, data);
33
37
  }
34
38
 
35
39
  isOptedIn(userId) {
36
- const row = this._db.prepare(
37
- 'SELECT opted_in, consent_version FROM consent_history WHERE user_id = ? ORDER BY id DESC LIMIT 1'
38
- ).get(userId);
39
- if (!row) return false;
40
- if (row.consent_version !== CURRENT_CONSENT_VERSION) return false;
41
- return row.opted_in === 1;
40
+ const data = readJSON(this._path);
41
+ if (!data) return false;
42
+ if (data.consent_version !== CURRENT_CONSENT_VERSION) return false;
43
+ return data.opted_in === true;
42
44
  }
43
45
 
44
46
  revokeConsent(userId) {
@@ -46,34 +48,28 @@ export class ConsentManager {
46
48
  }
47
49
 
48
50
  getOptedInCount() {
49
- const row = this._db.prepare(`
50
- SELECT COUNT(DISTINCT user_id) as cnt FROM consent_history ch1
51
- WHERE opted_in = 1
52
- AND consent_version = ?
53
- AND id = (SELECT MAX(id) FROM consent_history ch2 WHERE ch2.user_id = ch1.user_id)
54
- `).get(CURRENT_CONSENT_VERSION);
55
- return row?.cnt || 0;
51
+ const data = readJSON(this._path);
52
+ if (!data || !data.opted_in || data.consent_version !== CURRENT_CONSENT_VERSION) return 0;
53
+ return 1;
56
54
  }
57
55
 
58
56
  getConsentHistory(userId) {
59
- const rows = this._db.prepare(
60
- 'SELECT * FROM consent_history WHERE user_id = ? ORDER BY id ASC'
61
- ).all(userId);
62
- return rows.map((r) => ({
63
- ...r,
64
- opted_in: r.opted_in === 1,
65
- metadata: r.metadata ? JSON.parse(r.metadata) : null,
66
- }));
57
+ const data = readJSON(this._path);
58
+ if (!data) return [];
59
+ return [{
60
+ user_id: data.user_id,
61
+ opted_in: data.opted_in,
62
+ consent_version: data.consent_version,
63
+ created_at: data.updated_at,
64
+ metadata: null,
65
+ }];
67
66
  }
68
67
 
69
- close() {
70
- this._db.close();
71
- }
68
+ close() {}
72
69
 
73
70
  static getOrCreateUserId(userIdPath) {
74
71
  const filePath = userIdPath || join(homedir(), '.groove', 'user_id');
75
- const dir = filePath.replace(/[/\\][^/\\]+$/, '');
76
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
72
+ ensureDir(filePath);
77
73
  if (existsSync(filePath)) {
78
74
  return readFileSync(filePath, 'utf-8').trim();
79
75
  }
@@ -82,15 +78,11 @@ export class ConsentManager {
82
78
  return uid;
83
79
  }
84
80
 
85
- static isCaptureEnabled(userIdPath, dbPath) {
81
+ static isCaptureEnabled(userIdPath, consentPath) {
86
82
  const filePath = userIdPath || join(homedir(), '.groove', 'user_id');
87
83
  if (!existsSync(filePath)) return false;
84
+ const manager = new ConsentManager(consentPath);
88
85
  const userId = readFileSync(filePath, 'utf-8').trim();
89
- const manager = new ConsentManager(dbPath);
90
- try {
91
- return manager.isOptedIn(userId);
92
- } finally {
93
- manager.close();
94
- }
86
+ return manager.isOptedIn(userId);
95
87
  }
96
88
  }
@@ -57,15 +57,15 @@ export class CodexParser {
57
57
  if (item.type === 'command_execution') {
58
58
  const rawOutput = item.aggregated_output || '';
59
59
  if (item.exit_code !== 0) {
60
- return { type: 'error', content: rawOutput.slice(0, 2000) || `Exit code: ${item.exit_code}` };
60
+ return { type: 'error', is_error: true, content: rawOutput.slice(0, 2000) || `Exit code: ${item.exit_code}` };
61
61
  }
62
62
  const obs = truncateObservation(rawOutput);
63
- return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
63
+ return { type: 'observation', is_error: false, content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
64
64
  }
65
65
  if (item.type === 'file_edit' || item.type === 'file_write' || item.type === 'file_read') {
66
66
  const rawOutput = item.output || item.content || '';
67
67
  const obs = truncateObservation(rawOutput);
68
- return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
68
+ return { type: 'observation', is_error: false, content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
69
69
  }
70
70
  return null;
71
71
  }
@@ -64,11 +64,11 @@ export class GeminiParser {
64
64
  const contentParts = Array.isArray(rawContent) ? rawContent : (typeof rawContent === 'string' ? [{ text: rawContent }] : rawContent ? [rawContent] : []);
65
65
  const rawText = contentParts.map((p) => p.text || '').join('');
66
66
  const obs = truncateObservation(rawText);
67
- return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
67
+ return { type: 'observation', is_error: false, content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
68
68
  }
69
69
 
70
70
  case 'error': {
71
- return { type: 'error', content: jsonEvent.message || 'Unknown error' };
71
+ return { type: 'error', is_error: true, content: jsonEvent.message || 'Unknown error' };
72
72
  }
73
73
 
74
74
  case 'agent_end': {
@@ -1,6 +1,6 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
- const ERROR_SIGNAL_RE = /\b(?:error|Error|ERROR|exception|Exception|EXCEPTION|failed|FAILED|exit code [1-9]|ENOENT|EACCES|EPERM|TypeError|ReferenceError|SyntaxError|Cannot find|Module not found|Command failed|non-zero exit)\b/;
3
+ const ERROR_SIGNAL_RE = /(?:^|\n)\s*(?:error[ :\[]\S|Error[ :\[]\S|ERROR[ :\[]\S)|(?:exit code [1-9]|non-zero exit|ENOENT|EACCES|EPERM|Command failed)|(?:(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError):\s)|(?:Cannot find module|Module not found|ModuleNotFoundError|ImportError:)|(?:FATAL|PANIC|Traceback \(most recent)|(?:error TS\d{4}:)/;
4
4
  const FIX_SIGNAL_RE = /\b(?:fix|correcting|I see the issue|let me fix|the (?:issue|problem|bug) (?:is|was)|instead I should|my mistake)\b/i;
5
5
 
6
6
  const CORRECTION_RE = /\b(?:no[,. ](?:that|not|don't|wrong)|that'?s (?:not|wrong|incorrect)|don'?t do that|stop (?:doing|that)|instead (?:of|do)|undo|revert|go back|try (?:again|differently)|you (?:broke|missed|forgot))\b/i;
@@ -54,7 +54,7 @@ export class StepClassifier {
54
54
 
55
55
  const content = step.content || '';
56
56
 
57
- if ((step.type === 'action' || step.type === 'observation') && step.is_error !== false && ERROR_SIGNAL_RE.test(content)) {
57
+ if (step.type === 'observation' && step.is_error !== false && ERROR_SIGNAL_RE.test(content)) {
58
58
  step.type = 'error';
59
59
  }
60
60
 
@@ -8,12 +8,12 @@ import { tmpdir } from 'node:os';
8
8
  import { ConsentManager } from '../../client/consent.js';
9
9
 
10
10
  describe('ConsentManager', () => {
11
- let tmpDir, dbPath, manager;
11
+ let tmpDir, consentPath, manager;
12
12
 
13
13
  beforeEach(() => {
14
14
  tmpDir = mkdtempSync(join(tmpdir(), 'consent-test-'));
15
- dbPath = join(tmpDir, 'consent.db');
16
- manager = new ConsentManager(dbPath);
15
+ consentPath = join(tmpDir, 'consent.json');
16
+ manager = new ConsentManager(consentPath);
17
17
  });
18
18
 
19
19
  afterEach(() => {
@@ -43,19 +43,24 @@ describe('ConsentManager', () => {
43
43
 
44
44
  it('getOptedInCount counts correctly', () => {
45
45
  manager.recordConsent('user1', true, '1.0');
46
- manager.recordConsent('user2', true, '1.0');
47
- manager.recordConsent('user3', false, '1.0');
48
- assert.equal(manager.getOptedInCount(), 2);
46
+ assert.equal(manager.getOptedInCount(), 1);
49
47
  });
50
48
 
51
- it('getConsentHistory returns all records', () => {
52
- manager.recordConsent('user1', true, '1.0', { source: 'ui' });
53
- manager.revokeConsent('user1');
49
+ it('getConsentHistory returns current state', () => {
50
+ manager.recordConsent('user1', true, '1.0');
54
51
  const history = manager.getConsentHistory('user1');
55
- assert.equal(history.length, 2);
52
+ assert.equal(history.length, 1);
56
53
  assert.equal(history[0].opted_in, true);
57
- assert.deepEqual(history[0].metadata, { source: 'ui' });
58
- assert.equal(history[1].opted_in, false);
54
+ });
55
+
56
+ it('consent.json is written with 0o600 permissions', () => {
57
+ manager.recordConsent('user1', true, '1.0');
58
+ assert.ok(existsSync(consentPath));
59
+ const data = JSON.parse(readFileSync(consentPath, 'utf-8'));
60
+ assert.equal(data.opted_in, true);
61
+ assert.equal(data.consent_version, '1.0');
62
+ assert.equal(data.user_id, 'user1');
63
+ assert.ok(data.updated_at);
59
64
  });
60
65
  });
61
66
 
@@ -96,26 +101,24 @@ describe('ConsentManager.isCaptureEnabled', () => {
96
101
  });
97
102
 
98
103
  it('returns false when no user_id file exists', () => {
99
- const result = ConsentManager.isCaptureEnabled(join(tmpDir, 'no_file'), join(tmpDir, 'c.db'));
104
+ const result = ConsentManager.isCaptureEnabled(join(tmpDir, 'no_file'), join(tmpDir, 'consent.json'));
100
105
  assert.equal(result, false);
101
106
  });
102
107
 
103
108
  it('returns false when user not opted in', () => {
104
109
  const uidPath = join(tmpDir, 'user_id');
105
- const uid = ConsentManager.getOrCreateUserId(uidPath);
106
- const dbPath = join(tmpDir, 'consent.db');
107
- const result = ConsentManager.isCaptureEnabled(uidPath, dbPath);
110
+ ConsentManager.getOrCreateUserId(uidPath);
111
+ const result = ConsentManager.isCaptureEnabled(uidPath, join(tmpDir, 'consent.json'));
108
112
  assert.equal(result, false);
109
113
  });
110
114
 
111
115
  it('returns true when user is opted in', () => {
112
116
  const uidPath = join(tmpDir, 'user_id');
113
117
  const uid = ConsentManager.getOrCreateUserId(uidPath);
114
- const dbPath = join(tmpDir, 'consent.db');
115
- const mgr = new ConsentManager(dbPath);
118
+ const consentPath = join(tmpDir, 'consent.json');
119
+ const mgr = new ConsentManager(consentPath);
116
120
  mgr.recordConsent(uid, true, '1.0');
117
- mgr.close();
118
- const result = ConsentManager.isCaptureEnabled(uidPath, dbPath);
121
+ const result = ConsentManager.isCaptureEnabled(uidPath, consentPath);
119
122
  assert.equal(result, true);
120
123
  });
121
124
  });