groove-dev 0.27.107 → 0.27.108

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 (53) hide show
  1. package/TRAINING_DATA.md +9 -0
  2. package/moe-training/client/envelope-builder.js +5 -0
  3. package/moe-training/client/scrubber.js +1 -1
  4. package/moe-training/client/step-classifier.js +22 -6
  5. package/moe-training/client/trajectory-capture.js +15 -4
  6. package/moe-training/shared/constants.js +2 -0
  7. package/moe-training/shared/envelope-schema.js +1 -1
  8. package/moe-training/test/client/envelope-builder.test.js +32 -0
  9. package/moe-training/test/client/scrubber.test.js +13 -0
  10. package/moe-training/test/client/step-classifier.test.js +96 -7
  11. package/moe-training/test/client/trajectory-capture.test.js +53 -6
  12. package/node_modules/@groove-dev/cli/package.json +1 -1
  13. package/node_modules/@groove-dev/daemon/package.json +1 -1
  14. package/node_modules/@groove-dev/daemon/src/api.js +29 -2
  15. package/node_modules/@groove-dev/daemon/src/process.js +5 -5
  16. package/node_modules/@groove-dev/gui/dist/assets/{index-DkAGIluW.js → index-CEgtSfbG.js} +1748 -1745
  17. package/node_modules/@groove-dev/gui/dist/assets/{index-QwgLRN8B.css → index-_3cJS_UG.css} +1 -1
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/package.json +1 -1
  20. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +2 -1
  21. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +9 -3
  22. package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +2 -2
  23. package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +14 -2
  24. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +9 -0
  25. package/node_modules/@groove-dev/gui/src/stores/groove.js +10 -0
  26. package/node_modules/@groove-dev/gui/src/views/federation.jsx +56 -15
  27. package/node_modules/moe-training/client/envelope-builder.js +5 -0
  28. package/node_modules/moe-training/client/scrubber.js +1 -1
  29. package/node_modules/moe-training/client/step-classifier.js +22 -6
  30. package/node_modules/moe-training/client/trajectory-capture.js +15 -4
  31. package/node_modules/moe-training/shared/constants.js +2 -0
  32. package/node_modules/moe-training/shared/envelope-schema.js +1 -1
  33. package/node_modules/moe-training/test/client/envelope-builder.test.js +32 -0
  34. package/node_modules/moe-training/test/client/scrubber.test.js +13 -0
  35. package/node_modules/moe-training/test/client/step-classifier.test.js +96 -7
  36. package/node_modules/moe-training/test/client/trajectory-capture.test.js +53 -6
  37. package/package.json +1 -1
  38. package/packages/cli/package.json +1 -1
  39. package/packages/daemon/package.json +1 -1
  40. package/packages/daemon/src/api.js +29 -2
  41. package/packages/daemon/src/process.js +5 -5
  42. package/packages/gui/dist/assets/{index-DkAGIluW.js → index-CEgtSfbG.js} +1748 -1745
  43. package/packages/gui/dist/assets/{index-QwgLRN8B.css → index-_3cJS_UG.css} +1 -1
  44. package/packages/gui/dist/index.html +2 -2
  45. package/packages/gui/package.json +1 -1
  46. package/packages/gui/src/components/layout/command-palette.jsx +2 -1
  47. package/packages/gui/src/components/layout/status-bar.jsx +9 -3
  48. package/packages/gui/src/components/settings/federation-panel.jsx +2 -2
  49. package/packages/gui/src/components/settings/federation-peers.jsx +14 -2
  50. package/packages/gui/src/components/settings/quick-connect.jsx +9 -0
  51. package/packages/gui/src/stores/groove.js +10 -0
  52. package/packages/gui/src/views/federation.jsx +56 -15
  53. package/ssh/main.js +2253 -0
@@ -31,12 +31,17 @@ export class EnvelopeBuilder {
31
31
  return this._buildEnvelope();
32
32
  }
33
33
 
34
+ updateMetadata(updates) {
35
+ Object.assign(this._metadata, updates);
36
+ }
37
+
34
38
  buildSessionClose(outcome) {
35
39
  return {
36
40
  envelope_id: `env_${randomUUID()}`,
37
41
  session_id: this._sessionId,
38
42
  type: 'SESSION_CLOSE',
39
43
  attestation: { session_hmac: '', sequence: 0, app_version_hash: '' },
44
+ metadata: { ...this._metadata },
40
45
  outcome,
41
46
  };
42
47
  }
@@ -65,7 +65,7 @@ export class PIIScrubber {
65
65
  },
66
66
  {
67
67
  name: 'ipv6',
68
- regex: /(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|::(?:[fF]{4}:)?(?:\d{1,3}\.){3}\d{1,3}|::1\b/g,
68
+ regex: /(?<![\w.#-])(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|::(?:[fF]{4}:)?(?:\d{1,3}\.){3}\d{1,3}|::1\b)/g,
69
69
  replacement: '[IP]',
70
70
  },
71
71
  {
@@ -3,23 +3,39 @@
3
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/;
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
+ 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;
7
+ const APPROVAL_RE = /\b(?:looks? good|lgtm|approved?|go ahead|ship it|that'?s (?:right|correct|perfect)|perfect|exactly right|nice work|well done|great job)\b/i;
8
+ const CLARIFICATION_RE = /\b(?:what (?:about|I (?:mean|want))|I meant|to (?:be clear|clarify)|let me (?:rephrase|explain)|clarif(?:y|ication)|more specifically)\b/i;
9
+
6
10
  export class StepClassifier {
7
11
  constructor() {
8
12
  this.hasAgentActed = false;
9
13
  this._lastStepType = null;
10
14
  }
11
15
 
12
- classifyUserMessage(text) {
16
+ classifyUserMessage(text, source = 'user') {
13
17
  if (!this.hasAgentActed) {
14
- return null;
18
+ return {
19
+ type: 'instruction',
20
+ content: text,
21
+ source,
22
+ };
15
23
  }
16
24
  return {
17
- type: 'correction',
25
+ type: StepClassifier.classifyIntent(text),
18
26
  content: text,
19
- source: 'user',
27
+ source,
20
28
  };
21
29
  }
22
30
 
31
+ static classifyIntent(text) {
32
+ if (!text || typeof text !== 'string') return 'instruction';
33
+ if (CORRECTION_RE.test(text)) return 'correction';
34
+ if (APPROVAL_RE.test(text)) return 'approval';
35
+ if (CLARIFICATION_RE.test(text)) return 'clarification';
36
+ return 'instruction';
37
+ }
38
+
23
39
  classifyCoordinationEvent(event) {
24
40
  return {
25
41
  type: 'coordination',
@@ -42,7 +58,7 @@ export class StepClassifier {
42
58
  step.type = 'error';
43
59
  }
44
60
 
45
- if (step.type === 'thought' && this._lastStepType === 'correction' && FIX_SIGNAL_RE.test(content)) {
61
+ if (step.type === 'thought' && (this._lastStepType === 'correction' || this._lastStepType === 'instruction') && FIX_SIGNAL_RE.test(content)) {
46
62
  step.correction_context = true;
47
63
  }
48
64
 
@@ -63,6 +79,6 @@ export class StepClassifier {
63
79
  }
64
80
 
65
81
  static countUserInterventions(steps) {
66
- return steps.filter((s) => s.type === 'correction').length;
82
+ return steps.filter((s) => s.type === 'correction' || s.type === 'clarification').length;
67
83
  }
68
84
  }
@@ -18,6 +18,7 @@ import {
18
18
  TRAINING_MIN_TOKENS,
19
19
  TRAINING_MIN_DURATION,
20
20
  TRAINING_EXCLUSION_REASONS,
21
+ USER_MESSAGE_MAX_CHARS,
21
22
  } from '../shared/constants.js';
22
23
 
23
24
  const OFFLINE_RETRY_INTERVAL_MS = 60_000;
@@ -131,15 +132,20 @@ export class TrajectoryCapture {
131
132
 
132
133
  }
133
134
 
134
- onUserMessage(agentId, text) {
135
+ onUserMessage(agentId, text, source = 'user') {
135
136
  if (!this._enabled) return;
136
137
  const ctx = this._contexts.get(agentId);
137
138
  if (!ctx) return;
138
139
 
139
- ctx.revisionRounds++;
140
+ const capped = (typeof text === 'string' && text.length > USER_MESSAGE_MAX_CHARS)
141
+ ? text.slice(0, USER_MESSAGE_MAX_CHARS)
142
+ : text;
140
143
 
141
- const classified = ctx.classifier.classifyUserMessage(text);
142
- if (!classified) return;
144
+ const classified = ctx.classifier.classifyUserMessage(capped, source);
145
+
146
+ if (classified.type === 'correction' || classified.type === 'clarification') {
147
+ ctx.revisionRounds++;
148
+ }
143
149
 
144
150
  this._processStep(agentId, ctx, classified);
145
151
  }
@@ -301,6 +307,11 @@ export class TrajectoryCapture {
301
307
  const { tier, reason: tierReason } = this._computeQualityTier(ctx, status, userInterventions);
302
308
  const { eligible, exclusionReason } = this._computeTrainingEligibility(ctx, durationSeconds);
303
309
 
310
+ ctx.builder.updateMetadata({
311
+ domain_tags: ctx.metadata.domain_tags,
312
+ session_quality: ctx.metadata.session_quality,
313
+ });
314
+
304
315
  const closeEnvelope = ctx.builder.buildSessionClose({
305
316
  status,
306
317
  session_quality: ctx.metadata.session_quality,
@@ -36,4 +36,6 @@ export const TRAINING_MIN_TOKENS = 500;
36
36
  export const TRAINING_MIN_DURATION = 10;
37
37
  export const TRAINING_EXCLUSION_REASONS = ['too_few_steps', 'no_actions', 'no_observations', 'insufficient_tokens', 'too_short'];
38
38
 
39
+ export const USER_MESSAGE_MAX_CHARS = 2000;
40
+
39
41
  export const CENTRAL_COMMAND_URL = process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai';
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { SUPPORTED_PROVIDERS, MODEL_TIERS, TRAINING_EXCLUSION_REASONS } from './constants.js';
4
4
 
5
- export const STEP_TYPES = ['thought', 'action', 'observation', 'correction', 'resolution', 'error', 'coordination', 'edit'];
5
+ export const STEP_TYPES = ['thought', 'action', 'observation', 'correction', 'resolution', 'error', 'coordination', 'edit', 'instruction', 'clarification', 'approval'];
6
6
  const VALID_QUALITY_TIERS = ['TIER_A', 'TIER_B', 'TIER_C'];
7
7
  const VALID_FEEDBACK_SIGNALS = ['accepted', 'modified', 'rejected', 'iterated'];
8
8
 
@@ -117,6 +117,38 @@ describe('EnvelopeBuilder', () => {
117
117
  assert.equal(close.outcome.training_exclusion_reason, null);
118
118
  });
119
119
 
120
+ it('updateMetadata syncs late-computed fields to builder', () => {
121
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
122
+ const domainTags = {
123
+ primary: { domain: 'react_frontend', confidence: 0.3 },
124
+ secondary: { domain: 'typescript_node', confidence: 0.25 },
125
+ tertiary: { domain: 'python', confidence: 0 },
126
+ };
127
+ builder.updateMetadata({ domain_tags: domainTags, session_quality: 85 });
128
+
129
+ builder.addStep({ step: 1, type: 'thought', timestamp: 123 });
130
+ const envelope = builder.flush();
131
+ assert.deepEqual(envelope.metadata.domain_tags, domainTags);
132
+ assert.equal(envelope.metadata.session_quality, 85);
133
+ });
134
+
135
+ it('SESSION_CLOSE includes metadata with domain_tags', () => {
136
+ const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
137
+ const domainTags = {
138
+ primary: { domain: 'react_frontend', confidence: 0.3 },
139
+ secondary: { domain: 'typescript_node', confidence: 0.25 },
140
+ tertiary: { domain: 'python', confidence: 0 },
141
+ };
142
+ builder.updateMetadata({ domain_tags: domainTags });
143
+
144
+ const close = builder.buildSessionClose({
145
+ status: 'SUCCESS', total_steps: 10, total_chunks: 1,
146
+ });
147
+ assert.ok(close.metadata, 'SESSION_CLOSE must include metadata');
148
+ assert.deepEqual(close.metadata.domain_tags, domainTags);
149
+ assert.equal(close.metadata.agent_role, 'backend');
150
+ });
151
+
120
152
  it('chunk sequence increments correctly', () => {
121
153
  const builder = new EnvelopeBuilder('sess_1', 'user_1', metadata);
122
154
  let first = null;
@@ -136,11 +136,24 @@ describe('PIIScrubber', () => {
136
136
  assert.equal(scrubber.scrub(input), input);
137
137
  });
138
138
 
139
+ it('does not scrub CSS pseudo-elements with hex-like class names', () => {
140
+ assert.equal(scrubber.scrub('.page::before { content: ""; }'), '.page::before { content: ""; }');
141
+ assert.equal(scrubber.scrub('.page::-webkit-scrollbar { width: 8px; }'), '.page::-webkit-scrollbar { width: 8px; }');
142
+ assert.equal(scrubber.scrub('.cafe::after { display: block; }'), '.cafe::after { display: block; }');
143
+ assert.equal(scrubber.scrub('::placeholder { color: gray; }'), '::placeholder { color: gray; }');
144
+ });
145
+
139
146
  it('still scrubs IPv6 loopback ::1', () => {
140
147
  const input = 'listening on ::1 port 3000';
141
148
  assert.equal(scrubber.scrub(input), 'listening on [IP] port 3000');
142
149
  });
143
150
 
151
+ it('scrubs compressed IPv6 addresses completely', () => {
152
+ assert.equal(scrubber.scrub('addr fe80::1 here'), 'addr [IP] here');
153
+ assert.equal(scrubber.scrub('addr fe80:: here'), 'addr [IP] here');
154
+ assert.equal(scrubber.scrub('addr 2001:db8::1 here'), 'addr [IP] here');
155
+ });
156
+
144
157
  it('does not scrub file paths as base64 secrets', () => {
145
158
  const input = '/home/user/project/groove/packages/gui/src/views/settings.jsx';
146
159
  const result = scrubber.scrub(input);
@@ -5,18 +5,54 @@ import assert from 'node:assert/strict';
5
5
  import { StepClassifier } from '../../client/step-classifier.js';
6
6
 
7
7
  describe('StepClassifier', () => {
8
- it('user message before any action is not a correction', () => {
8
+ it('user message before any action is classified as instruction', () => {
9
9
  const classifier = new StepClassifier();
10
10
  const result = classifier.classifyUserMessage('fix the bug');
11
- assert.equal(result, null);
11
+ assert.equal(result.type, 'instruction');
12
+ assert.equal(result.content, 'fix the bug');
13
+ assert.equal(result.source, 'user');
12
14
  });
13
15
 
14
- it('user message after action is a correction', () => {
16
+ it('user correction after action is classified as correction', () => {
15
17
  const classifier = new StepClassifier();
16
18
  classifier.onStep({ type: 'action' });
17
- const result = classifier.classifyUserMessage('no, use exponential backoff');
19
+ const result = classifier.classifyUserMessage('no, that\'s wrong, use exponential backoff');
18
20
  assert.equal(result.type, 'correction');
19
- assert.equal(result.content, 'no, use exponential backoff');
21
+ assert.equal(result.content, 'no, that\'s wrong, use exponential backoff');
22
+ assert.equal(result.source, 'user');
23
+ });
24
+
25
+ it('user approval after action is classified as approval', () => {
26
+ const classifier = new StepClassifier();
27
+ classifier.onStep({ type: 'action' });
28
+ const result = classifier.classifyUserMessage('looks good, ship it');
29
+ assert.equal(result.type, 'approval');
30
+ });
31
+
32
+ it('user clarification after action is classified as clarification', () => {
33
+ const classifier = new StepClassifier();
34
+ classifier.onStep({ type: 'action' });
35
+ const result = classifier.classifyUserMessage('to clarify, I meant the sidebar component');
36
+ assert.equal(result.type, 'clarification');
37
+ });
38
+
39
+ it('new instruction after action defaults to instruction', () => {
40
+ const classifier = new StepClassifier();
41
+ classifier.onStep({ type: 'action' });
42
+ const result = classifier.classifyUserMessage('now add pagination to the list view');
43
+ assert.equal(result.type, 'instruction');
44
+ });
45
+
46
+ it('passes source through from caller', () => {
47
+ const classifier = new StepClassifier();
48
+ const result = classifier.classifyUserMessage('deploy the backend', 'planner');
49
+ assert.equal(result.source, 'planner');
50
+ assert.equal(result.type, 'instruction');
51
+ });
52
+
53
+ it('defaults source to user', () => {
54
+ const classifier = new StepClassifier();
55
+ const result = classifier.classifyUserMessage('do the thing');
20
56
  assert.equal(result.source, 'user');
21
57
  });
22
58
 
@@ -66,17 +102,27 @@ describe('StepClassifier', () => {
66
102
  assert.equal(StepClassifier.detectErrorRecovery(steps), false);
67
103
  });
68
104
 
69
- it('counts user interventions', () => {
105
+ it('counts corrections and clarifications as interventions', () => {
70
106
  const steps = [
71
107
  { type: 'thought' },
72
108
  { type: 'correction' },
73
109
  { type: 'action' },
74
- { type: 'correction' },
110
+ { type: 'clarification' },
75
111
  { type: 'resolution' },
76
112
  ];
77
113
  assert.equal(StepClassifier.countUserInterventions(steps), 2);
78
114
  });
79
115
 
116
+ it('does not count instruction or approval as interventions', () => {
117
+ const steps = [
118
+ { type: 'instruction' },
119
+ { type: 'action' },
120
+ { type: 'approval' },
121
+ { type: 'resolution' },
122
+ ];
123
+ assert.equal(StepClassifier.countUserInterventions(steps), 0);
124
+ });
125
+
80
126
  it('counts zero interventions when none present', () => {
81
127
  const steps = [
82
128
  { type: 'thought' },
@@ -117,6 +163,14 @@ describe('StepClassifier', () => {
117
163
  assert.equal(result.correction_context, true);
118
164
  });
119
165
 
166
+ it('marks thought after instruction as correction_context when fix signal present', () => {
167
+ const classifier = new StepClassifier();
168
+ classifier.onStep({ type: 'instruction', content: 'fix the login page' });
169
+ const step = { type: 'thought', content: 'I see the issue, let me fix the validation' };
170
+ const result = classifier.onStep(step);
171
+ assert.equal(result.correction_context, true);
172
+ });
173
+
120
174
  it('does not mark thought as correction_context without prior correction', () => {
121
175
  const classifier = new StepClassifier();
122
176
  classifier.onStep({ type: 'action', content: 'running test' });
@@ -175,3 +229,38 @@ describe('StepClassifier', () => {
175
229
  assert.equal(result.type, 'error');
176
230
  });
177
231
  });
232
+
233
+ describe('StepClassifier.classifyIntent', () => {
234
+ it('classifies corrections', () => {
235
+ assert.equal(StepClassifier.classifyIntent("no, that's wrong"), 'correction');
236
+ assert.equal(StepClassifier.classifyIntent("that's not what I wanted"), 'correction');
237
+ assert.equal(StepClassifier.classifyIntent('undo that change'), 'correction');
238
+ assert.equal(StepClassifier.classifyIntent('revert the last edit'), 'correction');
239
+ assert.equal(StepClassifier.classifyIntent('you missed the edge case'), 'correction');
240
+ });
241
+
242
+ it('classifies approvals', () => {
243
+ assert.equal(StepClassifier.classifyIntent('looks good'), 'approval');
244
+ assert.equal(StepClassifier.classifyIntent('lgtm, ship it'), 'approval');
245
+ assert.equal(StepClassifier.classifyIntent("that's correct"), 'approval');
246
+ assert.equal(StepClassifier.classifyIntent('go ahead with that approach'), 'approval');
247
+ });
248
+
249
+ it('classifies clarifications', () => {
250
+ assert.equal(StepClassifier.classifyIntent('to clarify, I meant the sidebar'), 'clarification');
251
+ assert.equal(StepClassifier.classifyIntent('what I want is the mobile layout'), 'clarification');
252
+ assert.equal(StepClassifier.classifyIntent('let me rephrase — update the header'), 'clarification');
253
+ });
254
+
255
+ it('defaults to instruction for new directions', () => {
256
+ assert.equal(StepClassifier.classifyIntent('now add pagination to the list'), 'instruction');
257
+ assert.equal(StepClassifier.classifyIntent('also update the README'), 'instruction');
258
+ assert.equal(StepClassifier.classifyIntent('can you refactor the auth module'), 'instruction');
259
+ });
260
+
261
+ it('returns instruction for null/empty input', () => {
262
+ assert.equal(StepClassifier.classifyIntent(null), 'instruction');
263
+ assert.equal(StepClassifier.classifyIntent(''), 'instruction');
264
+ assert.equal(StepClassifier.classifyIntent(undefined), 'instruction');
265
+ });
266
+ });
@@ -29,12 +29,17 @@ function makeCtx(overrides = {}) {
29
29
  session_quality: overrides.quality ?? 80,
30
30
  },
31
31
  builder: {
32
- buildSessionClose: (outcome) => ({
33
- envelope_id: 'env_test_close',
34
- session_id: 'sess_test_1',
35
- type: 'SESSION_CLOSE',
36
- outcome,
37
- }),
32
+ _metadata: {},
33
+ updateMetadata(updates) { Object.assign(this._metadata, updates); },
34
+ buildSessionClose: function (outcome) {
35
+ return {
36
+ envelope_id: 'env_test_close',
37
+ session_id: 'sess_test_1',
38
+ type: 'SESSION_CLOSE',
39
+ metadata: { ...this._metadata },
40
+ outcome,
41
+ };
42
+ },
38
43
  },
39
44
  };
40
45
  }
@@ -439,6 +444,48 @@ describe('TrajectoryCapture — _computeQuality', () => {
439
444
  });
440
445
  });
441
446
 
447
+ describe('TrajectoryCapture — domain_tags in SESSION_CLOSE', () => {
448
+ it('domain_tags set on ctx.metadata flow into SESSION_CLOSE via updateMetadata', () => {
449
+ const tc = makeTc();
450
+ const captured = [];
451
+ tc._signAndTransmit = (_sid, envelope) => { captured.push(envelope); };
452
+
453
+ const ctx = makeCtx();
454
+ ctx.metadata.domain_tags = {
455
+ primary: { domain: 'react_frontend', confidence: 0.3 },
456
+ secondary: { domain: 'typescript_node', confidence: 0.25 },
457
+ tertiary: { domain: 'python', confidence: 0 },
458
+ };
459
+
460
+ ctx.builder.updateMetadata({
461
+ domain_tags: ctx.metadata.domain_tags,
462
+ session_quality: ctx.metadata.session_quality,
463
+ });
464
+ const close = ctx.builder.buildSessionClose({
465
+ status: 'SUCCESS', total_steps: 10, total_chunks: 1,
466
+ });
467
+
468
+ assert.ok(close.metadata, 'SESSION_CLOSE must have metadata');
469
+ assert.deepEqual(close.metadata.domain_tags, ctx.metadata.domain_tags);
470
+ });
471
+
472
+ it('SESSION_CLOSE metadata is absent domain_tags when tagger returns null', () => {
473
+ const ctx = makeCtx();
474
+ ctx.metadata.domain_tags = null;
475
+
476
+ ctx.builder.updateMetadata({
477
+ domain_tags: null,
478
+ session_quality: ctx.metadata.session_quality,
479
+ });
480
+ const close = ctx.builder.buildSessionClose({
481
+ status: 'SUCCESS', total_steps: 5, total_chunks: 1,
482
+ });
483
+
484
+ assert.ok(close.metadata, 'SESSION_CLOSE must have metadata');
485
+ assert.equal(close.metadata.domain_tags, null);
486
+ });
487
+ });
488
+
442
489
  describe('TrajectoryCapture — onParsedOutput', () => {
443
490
  function makeEnabledTc() {
444
491
  const tc = makeTc();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.107",
3
+ "version": "0.27.108",
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.107",
3
+ "version": "0.27.108",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -62,12 +62,12 @@ async function _executeApprovalRetry(daemon, approval) {
62
62
  return;
63
63
  }
64
64
  if (rp.agentId) {
65
- await daemon.processes.sendMessage(rp.agentId, `Your ${rp.type === 'integration_exec' ? 'integration action' : 'upload'} was approved and executed successfully. Result: ${resultText}`);
65
+ await daemon.processes.sendMessage(rp.agentId, `Your ${rp.type === 'integration_exec' ? 'integration action' : 'upload'} was approved and executed successfully. Result: ${resultText}`, 'system');
66
66
  }
67
67
  } catch (err) {
68
68
  console.log(`[Groove] Auto-retry for approval ${approval.id} failed: ${err.message}`);
69
69
  if (rp.agentId) {
70
- daemon.processes.sendMessage(rp.agentId, `Your ${rp.type === 'integration_exec' ? 'integration action' : 'upload'} was approved but execution failed: ${err.message}`).catch(() => {});
70
+ daemon.processes.sendMessage(rp.agentId, `Your ${rp.type === 'integration_exec' ? 'integration action' : 'upload'} was approved but execution failed: ${err.message}`, 'system').catch(() => {});
71
71
  }
72
72
  }
73
73
  }
@@ -4211,6 +4211,33 @@ Keep responses concise. Help them think, don't lecture them about the system the
4211
4211
  res.json(daemon.federation.getStatus());
4212
4212
  });
4213
4213
 
4214
+ app.get('/api/federation/test', async (req, res) => {
4215
+ const target = req.query.target;
4216
+ if (!target) return res.status(400).json({ error: 'target required' });
4217
+ const host = target.split(':')[0];
4218
+ const privatePatterns = [
4219
+ /^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./,
4220
+ /^0\./, /^169\.254\./, /^localhost$/i, /^::1$/, /^\[::1\]$/,
4221
+ /^0\.0\.0\.0$/, /^fc/i, /^fd/i, /^fe80/i,
4222
+ ];
4223
+ if (privatePatterns.some(p => p.test(host))) {
4224
+ return res.status(400).json({ error: 'Private/local addresses are not allowed' });
4225
+ }
4226
+ try {
4227
+ const controller = new AbortController();
4228
+ const timeout = setTimeout(() => controller.abort(), 5000);
4229
+ const resp = await fetch(`http://${target}/api/health`, { signal: controller.signal });
4230
+ clearTimeout(timeout);
4231
+ if (resp.ok) {
4232
+ const data = await resp.json();
4233
+ return res.json({ reachable: true, version: data.version, peerId: data.daemonId, agents: data.agents });
4234
+ }
4235
+ res.json({ reachable: false });
4236
+ } catch {
4237
+ res.json({ reachable: false });
4238
+ }
4239
+ });
4240
+
4214
4241
  // List peers
4215
4242
  app.get('/api/federation/peers', (req, res) => {
4216
4243
  res.json(daemon.federation.getPeers());
@@ -1434,7 +1434,7 @@ For normal file edits within your scope, proceed without review.
1434
1434
  if (existing && (existing.status === 'running' || existing.status === 'starting')) {
1435
1435
  // Agent already active — reuse it instead of spawning a duplicate
1436
1436
  if (config.prompt) {
1437
- this.sendMessage(existing.id, config.prompt).catch((err) => {
1437
+ this.sendMessage(existing.id, config.prompt, 'planner').catch((err) => {
1438
1438
  console.error(`[Groove] Phase 2 reuse message failed for ${existing.name}: ${err.message}`);
1439
1439
  });
1440
1440
  }
@@ -1527,7 +1527,7 @@ For normal file edits within your scope, proceed without review.
1527
1527
  const message = `Your teammate ${completedAgent.name} (${completedAgent.role}) just finished their work.${fileList}${result ? `\n\nTheir summary:\n${result.slice(0, 2000)}` : ''}\n\nPlease audit their changes: verify correctness, check for bugs, run tests if available, and report any issues.`;
1528
1528
 
1529
1529
  // Send message to the QC agent via the instruct flow
1530
- this.sendMessage(qc.id, message).catch((err) => {
1530
+ this.sendMessage(qc.id, message, 'system').catch((err) => {
1531
1531
  console.error(`[Groove] QC auto-trigger failed: ${err.message}`);
1532
1532
  });
1533
1533
 
@@ -1651,7 +1651,7 @@ For normal file edits within your scope, proceed without review.
1651
1651
  if (target.status === 'running') {
1652
1652
  let sent = false;
1653
1653
  if (this.hasAgentLoop(target.id)) {
1654
- this.sendMessage(target.id, message).catch(() => {});
1654
+ this.sendMessage(target.id, message, 'agent').catch(() => {});
1655
1655
  sent = true;
1656
1656
  }
1657
1657
  if (!sent && this.daemon.journalist) {
@@ -2189,7 +2189,7 @@ For normal file edits within your scope, proceed without review.
2189
2189
  * Send a message to a running agent loop.
2190
2190
  * Returns true if the message was sent, false if the agent doesn't have an active loop.
2191
2191
  */
2192
- async sendMessage(agentId, message) {
2192
+ async sendMessage(agentId, message, source = 'user') {
2193
2193
  const handle = this.handles.get(agentId);
2194
2194
  if (!handle?.loop) return false;
2195
2195
 
@@ -2200,7 +2200,7 @@ For normal file edits within your scope, proceed without review.
2200
2200
  const wrapped = agent ? wrapWithRoleReminder(agent.role, message) : message;
2201
2201
 
2202
2202
  if (this.daemon.trajectoryCapture) {
2203
- try { this.daemon.trajectoryCapture.onUserMessage(agentId, message); } catch (e) { /* fail silent */ }
2203
+ try { this.daemon.trajectoryCapture.onUserMessage(agentId, message, source); } catch (e) { /* fail silent */ }
2204
2204
  }
2205
2205
 
2206
2206
  loop.sendMessage(wrapped).catch(() => {});