groove-dev 0.27.102 → 0.27.103

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 (50) hide show
  1. package/CLAUDE.md +0 -7
  2. package/moe-training/client/domain-tagger.js +205 -0
  3. package/moe-training/client/edit-normalizer.js +188 -0
  4. package/moe-training/client/envelope-builder.js +1 -1
  5. package/moe-training/client/parsers/claude-code.js +56 -9
  6. package/moe-training/client/parsers/codex.js +25 -5
  7. package/moe-training/client/parsers/gemini.js +21 -2
  8. package/moe-training/client/parsers/grok.js +18 -0
  9. package/moe-training/client/trajectory-capture.js +95 -3
  10. package/moe-training/server/routes/ingest.js +26 -0
  11. package/moe-training/server/verifier.js +34 -0
  12. package/moe-training/shared/constants.js +9 -0
  13. package/moe-training/shared/envelope-schema.js +128 -2
  14. package/moe-training/test/client/domain-tagger.test.js +203 -0
  15. package/moe-training/test/client/edit-normalizer.test.js +376 -0
  16. package/moe-training/test/client/envelope-builder.test.js +28 -0
  17. package/moe-training/test/client/parsers/claude-code.test.js +248 -38
  18. package/moe-training/test/client/parsers/codex.test.js +2 -0
  19. package/moe-training/test/client/parsers/gemini.test.js +2 -0
  20. package/moe-training/test/client/trajectory-capture.test.js +345 -0
  21. package/moe-training/test/server/verifier.test.js +94 -0
  22. package/moe-training/test/shared/envelope-schema.test.js +291 -0
  23. package/node_modules/@groove-dev/cli/package.json +1 -1
  24. package/node_modules/@groove-dev/daemon/package.json +1 -1
  25. package/node_modules/@groove-dev/gui/package.json +1 -1
  26. package/node_modules/moe-training/client/domain-tagger.js +205 -0
  27. package/node_modules/moe-training/client/edit-normalizer.js +188 -0
  28. package/node_modules/moe-training/client/envelope-builder.js +1 -1
  29. package/node_modules/moe-training/client/parsers/claude-code.js +56 -9
  30. package/node_modules/moe-training/client/parsers/codex.js +25 -5
  31. package/node_modules/moe-training/client/parsers/gemini.js +21 -2
  32. package/node_modules/moe-training/client/parsers/grok.js +18 -0
  33. package/node_modules/moe-training/client/trajectory-capture.js +95 -3
  34. package/node_modules/moe-training/server/routes/ingest.js +26 -0
  35. package/node_modules/moe-training/server/verifier.js +34 -0
  36. package/node_modules/moe-training/shared/constants.js +9 -0
  37. package/node_modules/moe-training/shared/envelope-schema.js +128 -2
  38. package/node_modules/moe-training/test/client/domain-tagger.test.js +203 -0
  39. package/node_modules/moe-training/test/client/edit-normalizer.test.js +376 -0
  40. package/node_modules/moe-training/test/client/envelope-builder.test.js +28 -0
  41. package/node_modules/moe-training/test/client/parsers/claude-code.test.js +248 -38
  42. package/node_modules/moe-training/test/client/parsers/codex.test.js +2 -0
  43. package/node_modules/moe-training/test/client/parsers/gemini.test.js +2 -0
  44. package/node_modules/moe-training/test/client/trajectory-capture.test.js +345 -0
  45. package/node_modules/moe-training/test/server/verifier.test.js +94 -0
  46. package/node_modules/moe-training/test/shared/envelope-schema.test.js +291 -0
  47. package/package.json +1 -1
  48. package/packages/cli/package.json +1 -1
  49. package/packages/daemon/package.json +1 -1
  50. package/packages/gui/package.json +1 -1
@@ -0,0 +1,188 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ const APPLY_PATCH_RE = /apply_patch/;
4
+ const BEGIN_PATCH_RE = /^\*{3}\s*Begin Patch\s*$/;
5
+ const END_PATCH_RE = /^\*{3}\s*End Patch\s*$/;
6
+ const ADD_FILE_RE = /^\*{3}\s*Add File:\s*(.+)$/;
7
+ const UPDATE_FILE_RE = /^\*{3}\s*Update File:\s*(.+)$/;
8
+ const DELETE_FILE_RE = /^\*{3}\s*Delete File:\s*(.+)$/;
9
+ const HUNK_HEADER_RE = /^@@@.*@@@/;
10
+
11
+ export class EditNormalizer {
12
+ detectApplyPatch(actionContent) {
13
+ const text = extractText(actionContent);
14
+ if (!text) return false;
15
+ return APPLY_PATCH_RE.test(text);
16
+ }
17
+
18
+ normalize(actionContent, timestamp, startStep) {
19
+ const text = extractText(actionContent);
20
+ if (!text) return [];
21
+
22
+ const patchBody = extractPatchBody(text);
23
+ if (!patchBody) return [];
24
+
25
+ const sections = parseSections(patchBody);
26
+ const edits = [];
27
+ let step = startStep || 1;
28
+
29
+ for (const section of sections) {
30
+ if (section.type === 'add') {
31
+ edits.push({
32
+ step: step++,
33
+ type: 'edit',
34
+ timestamp: timestamp || Date.now() / 1000,
35
+ file_path: section.filePath,
36
+ edit_type: 'create',
37
+ content: section.content,
38
+ old_string: null,
39
+ new_string: null,
40
+ token_count: estimateTokens(section.content),
41
+ });
42
+ } else if (section.type === 'update') {
43
+ const hunks = parseHunks(section.lines);
44
+ for (const hunk of hunks) {
45
+ edits.push({
46
+ step: step++,
47
+ type: 'edit',
48
+ timestamp: timestamp || Date.now() / 1000,
49
+ file_path: section.filePath,
50
+ edit_type: 'modify',
51
+ content: null,
52
+ old_string: hunk.oldString,
53
+ new_string: hunk.newString,
54
+ token_count: estimateTokens(hunk.oldString + hunk.newString),
55
+ });
56
+ }
57
+ } else if (section.type === 'delete') {
58
+ edits.push({
59
+ step: step++,
60
+ type: 'edit',
61
+ timestamp: timestamp || Date.now() / 1000,
62
+ file_path: section.filePath,
63
+ edit_type: 'delete',
64
+ content: null,
65
+ old_string: section.content || null,
66
+ new_string: null,
67
+ token_count: estimateTokens(section.content || ''),
68
+ });
69
+ }
70
+ }
71
+
72
+ return edits;
73
+ }
74
+ }
75
+
76
+ function extractText(actionContent) {
77
+ if (typeof actionContent === 'string') return actionContent;
78
+ if (actionContent?.arguments?.command) return actionContent.arguments.command;
79
+ if (actionContent?.content) return actionContent.content;
80
+ return null;
81
+ }
82
+
83
+ function extractPatchBody(text) {
84
+ const lines = text.split('\n');
85
+ let startIdx = -1;
86
+ let endIdx = -1;
87
+
88
+ for (let i = 0; i < lines.length; i++) {
89
+ if (BEGIN_PATCH_RE.test(lines[i].trim())) {
90
+ startIdx = i + 1;
91
+ }
92
+ if (END_PATCH_RE.test(lines[i].trim())) {
93
+ endIdx = i;
94
+ }
95
+ }
96
+
97
+ if (startIdx === -1) return null;
98
+ if (endIdx === -1) endIdx = lines.length;
99
+
100
+ return lines.slice(startIdx, endIdx).join('\n');
101
+ }
102
+
103
+ function parseSections(body) {
104
+ const lines = body.split('\n');
105
+ const sections = [];
106
+ let current = null;
107
+
108
+ for (let i = 0; i < lines.length; i++) {
109
+ const line = lines[i];
110
+ const trimmed = line.trim();
111
+
112
+ let match;
113
+ if ((match = ADD_FILE_RE.exec(trimmed))) {
114
+ if (current) sections.push(current);
115
+ current = { type: 'add', filePath: match[1].trim(), lines: [], content: '' };
116
+ } else if ((match = UPDATE_FILE_RE.exec(trimmed))) {
117
+ if (current) sections.push(current);
118
+ current = { type: 'update', filePath: match[1].trim(), lines: [] };
119
+ } else if ((match = DELETE_FILE_RE.exec(trimmed))) {
120
+ if (current) sections.push(current);
121
+ current = { type: 'delete', filePath: match[1].trim(), lines: [], content: '' };
122
+ } else if (current) {
123
+ current.lines.push(line);
124
+ }
125
+ }
126
+
127
+ if (current) sections.push(current);
128
+
129
+ for (const section of sections) {
130
+ if (section.type === 'add' || section.type === 'delete') {
131
+ section.content = section.lines.join('\n').trim();
132
+ }
133
+ }
134
+
135
+ return sections;
136
+ }
137
+
138
+ function parseHunks(lines) {
139
+ const hunks = [];
140
+ let inHunk = false;
141
+ let oldLines = [];
142
+ let newLines = [];
143
+
144
+ for (const line of lines) {
145
+ if (HUNK_HEADER_RE.test(line.trim())) {
146
+ if (inHunk && (oldLines.length > 0 || newLines.length > 0)) {
147
+ hunks.push(buildHunk(oldLines, newLines));
148
+ }
149
+ inHunk = true;
150
+ oldLines = [];
151
+ newLines = [];
152
+ continue;
153
+ }
154
+
155
+ if (!inHunk && (line.startsWith('-') || line.startsWith('+') || line.startsWith(' '))) {
156
+ inHunk = true;
157
+ }
158
+
159
+ if (!inHunk) continue;
160
+
161
+ if (line.startsWith('-')) {
162
+ oldLines.push(line.slice(1));
163
+ } else if (line.startsWith('+')) {
164
+ newLines.push(line.slice(1));
165
+ } else if (line.startsWith(' ')) {
166
+ oldLines.push(line.slice(1));
167
+ newLines.push(line.slice(1));
168
+ }
169
+ }
170
+
171
+ if (inHunk && (oldLines.length > 0 || newLines.length > 0)) {
172
+ hunks.push(buildHunk(oldLines, newLines));
173
+ }
174
+
175
+ return hunks;
176
+ }
177
+
178
+ function buildHunk(oldLines, newLines) {
179
+ return {
180
+ oldString: oldLines.join('\n'),
181
+ newString: newLines.join('\n'),
182
+ };
183
+ }
184
+
185
+ export function estimateTokens(text) {
186
+ if (!text) return 0;
187
+ return Math.max(1, Math.ceil(text.length / 4));
188
+ }
@@ -7,7 +7,7 @@ export class EnvelopeBuilder {
7
7
  constructor(sessionId, contributorId, metadata) {
8
8
  this._sessionId = sessionId;
9
9
  this._contributorId = contributorId;
10
- this._metadata = metadata;
10
+ this._metadata = { ...metadata, leaf_context: metadata.leaf_context ?? null };
11
11
  this._buffer = [];
12
12
  this._chunkSequence = 0;
13
13
  }
@@ -1,15 +1,21 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
- import { OBSERVATION_TRUNCATE_HEAD, OBSERVATION_TRUNCATE_TAIL } from '../../shared/constants.js';
3
+ import { OBSERVATION_TOKEN_LIMIT } from '../../shared/constants.js';
4
+
5
+ function estimateTokens(text) {
6
+ if (!text) return 0;
7
+ return Math.ceil(text.length / 4);
8
+ }
4
9
 
5
10
  function truncateObservation(text) {
6
- if (!text || typeof text !== 'string') return text;
7
- const lines = text.split('\n');
8
- if (lines.length <= OBSERVATION_TRUNCATE_HEAD + OBSERVATION_TRUNCATE_TAIL) return text;
9
- const head = lines.slice(0, OBSERVATION_TRUNCATE_HEAD);
10
- const tail = lines.slice(-OBSERVATION_TRUNCATE_TAIL);
11
- const omitted = lines.length - OBSERVATION_TRUNCATE_HEAD - OBSERVATION_TRUNCATE_TAIL;
12
- return [...head, `[... ${omitted} lines omitted ...]`, ...tail].join('\n');
11
+ if (!text || typeof text !== 'string') return { content: text, truncated: false, original_token_count: estimateTokens(text) };
12
+ const originalTokens = estimateTokens(text);
13
+ if (originalTokens <= OBSERVATION_TOKEN_LIMIT) {
14
+ return { content: text, truncated: false, original_token_count: originalTokens };
15
+ }
16
+ const charLimit = OBSERVATION_TOKEN_LIMIT * 4;
17
+ const truncated = text.slice(0, charLimit) + `\n[TRUNCATED — original output was ${originalTokens} tokens]`;
18
+ return { content: truncated, truncated: true, original_token_count: originalTokens };
13
19
  }
14
20
 
15
21
  export class ClaudeCodeParser {
@@ -50,10 +56,51 @@ export class ClaudeCodeParser {
50
56
  if (block.is_error) {
51
57
  results.push({ type: 'error', content: resultContent, is_error: true });
52
58
  } else {
59
+ const obs = truncateObservation(resultContent);
60
+ results.push({
61
+ type: 'observation',
62
+ content: obs.content,
63
+ truncated: obs.truncated,
64
+ original_token_count: obs.original_token_count,
65
+ is_error: false,
66
+ });
67
+ }
68
+ }
69
+ }
70
+
71
+ return results.length === 1 ? results[0] : results.length > 1 ? results : null;
72
+ }
73
+
74
+ if (jsonEvent.type === 'user') {
75
+ const contentBlocks = jsonEvent.message?.content;
76
+ if (!Array.isArray(contentBlocks)) return null;
77
+
78
+ const results = [];
79
+ for (const block of contentBlocks) {
80
+ if (block.type === 'tool_result') {
81
+ const toolUse = this._pendingToolUse.get(block.tool_use_id);
82
+ if (toolUse) this._pendingToolUse.delete(block.tool_use_id);
83
+
84
+ const resultContent = Array.isArray(block.content)
85
+ ? block.content.map((c) => c.text || '').join('\n')
86
+ : (typeof block.content === 'string' ? block.content : '');
87
+
88
+ if (block.is_error) {
89
+ results.push({
90
+ type: 'error',
91
+ content: resultContent,
92
+ is_error: true,
93
+ tool: toolUse?.name,
94
+ });
95
+ } else {
96
+ const obs = truncateObservation(resultContent);
53
97
  results.push({
54
98
  type: 'observation',
55
- content: truncateObservation(resultContent),
99
+ content: obs.content,
100
+ truncated: obs.truncated,
101
+ original_token_count: obs.original_token_count,
56
102
  is_error: false,
103
+ tool: toolUse?.name,
57
104
  });
58
105
  }
59
106
  }
@@ -1,5 +1,23 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
+ import { OBSERVATION_TOKEN_LIMIT } from '../../shared/constants.js';
4
+
5
+ function estimateTokens(text) {
6
+ if (!text) return 0;
7
+ return Math.ceil(text.length / 4);
8
+ }
9
+
10
+ function truncateObservation(text) {
11
+ if (!text || typeof text !== 'string') return { content: text || '', truncated: false, original_token_count: estimateTokens(text) };
12
+ const originalTokens = estimateTokens(text);
13
+ if (originalTokens <= OBSERVATION_TOKEN_LIMIT) {
14
+ return { content: text, truncated: false, original_token_count: originalTokens };
15
+ }
16
+ const charLimit = OBSERVATION_TOKEN_LIMIT * 4;
17
+ const truncated = text.slice(0, charLimit) + `\n[TRUNCATED — original output was ${originalTokens} tokens]`;
18
+ return { content: truncated, truncated: true, original_token_count: originalTokens };
19
+ }
20
+
3
21
  export class CodexParser {
4
22
  constructor() {
5
23
  this._sessionId = null;
@@ -37,15 +55,17 @@ export class CodexParser {
37
55
  return { type: 'thought', content: item.text || '' };
38
56
  }
39
57
  if (item.type === 'command_execution') {
40
- const output = (item.aggregated_output || '').slice(0, 2000);
58
+ const rawOutput = item.aggregated_output || '';
41
59
  if (item.exit_code !== 0) {
42
- return { type: 'error', content: output || `Exit code: ${item.exit_code}` };
60
+ return { type: 'error', content: rawOutput.slice(0, 2000) || `Exit code: ${item.exit_code}` };
43
61
  }
44
- return { type: 'observation', content: output };
62
+ const obs = truncateObservation(rawOutput);
63
+ return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
45
64
  }
46
65
  if (item.type === 'file_edit' || item.type === 'file_write' || item.type === 'file_read') {
47
- const output = (item.output || item.content || '').slice(0, 2000);
48
- return { type: 'observation', content: output };
66
+ const rawOutput = item.output || item.content || '';
67
+ const obs = truncateObservation(rawOutput);
68
+ return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
49
69
  }
50
70
  return null;
51
71
  }
@@ -1,5 +1,23 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
+ import { OBSERVATION_TOKEN_LIMIT } from '../../shared/constants.js';
4
+
5
+ function estimateTokens(text) {
6
+ if (!text) return 0;
7
+ return Math.ceil(text.length / 4);
8
+ }
9
+
10
+ function truncateObservation(text) {
11
+ if (!text || typeof text !== 'string') return { content: text || '', truncated: false, original_token_count: estimateTokens(text) };
12
+ const originalTokens = estimateTokens(text);
13
+ if (originalTokens <= OBSERVATION_TOKEN_LIMIT) {
14
+ return { content: text, truncated: false, original_token_count: originalTokens };
15
+ }
16
+ const charLimit = OBSERVATION_TOKEN_LIMIT * 4;
17
+ const truncated = text.slice(0, charLimit) + `\n[TRUNCATED — original output was ${originalTokens} tokens]`;
18
+ return { content: truncated, truncated: true, original_token_count: originalTokens };
19
+ }
20
+
3
21
  export class GeminiParser {
4
22
  constructor() {
5
23
  this._sessionId = null;
@@ -44,8 +62,9 @@ export class GeminiParser {
44
62
  case 'tool_response': {
45
63
  const rawContent = jsonEvent.content;
46
64
  const contentParts = Array.isArray(rawContent) ? rawContent : (typeof rawContent === 'string' ? [{ text: rawContent }] : rawContent ? [rawContent] : []);
47
- const content = contentParts.map((p) => p.text || '').join('').slice(0, 2000);
48
- return { type: 'observation', content };
65
+ const rawText = contentParts.map((p) => p.text || '').join('');
66
+ const obs = truncateObservation(rawText);
67
+ return { type: 'observation', content: obs.content, truncated: obs.truncated, original_token_count: obs.original_token_count };
49
68
  }
50
69
 
51
70
  case 'error': {
@@ -1,5 +1,23 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
+ import { OBSERVATION_TOKEN_LIMIT } from '../../shared/constants.js';
4
+
5
+ function estimateTokens(text) {
6
+ if (!text) return 0;
7
+ return Math.ceil(text.length / 4);
8
+ }
9
+
10
+ function truncateObservation(text) {
11
+ if (!text || typeof text !== 'string') return { content: text || '', truncated: false, original_token_count: estimateTokens(text) };
12
+ const originalTokens = estimateTokens(text);
13
+ if (originalTokens <= OBSERVATION_TOKEN_LIMIT) {
14
+ return { content: text, truncated: false, original_token_count: originalTokens };
15
+ }
16
+ const charLimit = OBSERVATION_TOKEN_LIMIT * 4;
17
+ const truncated = text.slice(0, charLimit) + `\n[TRUNCATED — original output was ${originalTokens} tokens]`;
18
+ return { content: truncated, truncated: true, original_token_count: originalTokens };
19
+ }
20
+
3
21
  export class GrokParser {
4
22
  // TODO: Grok agentic CLI not yet available. Wire up when headless CLI is built.
5
23
  parseEvent(_jsonEvent) {
@@ -8,7 +8,16 @@ import { StepClassifier } from './step-classifier.js';
8
8
  import { EnvelopeBuilder } from './envelope-builder.js';
9
9
  import { SessionAttestation } from './session-attestation.js';
10
10
  import { TransmissionQueue } from './transmission-queue.js';
11
- import { CHUNK_TIMEOUT_MS, CENTRAL_COMMAND_URL } from '../shared/constants.js';
11
+ import {
12
+ CHUNK_TIMEOUT_MS,
13
+ CENTRAL_COMMAND_URL,
14
+ TIER_A_MIN_QUALITY,
15
+ TIER_B_MIN_QUALITY,
16
+ TRAINING_MIN_STEPS,
17
+ TRAINING_MIN_TOKENS,
18
+ TRAINING_MIN_DURATION,
19
+ TRAINING_EXCLUSION_REASONS,
20
+ } from '../shared/constants.js';
12
21
 
13
22
  const OFFLINE_RETRY_INTERVAL_MS = 60_000;
14
23
 
@@ -56,6 +65,7 @@ export class TrajectoryCapture {
56
65
  team_size: teamSize || 1,
57
66
  session_quality: 0,
58
67
  groove_version: this._grooveVersion,
68
+ leaf_context: null,
59
69
  };
60
70
 
61
71
  const builder = new EnvelopeBuilder(sessionId, contributorId, metadata);
@@ -78,6 +88,7 @@ export class TrajectoryCapture {
78
88
  startTime,
79
89
  chunkTimer: null,
80
90
  allSteps: [],
91
+ revisionRounds: 0,
81
92
  };
82
93
 
83
94
  ctx.chunkTimer = setInterval(() => {
@@ -126,6 +137,8 @@ export class TrajectoryCapture {
126
137
  const ctx = this._contexts.get(agentId);
127
138
  if (!ctx) return;
128
139
 
140
+ ctx.revisionRounds++;
141
+
129
142
  const classified = ctx.classifier.classifyUserMessage(text);
130
143
  if (!classified) return;
131
144
 
@@ -238,22 +251,34 @@ export class TrajectoryCapture {
238
251
 
239
252
  ctx.metadata.session_quality = this._computeQuality(ctx);
240
253
 
254
+ const userInterventions = StepClassifier.countUserInterventions(ctx.allSteps);
255
+ const durationSeconds = Math.round((Date.now() - ctx.startTime) / 1000);
256
+
257
+ const { tier, reason: tierReason } = this._computeQualityTier(ctx, status, userInterventions);
258
+ const { eligible, exclusionReason } = this._computeTrainingEligibility(ctx, durationSeconds);
259
+
241
260
  const closeEnvelope = ctx.builder.buildSessionClose({
242
261
  status,
243
262
  session_quality: ctx.metadata.session_quality,
244
- user_interventions: StepClassifier.countUserInterventions(ctx.allSteps),
263
+ quality_tier: tier,
264
+ quality_tier_reason: tierReason,
265
+ user_interventions: userInterventions,
245
266
  total_steps: ctx.stepCount,
246
267
  total_chunks: ctx.chunkCount,
247
268
  total_tokens: ctx.totalTokens,
248
- duration_seconds: Math.round((Date.now() - ctx.startTime) / 1000),
269
+ duration_seconds: durationSeconds,
249
270
  files_modified: extra?.files_modified || ctx.filesModified,
250
271
  errors_encountered: ctx.errorsEncountered,
251
272
  errors_recovered: ctx.errorsRecovered,
252
273
  coordination_events: ctx.coordinationEvents,
274
+ training_eligible: eligible,
275
+ training_exclusion_reason: exclusionReason,
253
276
  });
254
277
 
255
278
  this._signAndTransmit(ctx.sessionId, closeEnvelope);
256
279
 
280
+ this._emitUserFeedback(ctx, status, userInterventions);
281
+
257
282
  try {
258
283
  await this._transmissionQueue.waitForDrain();
259
284
  } catch {
@@ -269,6 +294,73 @@ export class TrajectoryCapture {
269
294
  this._contexts.delete(agentId);
270
295
  }
271
296
 
297
+ _computeQualityTier(ctx, status, userInterventions) {
298
+ const quality = ctx.metadata.session_quality;
299
+ if (quality >= TIER_A_MIN_QUALITY && ctx.errorsEncountered === 0 && userInterventions === 0 && status === 'SUCCESS') {
300
+ return { tier: 'TIER_A', reason: 'high_quality_no_errors' };
301
+ }
302
+ if (status !== 'SUCCESS') {
303
+ return { tier: 'TIER_C', reason: 'non_success_status' };
304
+ }
305
+ if (quality >= TIER_B_MIN_QUALITY || (ctx.errorsEncountered > 0 && ctx.errorsRecovered === ctx.errorsEncountered)) {
306
+ return { tier: 'TIER_B', reason: quality >= TIER_B_MIN_QUALITY ? 'moderate_quality' : 'errors_recovered' };
307
+ }
308
+ return { tier: 'TIER_C', reason: quality < TIER_B_MIN_QUALITY ? 'low_quality' : 'unrecovered_errors' };
309
+ }
310
+
311
+ _computeTrainingEligibility(ctx, durationSeconds) {
312
+ if (ctx.stepCount < TRAINING_MIN_STEPS) {
313
+ return { eligible: false, exclusionReason: 'too_few_steps' };
314
+ }
315
+ const hasAction = ctx.allSteps.some((s) => s.type === 'action' && s.tool);
316
+ if (!hasAction) {
317
+ return { eligible: false, exclusionReason: 'no_actions' };
318
+ }
319
+ const hasObservation = ctx.allSteps.some((s) => s.type === 'observation' && s.content);
320
+ if (!hasObservation) {
321
+ return { eligible: false, exclusionReason: 'no_observations' };
322
+ }
323
+ if (ctx.totalTokens < TRAINING_MIN_TOKENS) {
324
+ return { eligible: false, exclusionReason: 'insufficient_tokens' };
325
+ }
326
+ if (durationSeconds < TRAINING_MIN_DURATION) {
327
+ return { eligible: false, exclusionReason: 'too_short' };
328
+ }
329
+ return { eligible: true, exclusionReason: null };
330
+ }
331
+
332
+ _emitUserFeedback(ctx, status, userInterventions) {
333
+ let signal;
334
+ let context;
335
+
336
+ if (status === 'SUCCESS' && userInterventions === 0 && ctx.revisionRounds === 0) {
337
+ signal = 'accepted';
338
+ context = 'session completed successfully with no user interventions';
339
+ } else if (status === 'SUCCESS' && ctx.revisionRounds > 0) {
340
+ signal = 'iterated';
341
+ context = `user requested ${ctx.revisionRounds} revision(s) before completion`;
342
+ } else {
343
+ return;
344
+ }
345
+
346
+ const feedbackEnvelope = {
347
+ envelope_id: `env_${randomUUID()}`,
348
+ session_id: ctx.sessionId,
349
+ type: 'USER_FEEDBACK',
350
+ attestation: { session_hmac: '', sequence: 0, app_version_hash: '' },
351
+ feedback: {
352
+ signal,
353
+ timestamp: Date.now() / 1000,
354
+ context,
355
+ target_step: ctx.stepCount,
356
+ revision_rounds: ctx.revisionRounds,
357
+ delta_summary: null,
358
+ },
359
+ };
360
+
361
+ this._signAndTransmit(ctx.sessionId, feedbackEnvelope);
362
+ }
363
+
272
364
  async _retryOfflineQueue() {
273
365
  if (!this._enabled || !this._transmissionQueue || this._transmissionQueue.offlineQueueSize === 0) return;
274
366
  try {
@@ -15,6 +15,32 @@ export function createIngestRoutes(verifier, storage, stitcher, scorer, enrichme
15
15
  return res.status(400).json({ accepted: false, reason: 'malformed request: missing envelope or session_id' });
16
16
  }
17
17
 
18
+ if (envelope.type === 'USER_FEEDBACK') {
19
+ const feedbackResult = verifier.verifyFeedback(envelope);
20
+ if (!feedbackResult.valid) {
21
+ return res.json({ accepted: false, reason: feedbackResult.reason });
22
+ }
23
+
24
+ const envelopeId = `env_${uuidv4()}`;
25
+ envelope.envelope_id = envelopeId;
26
+
27
+ if (sessionRegistry.isEnvelopeProcessed(envelopeId)) {
28
+ return res.json({ accepted: false, reason: 'duplicate envelope' });
29
+ }
30
+
31
+ try {
32
+ storage.store(envelope);
33
+ } catch (err) {
34
+ if (err.message === 'STORAGE_QUOTA_EXCEEDED') {
35
+ return res.status(507).json({ accepted: false, reason: 'storage quota exceeded' });
36
+ }
37
+ throw err;
38
+ }
39
+
40
+ sessionRegistry.recordProcessedEnvelope(envelopeId, envelope.session_id);
41
+ return res.json({ accepted: true, envelope_id: envelopeId });
42
+ }
43
+
18
44
  if (envelope.type === 'SESSION_CLOSE') {
19
45
  const closeResult = verifier.verifyClose(envelope);
20
46
  if (!closeResult.valid) {
@@ -99,4 +99,38 @@ export class EnvelopeVerifier {
99
99
  this.sessionRegistry.closeSession(sessionId);
100
100
  return { valid: true };
101
101
  }
102
+
103
+ verifyFeedback(envelope) {
104
+ const sessionId = envelope.session_id;
105
+ if (!sessionId) return { valid: false, reason: 'missing session_id' };
106
+
107
+ const session = this.sessionRegistry.getSession(sessionId);
108
+ if (!session) return { valid: false, reason: 'unknown session_id' };
109
+
110
+ const attestation = envelope.attestation;
111
+ if (!attestation) return { valid: false, reason: 'missing attestation' };
112
+
113
+ if (!attestation.session_hmac || typeof attestation.session_hmac !== 'string' || attestation.session_hmac.length === 0) {
114
+ return { valid: false, reason: 'empty or missing HMAC' };
115
+ }
116
+
117
+ const envelopeForHmac = { ...envelope };
118
+ delete envelopeForHmac.attestation;
119
+ const envelopeBytes = JSON.stringify(envelopeForHmac);
120
+
121
+ const hmacValid = verifyEnvelope(
122
+ session.shared_secret,
123
+ envelopeBytes,
124
+ attestation.sequence,
125
+ attestation.session_hmac
126
+ );
127
+ if (!hmacValid) return { valid: false, reason: 'HMAC verification failed' };
128
+
129
+ const schemaResult = validateEnvelope(envelope);
130
+ if (!schemaResult.valid) {
131
+ return { valid: false, reason: `schema validation failed: ${schemaResult.reason || schemaResult.errors?.join(', ')}` };
132
+ }
133
+
134
+ return { valid: true };
135
+ }
102
136
  }
@@ -13,6 +13,7 @@ export const MODEL_TIERS = {
13
13
  'claude-opus-4-7': 5,
14
14
  'claude-sonnet-4-6': 3,
15
15
  'gpt-4.5': 5,
16
+ 'gpt-5.5': 5,
16
17
  'o3': 5,
17
18
  'o4-mini': 2,
18
19
  'gemini-2.5-pro': 3,
@@ -27,4 +28,12 @@ export const QUALITY_MULTIPLIERS = {
27
28
  highQuality: 1.5,
28
29
  };
29
30
 
31
+ export const OBSERVATION_TOKEN_LIMIT = 4096;
32
+ export const TIER_A_MIN_QUALITY = 70;
33
+ export const TIER_B_MIN_QUALITY = 50;
34
+ export const TRAINING_MIN_STEPS = 5;
35
+ export const TRAINING_MIN_TOKENS = 500;
36
+ export const TRAINING_MIN_DURATION = 10;
37
+ export const TRAINING_EXCLUSION_REASONS = ['too_few_steps', 'no_actions', 'no_observations', 'insufficient_tokens', 'too_short'];
38
+
30
39
  export const CENTRAL_COMMAND_URL = process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai';