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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.107",
3
+ "version": "0.27.108",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -4,7 +4,7 @@ import { useGrooveStore } from '../../stores/groove';
4
4
  import {
5
5
  Network, Code2, ChartSpline, Puzzle, Users, Plus,
6
6
  RotateCw, Skull, MessageSquare, Terminal, Newspaper,
7
- Search, Radio, ExternalLink, FolderOpen, Globe,
7
+ Search, Radio, ExternalLink, FolderOpen, Globe, Shield,
8
8
  } from 'lucide-react';
9
9
  import { cn } from '../../lib/cn';
10
10
  import { AnimatePresence, motion } from 'framer-motion';
@@ -16,6 +16,7 @@ const STATIC_COMMANDS = [
16
16
  { id: 'nav:marketplace', label: 'Go to Marketplace', icon: Puzzle, category: 'Navigation', action: (s) => { s.setActiveView('marketplace'); } },
17
17
  { id: 'nav:teams', label: 'Go to Teams', icon: Users, category: 'Navigation', action: (s) => { s.setActiveView('teams'); } },
18
18
  { id: 'nav:federation', label: 'Go to Federation', icon: Globe, category: 'Navigation', action: (s) => { s.setActiveView('federation'); } },
19
+ { id: 'action:whitelist', label: 'Add to Federation Whitelist', icon: Shield, category: 'Federation', action: (s) => { s.setActiveView('federation'); } },
19
20
  { id: 'action:spawn', label: 'Spawn Agent', icon: Plus, category: 'Actions', action: (s) => { s.openDetail({ type: 'spawn' }); } },
20
21
  { id: 'action:terminal', label: 'Toggle Terminal', icon: Terminal, category: 'Actions', action: (s) => { s.setTerminalVisible(!s.terminalVisible); }, shortcut: 'Cmd+J' },
21
22
  { id: 'action:journalist', label: 'Toggle Journalist', icon: Newspaper, category: 'Actions', action: (s) => {
@@ -1,5 +1,5 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { Terminal, BookOpen, Radio, Plug, Globe, ArrowUpCircle, X, Unplug } from 'lucide-react';
2
+ import { Terminal, BookOpen, Radio, Plug, Globe, ArrowUpCircle, X, Unplug, Shield } from 'lucide-react';
3
3
  import { cn } from '../../lib/cn';
4
4
  import { StatusDot } from '../ui/status-dot';
5
5
  import { Badge } from '../ui/badge';
@@ -22,7 +22,6 @@ export function StatusBar({
22
22
  const updateReady = useGrooveStore((s) => s.updateReady);
23
23
  const updateProgress = useGrooveStore((s) => s.updateProgress);
24
24
  const setUpdateModalOpen = useGrooveStore((s) => s.setUpdateModalOpen);
25
- const subscription = useGrooveStore((s) => s.subscription);
26
25
  const navigate = useGrooveStore((s) => s.setActiveView);
27
26
  const activeTunnel = savedTunnels.find((t) => t.active);
28
27
  const electron = isElectron();
@@ -71,6 +70,13 @@ export function StatusBar({
71
70
  <span className="text-text-4">{activeTunnel.latencyMs}ms</span>
72
71
  )}
73
72
  </button>
73
+ <button
74
+ onClick={() => useGrooveStore.getState().addToWhitelist(activeTunnel.host)}
75
+ className="p-0.5 text-text-4 hover:text-accent cursor-pointer transition-colors rounded"
76
+ title="Add to Federation Whitelist"
77
+ >
78
+ <Shield size={10} />
79
+ </button>
74
80
  <button
75
81
  onClick={() => useGrooveStore.getState().disconnectTunnel(activeTunnel.id)}
76
82
  className="p-0.5 text-text-4 hover:text-danger cursor-pointer transition-colors rounded"
@@ -98,7 +104,7 @@ export function StatusBar({
98
104
  <span>Connect</span>
99
105
  </button>
100
106
  )}
101
- {subscription?.active && (subscription.plan === 'pro' || subscription.plan === 'team') && (
107
+ {connected && (
102
108
  <button
103
109
  onClick={() => navigate('federation')}
104
110
  className="flex items-center gap-1.5 text-text-4 hover:text-text-1 cursor-pointer transition-colors"
@@ -22,7 +22,7 @@ function statusBadge(status) {
22
22
  }
23
23
  }
24
24
 
25
- function WhitelistTab() {
25
+ export function WhitelistTab() {
26
26
  const whitelist = useGrooveStore((s) => s.federation.whitelist);
27
27
  const addToWhitelist = useGrooveStore((s) => s.addToWhitelist);
28
28
  const removeFromWhitelist = useGrooveStore((s) => s.removeFromWhitelist);
@@ -198,7 +198,7 @@ function WhitelistTab() {
198
198
  );
199
199
  }
200
200
 
201
- function AmbassadorsTab() {
201
+ export function AmbassadorsTab() {
202
202
  const ambassadors = useGrooveStore((s) => s.federation.ambassadors);
203
203
 
204
204
  return (
@@ -22,6 +22,8 @@ export function FederationPeers({ onOpenWizard }) {
22
22
  const connections = useGrooveStore((s) => s.federation.connections);
23
23
  const peers = useGrooveStore((s) => s.federation.peers);
24
24
  const sendPouch = useGrooveStore((s) => s.sendPouch);
25
+ const disconnectPeer = useGrooveStore((s) => s.disconnectPeer);
26
+ const addToast = useGrooveStore((s) => s.addToast);
25
27
  const [sendingTo, setSendingTo] = useState(null);
26
28
 
27
29
  const allPeers = peers.length > 0 ? peers : connections;
@@ -97,7 +99,12 @@ export function FederationPeers({ onOpenWizard }) {
97
99
  </div>
98
100
 
99
101
  <div className="flex items-center gap-1.5 mt-3 pt-2.5 border-t border-border-subtle">
100
- <Button size="sm" variant="ghost" className="h-6 text-2xs gap-1 text-text-2">
102
+ <Button
103
+ size="sm"
104
+ variant="ghost"
105
+ className="h-6 text-2xs gap-1 text-text-2"
106
+ onClick={() => addToast('info', name, `ID: ${id}\nIP: ${ip}${peer.port ? `:${peer.port}` : ''}\nLatency: ${latency != null ? `${latency}ms` : 'N/A'}\nState: ${state}`)}
107
+ >
101
108
  <Eye size={10} />
102
109
  Details
103
110
  </Button>
@@ -111,7 +118,12 @@ export function FederationPeers({ onOpenWizard }) {
111
118
  <Send size={10} />
112
119
  Send Pouch
113
120
  </Button>
114
- <Button size="sm" variant="ghost" className="h-6 text-2xs gap-1 text-danger/70 hover:text-danger ml-auto">
121
+ <Button
122
+ size="sm"
123
+ variant="ghost"
124
+ className="h-6 text-2xs gap-1 text-danger/70 hover:text-danger ml-auto"
125
+ onClick={() => disconnectPeer(id)}
126
+ >
115
127
  <Unplug size={10} />
116
128
  Disconnect
117
129
  </Button>
@@ -25,6 +25,15 @@ export function QuickConnect() {
25
25
  setConnectingId(id);
26
26
  try {
27
27
  await useGrooveStore.getState().connectTunnel(id);
28
+ const tunnel = savedTunnels.find((t) => t.id === id);
29
+ if (tunnel?.host) {
30
+ addToast('info', `Add ${tunnel.host} to Federation Whitelist?`, '', {
31
+ action: {
32
+ label: 'Add',
33
+ onClick: () => useGrooveStore.getState().addToWhitelist(tunnel.host),
34
+ },
35
+ });
36
+ }
28
37
  toggle();
29
38
  } catch (err) {
30
39
  let detail = err?.message || 'Unknown error';
@@ -2660,6 +2660,16 @@ export const useGrooveStore = create((set, get) => ({
2660
2660
  }
2661
2661
  },
2662
2662
 
2663
+ async disconnectPeer(peerId) {
2664
+ try {
2665
+ await api.delete(`/federation/peers/${encodeURIComponent(peerId)}`);
2666
+ get().addToast('info', 'Peer disconnected');
2667
+ get().fetchFederationStatus();
2668
+ } catch (err) {
2669
+ get().addToast('error', 'Disconnect failed', err.message);
2670
+ }
2671
+ },
2672
+
2663
2673
  // ── Training Data ─────────────────────────────────────────
2664
2674
 
2665
2675
  async setTrainingOptIn(enabled) {
@@ -1,37 +1,78 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useEffect } from 'react';
2
+ import { useEffect, useState } from 'react';
3
3
  import { useGrooveStore } from '../stores/groove';
4
- import { FederationPanel } from '../components/settings/federation-panel';
5
- import { ProGate } from '../components/pro/pro-gate';
4
+ import { FederationPeers } from '../components/settings/federation-peers';
5
+ import { FederationActivity } from '../components/settings/federation-activity';
6
+ import { FederationWizard } from '../components/settings/federation-wizard';
7
+ import { WhitelistTab, AmbassadorsTab } from '../components/settings/federation-panel';
8
+ import { Button } from '../components/ui/button';
6
9
  import { ScrollArea } from '../components/ui/scroll-area';
7
- import { Globe } from 'lucide-react';
10
+ import { Globe, Plus } from 'lucide-react';
8
11
 
9
12
  export default function FederationView() {
10
13
  const fetchFederationStatus = useGrooveStore((s) => s.fetchFederationStatus);
14
+ const fetchPouchLog = useGrooveStore((s) => s.fetchPouchLog);
15
+ const peers = useGrooveStore((s) => s.federation.peers);
16
+ const connections = useGrooveStore((s) => s.federation.connections);
17
+ const whitelist = useGrooveStore((s) => s.federation.whitelist);
18
+ const [wizardOpen, setWizardOpen] = useState(false);
19
+
20
+ const peerCount = (peers.length > 0 ? peers : connections).length;
11
21
 
12
22
  useEffect(() => {
13
23
  fetchFederationStatus();
24
+ fetchPouchLog();
14
25
  }, []);
15
26
 
16
27
  return (
17
28
  <div className="flex flex-col h-full bg-surface-0">
18
- <div className="flex items-center gap-3 px-6 py-4 border-b border-border">
19
- <div className="w-8 h-8 rounded-md bg-accent/10 flex items-center justify-center">
20
- <Globe size={16} className="text-accent" />
21
- </div>
22
- <div>
23
- <h1 className="text-sm font-semibold text-text-0 font-sans">Federation</h1>
24
- <p className="text-2xs text-text-3 font-sans">Connect to remote Groove daemons</p>
29
+ {/* Hero strip */}
30
+ <div className="flex items-center justify-between px-6 py-4 border-b border-border">
31
+ <div className="flex items-center gap-3">
32
+ <div className="w-8 h-8 rounded-md bg-accent/10 flex items-center justify-center">
33
+ <Globe size={16} className="text-accent" />
34
+ </div>
35
+ <div>
36
+ <h1 className="text-sm font-semibold text-text-0 font-sans">Federation</h1>
37
+ <p className="text-2xs text-text-3 font-sans">
38
+ {peerCount} peer{peerCount !== 1 ? 's' : ''} &middot; {whitelist.length} whitelisted
39
+ </p>
40
+ </div>
25
41
  </div>
42
+ <Button size="sm" variant="primary" onClick={() => setWizardOpen(true)} className="h-8 text-xs gap-1.5">
43
+ <Plus size={12} />
44
+ Add Peer
45
+ </Button>
26
46
  </div>
27
47
 
48
+ {/* Content */}
28
49
  <ScrollArea className="flex-1">
29
- <div className="max-w-2xl mx-auto px-6 py-5">
30
- <ProGate feature="Federation" featureKey="federation" description="Daemon-to-daemon federation over Tailscale mesh for multi-machine agent coordination">
31
- <FederationPanel />
32
- </ProGate>
50
+ <div className="p-6">
51
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
52
+ {/* Left: Peers + Activity (stacked) */}
53
+ <div className="lg:col-span-2 space-y-4">
54
+ <div className="rounded-lg border border-border-subtle bg-surface-1 p-4">
55
+ <FederationPeers onOpenWizard={() => setWizardOpen(true)} />
56
+ </div>
57
+ <div className="rounded-lg border border-border-subtle bg-surface-1 p-4">
58
+ <FederationActivity />
59
+ </div>
60
+ </div>
61
+
62
+ {/* Right: Whitelist + Ambassadors (stacked) */}
63
+ <div className="space-y-4">
64
+ <div className="rounded-lg border border-border-subtle bg-surface-1 p-4">
65
+ <WhitelistTab />
66
+ </div>
67
+ <div className="rounded-lg border border-border-subtle bg-surface-1 p-4">
68
+ <AmbassadorsTab />
69
+ </div>
70
+ </div>
71
+ </div>
33
72
  </div>
34
73
  </ScrollArea>
74
+
75
+ <FederationWizard open={wizardOpen} onOpenChange={setWizardOpen} />
35
76
  </div>
36
77
  );
37
78
  }
@@ -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
+ });