rlhf-feedback-loop 0.5.0
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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/adapters/README.md +8 -0
- package/adapters/amp/skills/rlhf-feedback/SKILL.md +20 -0
- package/adapters/chatgpt/INSTALL.md +80 -0
- package/adapters/chatgpt/openapi.yaml +292 -0
- package/adapters/claude/.mcp.json +8 -0
- package/adapters/codex/config.toml +4 -0
- package/adapters/gemini/function-declarations.json +95 -0
- package/adapters/mcp/server-stdio.js +444 -0
- package/bin/cli.js +167 -0
- package/config/mcp-allowlists.json +29 -0
- package/config/policy-bundles/constrained-v1.json +53 -0
- package/config/policy-bundles/default-v1.json +80 -0
- package/config/rubrics/default-v1.json +52 -0
- package/config/subagent-profiles.json +32 -0
- package/openapi/openapi.yaml +292 -0
- package/package.json +91 -0
- package/plugins/amp-skill/INSTALL.md +52 -0
- package/plugins/amp-skill/SKILL.md +31 -0
- package/plugins/claude-skill/INSTALL.md +55 -0
- package/plugins/claude-skill/SKILL.md +46 -0
- package/plugins/codex-profile/AGENTS.md +20 -0
- package/plugins/codex-profile/INSTALL.md +57 -0
- package/plugins/gemini-extension/INSTALL.md +74 -0
- package/plugins/gemini-extension/gemini_prompt.txt +10 -0
- package/plugins/gemini-extension/tool_contract.json +28 -0
- package/scripts/billing.js +471 -0
- package/scripts/budget-guard.js +173 -0
- package/scripts/code-reasoning.js +307 -0
- package/scripts/context-engine.js +547 -0
- package/scripts/contextfs.js +513 -0
- package/scripts/contract-audit.js +198 -0
- package/scripts/dpo-optimizer.js +208 -0
- package/scripts/export-dpo-pairs.js +316 -0
- package/scripts/export-training.js +448 -0
- package/scripts/feedback-attribution.js +313 -0
- package/scripts/feedback-inbox-read.js +162 -0
- package/scripts/feedback-loop.js +838 -0
- package/scripts/feedback-schema.js +300 -0
- package/scripts/feedback-to-memory.js +165 -0
- package/scripts/feedback-to-rules.js +109 -0
- package/scripts/generate-paperbanana-diagrams.sh +99 -0
- package/scripts/hybrid-feedback-context.js +676 -0
- package/scripts/intent-router.js +164 -0
- package/scripts/mcp-policy.js +92 -0
- package/scripts/meta-policy.js +194 -0
- package/scripts/plan-gate.js +154 -0
- package/scripts/prove-adapters.js +364 -0
- package/scripts/prove-attribution.js +364 -0
- package/scripts/prove-automation.js +393 -0
- package/scripts/prove-data-quality.js +219 -0
- package/scripts/prove-intelligence.js +256 -0
- package/scripts/prove-lancedb.js +370 -0
- package/scripts/prove-loop-closure.js +255 -0
- package/scripts/prove-rlaif.js +404 -0
- package/scripts/prove-subway-upgrades.js +250 -0
- package/scripts/prove-training-export.js +324 -0
- package/scripts/prove-v2-milestone.js +273 -0
- package/scripts/prove-v3-milestone.js +381 -0
- package/scripts/rlaif-self-audit.js +123 -0
- package/scripts/rubric-engine.js +230 -0
- package/scripts/self-heal.js +127 -0
- package/scripts/self-healing-check.js +111 -0
- package/scripts/skill-quality-tracker.js +284 -0
- package/scripts/subagent-profiles.js +79 -0
- package/scripts/sync-gh-secrets-from-env.sh +29 -0
- package/scripts/thompson-sampling.js +331 -0
- package/scripts/train_from_feedback.py +914 -0
- package/scripts/validate-feedback.js +580 -0
- package/scripts/vector-store.js +100 -0
- package/src/api/server.js +497 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// rlhf: scripts/ is 1 level below repo root (not 2 like Subway .claude/scripts/feedback/)
|
|
8
|
+
const ROOT = path.join(__dirname, '..');
|
|
9
|
+
const PATHS = {
|
|
10
|
+
actionLog: path.join(ROOT, '.claude', 'memory', 'feedback', 'action-log.jsonl'),
|
|
11
|
+
attributions: path.join(ROOT, '.claude', 'memory', 'feedback', 'feedback-attributions.jsonl'),
|
|
12
|
+
attributedFeedback: path.join(ROOT, '.claude', 'memory', 'feedback', 'attributed-feedback.jsonl'),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const STOPWORDS = new Set([
|
|
16
|
+
'about', 'after', 'again', 'allow', 'already', 'always', 'because', 'before', 'being', 'between',
|
|
17
|
+
'could', 'does', 'done', 'each', 'ensure', 'every', 'from', 'have', 'into', 'just', 'make', 'more',
|
|
18
|
+
'most', 'must', 'never', 'only', 'other', 'over', 'repeat', 'same', 'should', 'since', 'that',
|
|
19
|
+
'their', 'them', 'then', 'there', 'these', 'they', 'this', 'under', 'until', 'very', 'what', 'when',
|
|
20
|
+
'where', 'which', 'while', 'with', 'without', 'would', 'thumbs', 'down', 'up', 'please', 'avoid',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function readJsonl(filePath, maxLines = 500) {
|
|
24
|
+
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
25
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
26
|
+
const out = [];
|
|
27
|
+
for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i -= 1) {
|
|
28
|
+
const line = lines[i].trim();
|
|
29
|
+
if (!line) continue;
|
|
30
|
+
try {
|
|
31
|
+
out.push(JSON.parse(line));
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore malformed jsonl lines
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out.reverse();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function appendJsonl(filePath, obj) {
|
|
40
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
41
|
+
fs.appendFileSync(filePath, `${JSON.stringify(obj)}\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalize(text) {
|
|
45
|
+
return String(text || '')
|
|
46
|
+
.replace(/\/Users\/[^\s/]+/g, '~')
|
|
47
|
+
.replace(/:[0-9]+/g, '')
|
|
48
|
+
.replace(/\s+/g, ' ')
|
|
49
|
+
.trim()
|
|
50
|
+
.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stripFeedbackPrefix(text) {
|
|
54
|
+
return String(text || '')
|
|
55
|
+
.replace(/^\s*thumbs?[ -]?down\s*[:\-]?\s*/i, '')
|
|
56
|
+
.replace(/^\s*thumbs?[ -]?up\s*[:\-]?\s*/i, '')
|
|
57
|
+
.replace(/^\s*(negative|positive)\s+feedback\s*[:\-]?\s*/i, '')
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tokenize(text) {
|
|
62
|
+
return [...new Set(
|
|
63
|
+
normalize(text)
|
|
64
|
+
.split(/[^a-z0-9]+/)
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.filter(w => w.length >= 3 && !STOPWORDS.has(w))
|
|
67
|
+
)].slice(0, 24);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function nowIso() {
|
|
71
|
+
return new Date().toISOString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hashText(text) {
|
|
75
|
+
let h = 2166136261;
|
|
76
|
+
const str = String(text || '');
|
|
77
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
78
|
+
h ^= str.charCodeAt(i);
|
|
79
|
+
h = Math.imul(h, 16777619);
|
|
80
|
+
}
|
|
81
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function summarizeToolInput(toolName, toolInput) {
|
|
85
|
+
const tool = String(toolName || 'unknown');
|
|
86
|
+
const input = (typeof toolInput === 'string') ? toolInput : JSON.stringify(toolInput || {});
|
|
87
|
+
if (tool === 'Bash') {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput;
|
|
90
|
+
if (parsed && typeof parsed.command === 'string') {
|
|
91
|
+
return parsed.command;
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
return input;
|
|
95
|
+
}
|
|
96
|
+
if (tool === 'Edit' || tool === 'Write') {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput;
|
|
99
|
+
const p = parsed?.file_path || parsed?.path || parsed?.filePath || '';
|
|
100
|
+
if (p) return `${tool} ${p}`;
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
return input;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function inferIntent(toolName, text) {
|
|
107
|
+
const t = normalize(text);
|
|
108
|
+
if (/git\s+push|--force|git\s+reset|git\s+checkout/.test(t)) return 'git-risk';
|
|
109
|
+
if (/rm\s+-rf|sudo|chmod\s+777|chown\s+-r/.test(t)) return 'destructive-shell';
|
|
110
|
+
if (/\.env|secret|token|credential/.test(t)) return 'sensitive-data';
|
|
111
|
+
if (toolName === 'Edit' || toolName === 'Write') return 'file-change';
|
|
112
|
+
if (toolName === 'Bash') return 'shell-command';
|
|
113
|
+
return 'general';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function riskScore(toolName, text) {
|
|
117
|
+
const t = normalize(text);
|
|
118
|
+
let score = 2;
|
|
119
|
+
if (toolName === 'Bash') score += 2;
|
|
120
|
+
if (toolName === 'Edit' || toolName === 'Write') score += 1;
|
|
121
|
+
if (/--force|rm\s+-rf|reset\s+--hard|chmod\s+777|sudo/.test(t)) score += 4;
|
|
122
|
+
if (/\.env|secret|token|credential/.test(t)) score += 3;
|
|
123
|
+
return Math.max(1, Math.min(10, score));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function overlapScore(tokensA, tokensB) {
|
|
127
|
+
const a = new Set(tokensA || []);
|
|
128
|
+
const b = new Set(tokensB || []);
|
|
129
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
130
|
+
let inter = 0;
|
|
131
|
+
for (const w of a) {
|
|
132
|
+
if (b.has(w)) inter += 1;
|
|
133
|
+
}
|
|
134
|
+
const union = a.size + b.size - inter;
|
|
135
|
+
return union > 0 ? inter / union : 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function scoreCandidate(action, feedbackNormalized, feedbackTokens, nowMs) {
|
|
139
|
+
const ts = Date.parse(action.timestamp || '') || nowMs;
|
|
140
|
+
const ageMs = Math.max(0, nowMs - ts);
|
|
141
|
+
const recency = Math.exp(-ageMs / (6 * 60 * 1000));
|
|
142
|
+
const actionTokens = Array.isArray(action.keywords) && action.keywords.length > 0
|
|
143
|
+
? action.keywords
|
|
144
|
+
: tokenize(action.normalized_input || action.input || '');
|
|
145
|
+
const lexical = overlapScore(feedbackTokens, actionTokens);
|
|
146
|
+
const containment = feedbackNormalized && normalize(action.normalized_input || '').includes(feedbackNormalized) ? 1 : 0;
|
|
147
|
+
const risk = Number(action.risk_score || 0) / 10;
|
|
148
|
+
let score = 0.45 * lexical + 0.30 * recency + 0.20 * risk + 0.05 * containment;
|
|
149
|
+
|
|
150
|
+
const f = feedbackNormalized;
|
|
151
|
+
if (/\bgit\b|\bpush\b|\bforce\b/.test(f) && action.tool_name === 'Bash') score += 0.1;
|
|
152
|
+
if (/\bedit\b|\bwrite\b|\bfile\b/.test(f) && (action.tool_name === 'Edit' || action.tool_name === 'Write')) score += 0.1;
|
|
153
|
+
|
|
154
|
+
return Math.max(0, Math.min(1, score));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function recordAction(toolName, toolInput, opts = {}) {
|
|
158
|
+
const actionLogPath = opts.actionLogPath || process.env.RLHF_ACTION_LOG || PATHS.actionLog;
|
|
159
|
+
const tool = String(toolName || 'unknown');
|
|
160
|
+
const inputSummary = summarizeToolInput(tool, toolInput);
|
|
161
|
+
const normalized = normalize(inputSummary);
|
|
162
|
+
const row = {
|
|
163
|
+
action_id: `act_${Date.now()}_${hashText(`${tool}|${normalized}`)}`,
|
|
164
|
+
timestamp: nowIso(),
|
|
165
|
+
tool_name: tool,
|
|
166
|
+
input: inputSummary,
|
|
167
|
+
normalized_input: normalized,
|
|
168
|
+
intent: inferIntent(tool, inputSummary),
|
|
169
|
+
keywords: tokenize(inputSummary),
|
|
170
|
+
risk_score: riskScore(tool, inputSummary),
|
|
171
|
+
};
|
|
172
|
+
appendJsonl(actionLogPath, row);
|
|
173
|
+
return { ok: true, action: row, actionLogPath };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function attributeFeedback(signal, feedbackContext, opts = {}) {
|
|
177
|
+
const sig = String(signal || '').toLowerCase().trim();
|
|
178
|
+
const actionLogPath = opts.actionLogPath || process.env.RLHF_ACTION_LOG || PATHS.actionLog;
|
|
179
|
+
const attributionsPath = opts.attributionsPath || process.env.RLHF_FEEDBACK_ATTRIBUTIONS || PATHS.attributions;
|
|
180
|
+
const attributedFeedbackPath = opts.attributedFeedbackPath || process.env.RLHF_ATTRIBUTED_FEEDBACK || PATHS.attributedFeedback;
|
|
181
|
+
|
|
182
|
+
if (sig !== 'negative' && sig !== 'positive') {
|
|
183
|
+
return { ok: true, skipped: true, reason: 'signal_not_supported' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const nowMs = Date.now();
|
|
187
|
+
const feedbackText = stripFeedbackPrefix(feedbackContext);
|
|
188
|
+
const feedbackNormalized = normalize(feedbackText);
|
|
189
|
+
const feedbackTokens = tokenize(feedbackText);
|
|
190
|
+
const windowMs = Number(opts.windowMs || process.env.RLHF_ATTRIBUTION_WINDOW_MS || (20 * 60 * 1000));
|
|
191
|
+
const maxActions = Number(opts.maxActions || 80);
|
|
192
|
+
const topK = Number(opts.topK || 3);
|
|
193
|
+
const threshold = Number(opts.threshold || (sig === 'negative' ? 0.33 : 0.38));
|
|
194
|
+
|
|
195
|
+
const actions = readJsonl(actionLogPath, maxActions)
|
|
196
|
+
.filter(a => {
|
|
197
|
+
const ts = Date.parse(a.timestamp || '') || nowMs;
|
|
198
|
+
return (nowMs - ts) <= windowMs;
|
|
199
|
+
})
|
|
200
|
+
.reverse();
|
|
201
|
+
|
|
202
|
+
const scored = actions.map(action => {
|
|
203
|
+
const confidence = scoreCandidate(action, feedbackNormalized, feedbackTokens, nowMs);
|
|
204
|
+
return { action, confidence };
|
|
205
|
+
}).sort((a, b) => b.confidence - a.confidence);
|
|
206
|
+
|
|
207
|
+
const selected = scored.filter(s => s.confidence >= threshold).slice(0, topK);
|
|
208
|
+
|
|
209
|
+
const attribution = {
|
|
210
|
+
attribution_id: `att_${Date.now()}_${hashText(`${sig}|${feedbackNormalized}`)}`,
|
|
211
|
+
timestamp: nowIso(),
|
|
212
|
+
signal: sig,
|
|
213
|
+
feedback_context: feedbackContext,
|
|
214
|
+
feedback_normalized: feedbackNormalized,
|
|
215
|
+
threshold,
|
|
216
|
+
candidates_considered: scored.length,
|
|
217
|
+
attributed_actions: selected.map(s => ({
|
|
218
|
+
action_id: s.action.action_id,
|
|
219
|
+
tool_name: s.action.tool_name,
|
|
220
|
+
input: s.action.input,
|
|
221
|
+
normalized_input: s.action.normalized_input,
|
|
222
|
+
intent: s.action.intent,
|
|
223
|
+
confidence: Number(s.confidence.toFixed(4)),
|
|
224
|
+
risk_score: s.action.risk_score,
|
|
225
|
+
})),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
appendJsonl(attributionsPath, attribution);
|
|
229
|
+
|
|
230
|
+
if (sig === 'negative') {
|
|
231
|
+
for (const s of selected) {
|
|
232
|
+
appendJsonl(attributedFeedbackPath, {
|
|
233
|
+
timestamp: attribution.timestamp,
|
|
234
|
+
signal: sig,
|
|
235
|
+
feedback: sig,
|
|
236
|
+
source: 'attributed',
|
|
237
|
+
source_detail: 'feedback-attribution-engine',
|
|
238
|
+
context: s.action.input,
|
|
239
|
+
tool_name: s.action.tool_name,
|
|
240
|
+
attributed_action_id: s.action.action_id,
|
|
241
|
+
attribution_id: attribution.attribution_id,
|
|
242
|
+
confidence: Number(s.confidence.toFixed(4)),
|
|
243
|
+
intent: s.action.intent,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
signal: sig,
|
|
251
|
+
attributedCount: selected.length,
|
|
252
|
+
topConfidence: selected.length ? Number(selected[0].confidence.toFixed(4)) : 0,
|
|
253
|
+
attributionId: attribution.attribution_id,
|
|
254
|
+
actionLogPath,
|
|
255
|
+
attributionsPath,
|
|
256
|
+
attributedFeedbackPath,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function main() {
|
|
261
|
+
const args = process.argv.slice(2);
|
|
262
|
+
const recordIdx = args.indexOf('--record-action');
|
|
263
|
+
const attrIdx = args.indexOf('--attribute');
|
|
264
|
+
|
|
265
|
+
if (recordIdx >= 0) {
|
|
266
|
+
const toolName = args[recordIdx + 1] || 'unknown';
|
|
267
|
+
const toolInput = args[recordIdx + 2] || '{}';
|
|
268
|
+
const result = recordAction(toolName, toolInput);
|
|
269
|
+
process.stdout.write(JSON.stringify(result, null, 2));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (attrIdx >= 0) {
|
|
274
|
+
const signal = args[attrIdx + 1] || '';
|
|
275
|
+
const context = args[attrIdx + 2] || '';
|
|
276
|
+
const result = attributeFeedback(signal, context);
|
|
277
|
+
process.stdout.write(JSON.stringify(result, null, 2));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
process.stdout.write(JSON.stringify({
|
|
282
|
+
ok: true,
|
|
283
|
+
usage: [
|
|
284
|
+
'--record-action <toolName> <toolInputJson>',
|
|
285
|
+
'--attribute <signal> <feedbackContext>',
|
|
286
|
+
],
|
|
287
|
+
}, null, 2));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (require.main === module) {
|
|
291
|
+
try {
|
|
292
|
+
main();
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.error(`[ATTRIBUTION WARNING] ${error.message}`);
|
|
295
|
+
process.exit(0);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
PATHS,
|
|
301
|
+
readJsonl,
|
|
302
|
+
appendJsonl,
|
|
303
|
+
normalize,
|
|
304
|
+
stripFeedbackPrefix,
|
|
305
|
+
tokenize,
|
|
306
|
+
inferIntent,
|
|
307
|
+
riskScore,
|
|
308
|
+
summarizeToolInput,
|
|
309
|
+
overlapScore,
|
|
310
|
+
scoreCandidate,
|
|
311
|
+
recordAction,
|
|
312
|
+
attributeFeedback,
|
|
313
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Feedback Inbox Reader
|
|
4
|
+
*
|
|
5
|
+
* Reads new feedback entries from the inbox JSONL file, using a cursor
|
|
6
|
+
* to avoid reprocessing. External systems (Phoenix bridge, other agents)
|
|
7
|
+
* append feedback signals to the inbox; Amp's reflexion-preflight skill
|
|
8
|
+
* calls this script each turn to ingest new signals.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/feedback-inbox-read.js # output new entries as JSON array
|
|
12
|
+
* node scripts/feedback-inbox-read.js --peek # show count without advancing cursor
|
|
13
|
+
* node scripts/feedback-inbox-read.js --reset # reset cursor to re-read all
|
|
14
|
+
* node scripts/feedback-inbox-read.js --test # run built-in tests
|
|
15
|
+
*
|
|
16
|
+
* Inbox: .claude/feedback-loop/inbox.jsonl
|
|
17
|
+
* Cursor: .claude/feedback-loop/inbox.cursor.json
|
|
18
|
+
*/
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
const PROJECT_ROOT = path.join(__dirname, '..');
|
|
25
|
+
const INBOX_PATH = path.join(PROJECT_ROOT, '.claude', 'feedback-loop', 'inbox.jsonl');
|
|
26
|
+
const CURSOR_PATH = path.join(PROJECT_ROOT, '.claude', 'feedback-loop', 'inbox.cursor.json');
|
|
27
|
+
|
|
28
|
+
function readInbox() {
|
|
29
|
+
if (!fs.existsSync(INBOX_PATH)) return [];
|
|
30
|
+
const raw = fs.readFileSync(INBOX_PATH, 'utf-8').trim();
|
|
31
|
+
if (!raw) return [];
|
|
32
|
+
return raw.split('\n').map((line, idx) => {
|
|
33
|
+
try {
|
|
34
|
+
return { _lineIndex: idx, ...JSON.parse(line) };
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadCursor() {
|
|
42
|
+
if (!fs.existsSync(CURSOR_PATH)) return { lastLineIndex: -1 };
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(fs.readFileSync(CURSOR_PATH, 'utf-8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return { lastLineIndex: -1 };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveCursor(cursor) {
|
|
51
|
+
const dir = path.dirname(CURSOR_PATH);
|
|
52
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
fs.writeFileSync(CURSOR_PATH, JSON.stringify(cursor, null, 2) + '\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getNewEntries(advance) {
|
|
57
|
+
const entries = readInbox();
|
|
58
|
+
if (entries.length === 0) return [];
|
|
59
|
+
|
|
60
|
+
const cursor = loadCursor();
|
|
61
|
+
const newEntries = entries.filter((e) => e._lineIndex > cursor.lastLineIndex);
|
|
62
|
+
|
|
63
|
+
if (advance && newEntries.length > 0) {
|
|
64
|
+
const maxIdx = Math.max(...newEntries.map((e) => e._lineIndex));
|
|
65
|
+
saveCursor({ lastLineIndex: maxIdx, updatedAt: new Date().toISOString() });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Strip internal _lineIndex before returning
|
|
69
|
+
return newEntries.map(({ _lineIndex, ...rest }) => rest);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// CLI
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function runTests() {
|
|
77
|
+
const os = require('os');
|
|
78
|
+
let passed = 0;
|
|
79
|
+
let failed = 0;
|
|
80
|
+
|
|
81
|
+
function assert(condition, name) {
|
|
82
|
+
if (condition) { passed++; console.log(` โ
${name}`); }
|
|
83
|
+
else { failed++; console.log(` โ ${name}`); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('\n๐งช feedback-inbox-read.js โ Tests\n');
|
|
87
|
+
|
|
88
|
+
// Setup temp inbox
|
|
89
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inbox-test-'));
|
|
90
|
+
const tmpInbox = path.join(tmpDir, 'inbox.jsonl');
|
|
91
|
+
const tmpCursor = path.join(tmpDir, 'inbox.cursor.json');
|
|
92
|
+
|
|
93
|
+
// Write test entries
|
|
94
|
+
const entries = [
|
|
95
|
+
{ signal: 'negative', context: 'Bad thing happened', tags: ['testing'] },
|
|
96
|
+
{ signal: 'positive', context: 'Good thing happened', tags: ['testing'] },
|
|
97
|
+
{ signal: 'negative', context: 'Another bad thing', tags: ['rlhf'] },
|
|
98
|
+
];
|
|
99
|
+
fs.writeFileSync(tmpInbox, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
100
|
+
|
|
101
|
+
// Test reading all (no cursor)
|
|
102
|
+
const allEntries = (() => {
|
|
103
|
+
const raw = fs.readFileSync(tmpInbox, 'utf-8').trim();
|
|
104
|
+
return raw.split('\n').map((line, idx) => {
|
|
105
|
+
try { return { _lineIndex: idx, ...JSON.parse(line) }; }
|
|
106
|
+
catch { return null; }
|
|
107
|
+
}).filter(Boolean);
|
|
108
|
+
})();
|
|
109
|
+
assert(allEntries.length === 3, 'reads all 3 entries from inbox');
|
|
110
|
+
|
|
111
|
+
// Test cursor-based filtering
|
|
112
|
+
fs.writeFileSync(tmpCursor, JSON.stringify({ lastLineIndex: 0 }));
|
|
113
|
+
const afterFirst = allEntries.filter((e) => e._lineIndex > 0);
|
|
114
|
+
assert(afterFirst.length === 2, 'cursor at 0 โ 2 new entries');
|
|
115
|
+
|
|
116
|
+
fs.writeFileSync(tmpCursor, JSON.stringify({ lastLineIndex: 2 }));
|
|
117
|
+
const afterAll = allEntries.filter((e) => e._lineIndex > 2);
|
|
118
|
+
assert(afterAll.length === 0, 'cursor at 2 โ 0 new entries');
|
|
119
|
+
|
|
120
|
+
// Test loadCursor with missing file
|
|
121
|
+
const missingCursor = path.join(tmpDir, 'missing.cursor.json');
|
|
122
|
+
const fakeMod = { loadCursor: () => {
|
|
123
|
+
if (!fs.existsSync(missingCursor)) return { lastLineIndex: -1 };
|
|
124
|
+
try { return JSON.parse(fs.readFileSync(missingCursor, 'utf-8')); } catch { return { lastLineIndex: -1 }; }
|
|
125
|
+
}};
|
|
126
|
+
assert(fakeMod.loadCursor().lastLineIndex === -1, 'loadCursor returns -1 for missing file');
|
|
127
|
+
|
|
128
|
+
// Test saveCursor creates directory
|
|
129
|
+
const deepDir = path.join(tmpDir, 'deep', 'nested');
|
|
130
|
+
const deepCursor = path.join(deepDir, 'cursor.json');
|
|
131
|
+
if (!fs.existsSync(deepDir)) fs.mkdirSync(deepDir, { recursive: true });
|
|
132
|
+
fs.writeFileSync(deepCursor, JSON.stringify({ lastLineIndex: 5, updatedAt: new Date().toISOString() }) + '\n');
|
|
133
|
+
const loaded = JSON.parse(fs.readFileSync(deepCursor, 'utf-8'));
|
|
134
|
+
assert(loaded.lastLineIndex === 5, 'saveCursor persists cursor value');
|
|
135
|
+
|
|
136
|
+
// Cleanup
|
|
137
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
138
|
+
|
|
139
|
+
console.log(`\n${'โ'.repeat(50)}`);
|
|
140
|
+
console.log(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total`);
|
|
141
|
+
console.log(`${'โ'.repeat(50)}\n`);
|
|
142
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (require.main === module) {
|
|
146
|
+
if (process.argv.includes('--test')) {
|
|
147
|
+
runTests();
|
|
148
|
+
} else if (process.argv.includes('--reset')) {
|
|
149
|
+
if (fs.existsSync(CURSOR_PATH)) fs.unlinkSync(CURSOR_PATH);
|
|
150
|
+
console.log('Cursor reset.');
|
|
151
|
+
} else {
|
|
152
|
+
const peek = process.argv.includes('--peek');
|
|
153
|
+
const entries = getNewEntries(!peek);
|
|
154
|
+
if (entries.length === 0) {
|
|
155
|
+
// Silent โ no new entries
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
process.stdout.write(JSON.stringify(entries, null, 2) + '\n');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { getNewEntries, readInbox, loadCursor, saveCursor, INBOX_PATH, CURSOR_PATH };
|