groove-dev 0.27.111 → 0.27.112

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.
@@ -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
  }
@@ -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
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.111",
3
+ "version": "0.27.112",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.111",
3
+ "version": "0.27.112",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -4855,9 +4855,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
4855
4855
 
4856
4856
  app.get('/api/training/status', (req, res) => {
4857
4857
  let userId = null;
4858
- try { userId = ConsentManager.isCaptureEnabled() ? ConsentManager.getOrCreateUserId() : null; } catch (e) { /* no db yet */ }
4858
+ let optedIn = false;
4859
+ try {
4860
+ userId = ConsentManager.getOrCreateUserId();
4861
+ optedIn = ConsentManager.isCaptureEnabled();
4862
+ } catch (e) { /* */ }
4859
4863
  res.json({
4860
- optedIn: !!daemon.config.training_opt_in,
4864
+ optedIn,
4861
4865
  userId: userId ? userId.substring(0, 8) + '...' : null,
4862
4866
  captureActive: !!daemon.trajectoryCapture,
4863
4867
  sessionsCaptured: daemon.state.get('training_sessions_captured') || 0,
@@ -4869,52 +4873,23 @@ Keep responses concise. Help them think, don't lecture them about the system the
4869
4873
  const { enabled } = req.body;
4870
4874
  if (typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be boolean' });
4871
4875
 
4872
- const { saveConfig } = await import('./firstrun.js');
4873
-
4874
- if (enabled) {
4875
- try {
4876
- await import('better-sqlite3');
4877
- } catch (modErr) {
4878
- console.error('[training/opt-in] Native module load failed:', modErr);
4879
- return res.status(500).json({
4880
- error: 'Failed to enable data sharing',
4881
- detail: 'Native SQLite module (better-sqlite3) is not available. On remote instances, ensure build tools are installed (gcc, g++, make, python3) and run: npm rebuild better-sqlite3',
4882
- });
4883
- }
4884
- try {
4885
- const userId = ConsentManager.getOrCreateUserId();
4886
- const consent = new ConsentManager();
4887
- try {
4888
- consent.recordConsent(userId, true, '1.0');
4889
- } finally {
4890
- consent.close();
4891
- }
4892
- daemon.config.training_opt_in = true;
4893
- saveConfig(daemon.grooveDir, daemon.config);
4876
+ try {
4877
+ const userId = ConsentManager.getOrCreateUserId();
4878
+ const consent = new ConsentManager();
4879
+ if (enabled) {
4880
+ consent.recordConsent(userId, true, '1.0');
4894
4881
  await daemon._initTrajectoryCapture();
4895
4882
  daemon.state.set('training_enrolled_at', new Date().toISOString());
4896
- } catch (e) {
4897
- console.error('[training/opt-in] Failed to enable data sharing:', e);
4898
- daemon.config.training_opt_in = false;
4899
- saveConfig(daemon.grooveDir, daemon.config);
4900
- return res.status(500).json({ error: 'Failed to enable data sharing', detail: e.message });
4901
- }
4902
- } else {
4903
- daemon.config.training_opt_in = false;
4904
- saveConfig(daemon.grooveDir, daemon.config);
4905
- if (daemon.trajectoryCapture) {
4906
- try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4907
- daemon.trajectoryCapture = null;
4908
- }
4909
- try {
4910
- const userId = ConsentManager.getOrCreateUserId();
4911
- const consent = new ConsentManager();
4912
- try {
4913
- consent.revokeConsent(userId);
4914
- } finally {
4915
- consent.close();
4883
+ } else {
4884
+ consent.revokeConsent(userId);
4885
+ if (daemon.trajectoryCapture) {
4886
+ try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4887
+ daemon.trajectoryCapture = null;
4916
4888
  }
4917
- } catch (e) { /* no user_id yet */ }
4889
+ }
4890
+ } catch (e) {
4891
+ console.error('[training/opt-in] Failed to update data sharing:', e);
4892
+ return res.status(500).json({ error: 'Failed to update data sharing', detail: e.message });
4918
4893
  }
4919
4894
 
4920
4895
  daemon.broadcast({ type: 'training:status', data: { optedIn: enabled, captureActive: !!daemon.trajectoryCapture } });
@@ -4924,18 +4899,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
4924
4899
 
4925
4900
  app.post('/api/training/opt-in/delete', async (req, res) => {
4926
4901
  try {
4927
- daemon.config.training_opt_in = false;
4928
- const { saveConfig } = await import('./firstrun.js');
4929
- saveConfig(daemon.grooveDir, daemon.config);
4902
+ const userId = ConsentManager.getOrCreateUserId();
4903
+ const consent = new ConsentManager();
4904
+ consent.revokeConsent(userId);
4930
4905
  if (daemon.trajectoryCapture) {
4931
4906
  try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4932
4907
  daemon.trajectoryCapture = null;
4933
4908
  }
4934
- try {
4935
- const userId = ConsentManager.getOrCreateUserId();
4936
- const consent = new ConsentManager();
4937
- try { consent.revokeConsent(userId); } finally { consent.close(); }
4938
- } catch (e) { /* */ }
4939
4909
  daemon.broadcast({ type: 'training:status', data: { optedIn: false, captureActive: false } });
4940
4910
  if (daemon.audit) daemon.audit.log('training.delete', {});
4941
4911
  res.json({ ok: true, deleted: true });
@@ -4961,7 +4931,6 @@ Keep responses concise. Help them think, don't lecture them about the system the
4961
4931
  'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
4962
4932
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
4963
4933
  'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
4964
- 'training_opt_in',
4965
4934
  ];
4966
4935
  for (const key of Object.keys(req.body)) {
4967
4936
  if (!ALLOWED_KEYS.includes(key)) {
@@ -666,17 +666,15 @@ export class Daemon {
666
666
  }
667
667
 
668
668
  async _initTrajectoryCapture() {
669
- if (!this.config.training_opt_in) return;
670
669
  try {
671
- if (ConsentManager.isCaptureEnabled()) {
672
- const pkgPath = new URL('../package.json', import.meta.url);
673
- const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
674
- this.trajectoryCapture = new TrajectoryCapture({
675
- centralCommandUrl: process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai',
676
- grooveVersion: version,
677
- });
678
- this.trajectoryCapture.init();
679
- }
670
+ if (!ConsentManager.isCaptureEnabled()) return;
671
+ const pkgPath = new URL('../package.json', import.meta.url);
672
+ const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
673
+ this.trajectoryCapture = new TrajectoryCapture({
674
+ centralCommandUrl: process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai',
675
+ grooveVersion: version,
676
+ });
677
+ this.trajectoryCapture.init();
680
678
  } catch (e) {
681
679
  // Training capture is never critical
682
680
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.111",
3
+ "version": "0.27.112",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -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
  }
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.111",
3
+ "version": "0.27.112",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.111",
3
+ "version": "0.27.112",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.111",
3
+ "version": "0.27.112",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -4855,9 +4855,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
4855
4855
 
4856
4856
  app.get('/api/training/status', (req, res) => {
4857
4857
  let userId = null;
4858
- try { userId = ConsentManager.isCaptureEnabled() ? ConsentManager.getOrCreateUserId() : null; } catch (e) { /* no db yet */ }
4858
+ let optedIn = false;
4859
+ try {
4860
+ userId = ConsentManager.getOrCreateUserId();
4861
+ optedIn = ConsentManager.isCaptureEnabled();
4862
+ } catch (e) { /* */ }
4859
4863
  res.json({
4860
- optedIn: !!daemon.config.training_opt_in,
4864
+ optedIn,
4861
4865
  userId: userId ? userId.substring(0, 8) + '...' : null,
4862
4866
  captureActive: !!daemon.trajectoryCapture,
4863
4867
  sessionsCaptured: daemon.state.get('training_sessions_captured') || 0,
@@ -4869,52 +4873,23 @@ Keep responses concise. Help them think, don't lecture them about the system the
4869
4873
  const { enabled } = req.body;
4870
4874
  if (typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be boolean' });
4871
4875
 
4872
- const { saveConfig } = await import('./firstrun.js');
4873
-
4874
- if (enabled) {
4875
- try {
4876
- await import('better-sqlite3');
4877
- } catch (modErr) {
4878
- console.error('[training/opt-in] Native module load failed:', modErr);
4879
- return res.status(500).json({
4880
- error: 'Failed to enable data sharing',
4881
- detail: 'Native SQLite module (better-sqlite3) is not available. On remote instances, ensure build tools are installed (gcc, g++, make, python3) and run: npm rebuild better-sqlite3',
4882
- });
4883
- }
4884
- try {
4885
- const userId = ConsentManager.getOrCreateUserId();
4886
- const consent = new ConsentManager();
4887
- try {
4888
- consent.recordConsent(userId, true, '1.0');
4889
- } finally {
4890
- consent.close();
4891
- }
4892
- daemon.config.training_opt_in = true;
4893
- saveConfig(daemon.grooveDir, daemon.config);
4876
+ try {
4877
+ const userId = ConsentManager.getOrCreateUserId();
4878
+ const consent = new ConsentManager();
4879
+ if (enabled) {
4880
+ consent.recordConsent(userId, true, '1.0');
4894
4881
  await daemon._initTrajectoryCapture();
4895
4882
  daemon.state.set('training_enrolled_at', new Date().toISOString());
4896
- } catch (e) {
4897
- console.error('[training/opt-in] Failed to enable data sharing:', e);
4898
- daemon.config.training_opt_in = false;
4899
- saveConfig(daemon.grooveDir, daemon.config);
4900
- return res.status(500).json({ error: 'Failed to enable data sharing', detail: e.message });
4901
- }
4902
- } else {
4903
- daemon.config.training_opt_in = false;
4904
- saveConfig(daemon.grooveDir, daemon.config);
4905
- if (daemon.trajectoryCapture) {
4906
- try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4907
- daemon.trajectoryCapture = null;
4908
- }
4909
- try {
4910
- const userId = ConsentManager.getOrCreateUserId();
4911
- const consent = new ConsentManager();
4912
- try {
4913
- consent.revokeConsent(userId);
4914
- } finally {
4915
- consent.close();
4883
+ } else {
4884
+ consent.revokeConsent(userId);
4885
+ if (daemon.trajectoryCapture) {
4886
+ try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4887
+ daemon.trajectoryCapture = null;
4916
4888
  }
4917
- } catch (e) { /* no user_id yet */ }
4889
+ }
4890
+ } catch (e) {
4891
+ console.error('[training/opt-in] Failed to update data sharing:', e);
4892
+ return res.status(500).json({ error: 'Failed to update data sharing', detail: e.message });
4918
4893
  }
4919
4894
 
4920
4895
  daemon.broadcast({ type: 'training:status', data: { optedIn: enabled, captureActive: !!daemon.trajectoryCapture } });
@@ -4924,18 +4899,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
4924
4899
 
4925
4900
  app.post('/api/training/opt-in/delete', async (req, res) => {
4926
4901
  try {
4927
- daemon.config.training_opt_in = false;
4928
- const { saveConfig } = await import('./firstrun.js');
4929
- saveConfig(daemon.grooveDir, daemon.config);
4902
+ const userId = ConsentManager.getOrCreateUserId();
4903
+ const consent = new ConsentManager();
4904
+ consent.revokeConsent(userId);
4930
4905
  if (daemon.trajectoryCapture) {
4931
4906
  try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
4932
4907
  daemon.trajectoryCapture = null;
4933
4908
  }
4934
- try {
4935
- const userId = ConsentManager.getOrCreateUserId();
4936
- const consent = new ConsentManager();
4937
- try { consent.revokeConsent(userId); } finally { consent.close(); }
4938
- } catch (e) { /* */ }
4939
4909
  daemon.broadcast({ type: 'training:status', data: { optedIn: false, captureActive: false } });
4940
4910
  if (daemon.audit) daemon.audit.log('training.delete', {});
4941
4911
  res.json({ ok: true, deleted: true });
@@ -4961,7 +4931,6 @@ Keep responses concise. Help them think, don't lecture them about the system the
4961
4931
  'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
4962
4932
  'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
4963
4933
  'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
4964
- 'training_opt_in',
4965
4934
  ];
4966
4935
  for (const key of Object.keys(req.body)) {
4967
4936
  if (!ALLOWED_KEYS.includes(key)) {
@@ -666,17 +666,15 @@ export class Daemon {
666
666
  }
667
667
 
668
668
  async _initTrajectoryCapture() {
669
- if (!this.config.training_opt_in) return;
670
669
  try {
671
- if (ConsentManager.isCaptureEnabled()) {
672
- const pkgPath = new URL('../package.json', import.meta.url);
673
- const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
674
- this.trajectoryCapture = new TrajectoryCapture({
675
- centralCommandUrl: process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai',
676
- grooveVersion: version,
677
- });
678
- this.trajectoryCapture.init();
679
- }
670
+ if (!ConsentManager.isCaptureEnabled()) return;
671
+ const pkgPath = new URL('../package.json', import.meta.url);
672
+ const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
673
+ this.trajectoryCapture = new TrajectoryCapture({
674
+ centralCommandUrl: process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai',
675
+ grooveVersion: version,
676
+ });
677
+ this.trajectoryCapture.init();
680
678
  } catch (e) {
681
679
  // Training capture is never critical
682
680
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.111",
3
+ "version": "0.27.112",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",