neurain 0.1.0-alpha.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 +19 -0
- package/LICENSE +57 -0
- package/README.md +205 -0
- package/SECURITY.md +22 -0
- package/bin/neurain.mjs +7 -0
- package/docs/comparison-mem0.en.md +22 -0
- package/docs/connect-claude.en.md +48 -0
- package/docs/connect-claude.kr.md +51 -0
- package/docs/connect-codex.en.md +38 -0
- package/docs/connect-codex.kr.md +40 -0
- package/docs/connect-gemini.en.md +71 -0
- package/docs/connect-gemini.kr.md +71 -0
- package/docs/connect-runtime.en.md +61 -0
- package/docs/connect-runtime.kr.md +61 -0
- package/docs/development-status.en.md +157 -0
- package/docs/development-status.kr.md +157 -0
- package/docs/knowledge-os.en.md +105 -0
- package/docs/knowledge-os.kr.md +106 -0
- package/docs/pricing.en.md +14 -0
- package/docs/privacy-and-data-flow.en.md +25 -0
- package/docs/public-saas-readiness.en.md +39 -0
- package/docs/quickstart.en.md +64 -0
- package/docs/quickstart.kr.md +64 -0
- package/docs/release-checklist.en.md +38 -0
- package/docs/safety.en.md +36 -0
- package/docs/self-improvement-90-roadmap.en.md +429 -0
- package/docs/self-improvement-90-roadmap.kr.md +429 -0
- package/docs/self-improving-workflows.en.md +163 -0
- package/docs/self-improving-workflows.kr.md +163 -0
- package/docs/support.en.md +17 -0
- package/docs/troubleshooting.en.md +35 -0
- package/package.json +36 -0
- package/src/cli.mjs +261 -0
- package/src/core/adopt.mjs +304 -0
- package/src/core/answer_eval.mjs +450 -0
- package/src/core/capabilities.mjs +217 -0
- package/src/core/capture_durable.mjs +181 -0
- package/src/core/classify.mjs +237 -0
- package/src/core/compile_desk.mjs +324 -0
- package/src/core/complete.mjs +108 -0
- package/src/core/config.mjs +142 -0
- package/src/core/connect.mjs +355 -0
- package/src/core/curator.mjs +351 -0
- package/src/core/daemon.mjs +536 -0
- package/src/core/digest.mjs +155 -0
- package/src/core/doctor.mjs +115 -0
- package/src/core/durable.mjs +96 -0
- package/src/core/envelope.mjs +97 -0
- package/src/core/flush.mjs +190 -0
- package/src/core/fs.mjs +121 -0
- package/src/core/init.mjs +194 -0
- package/src/core/journal.mjs +269 -0
- package/src/core/labels.mjs +117 -0
- package/src/core/lessons.mjs +793 -0
- package/src/core/lifecycle.mjs +1138 -0
- package/src/core/link_check.mjs +180 -0
- package/src/core/live_cases.mjs +221 -0
- package/src/core/onboard.mjs +175 -0
- package/src/core/plan_receipt.mjs +177 -0
- package/src/core/plan_writeback.mjs +176 -0
- package/src/core/queue.mjs +62 -0
- package/src/core/queue_archive.mjs +87 -0
- package/src/core/queue_model.mjs +161 -0
- package/src/core/queue_write.mjs +28 -0
- package/src/core/recall.mjs +1802 -0
- package/src/core/recall_bench.mjs +275 -0
- package/src/core/recall_corpus.mjs +152 -0
- package/src/core/recall_facts.mjs +233 -0
- package/src/core/recall_intel.mjs +233 -0
- package/src/core/recall_lexical.mjs +269 -0
- package/src/core/recap.mjs +78 -0
- package/src/core/review_queue.mjs +131 -0
- package/src/core/review_worker.mjs +284 -0
- package/src/core/route.mjs +73 -0
- package/src/core/safety.mjs +57 -0
- package/src/core/scheduler.mjs +697 -0
- package/src/core/search.mjs +54 -0
- package/src/core/secret_scan.mjs +143 -0
- package/src/core/semantic.mjs +187 -0
- package/src/core/source_digest.mjs +56 -0
- package/src/core/source_digest_gen.mjs +311 -0
- package/src/core/stage.mjs +105 -0
- package/src/core/status.mjs +175 -0
- package/src/core/vault_state.mjs +115 -0
- package/src/core/watch.mjs +282 -0
- package/src/core/wiki_log.mjs +29 -0
- package/src/core/wrap.mjs +62 -0
- package/src/mcp/server.mjs +865 -0
- package/templates/starter-vault/README.md +9 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Read-only review desk. Port of the vault review-queue: groups pending
|
|
2
|
+
// writeback-queue items into safe vs needs-confirmation, detects same-target
|
|
3
|
+
// conflicts, and ranks deep-compile candidates from the source-digest manifest.
|
|
4
|
+
// Private items never expose their file paths ([redacted:private], byte-identical
|
|
5
|
+
// to the vault). Writes nothing.
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { absPath } from './fs.mjs';
|
|
8
|
+
import { vaultConfig } from './config.mjs';
|
|
9
|
+
import { readJsonl } from './vault_state.mjs';
|
|
10
|
+
import { deepCompileCandidates } from './source_digest.mjs';
|
|
11
|
+
|
|
12
|
+
function inferTargetLayer(item) {
|
|
13
|
+
if (item.write_intent === 'update_current') return 'current';
|
|
14
|
+
if (item.write_intent === 'create_task') return 'task_memory';
|
|
15
|
+
if (item.write_intent === 'output_request') return 'output';
|
|
16
|
+
if (item.write_intent === 'evidence_only') return 'raw';
|
|
17
|
+
return 'wiki';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function inferFlushLevel(item) {
|
|
21
|
+
if (item.requires_user_decision || item.sensitivity === 'private') return 'full';
|
|
22
|
+
if (['update_current', 'create_task'].includes(item.write_intent)) return 'full';
|
|
23
|
+
if (item.write_intent === 'evidence_only') return 'light';
|
|
24
|
+
return 'standard';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function targetKeyFor(item) {
|
|
28
|
+
return `${item.target_layer || inferTargetLayer(item)}:${item.target_path || ''}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function detectConflicts(items) {
|
|
32
|
+
const byTarget = new Map();
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
if (!item.target_path) continue;
|
|
35
|
+
const key = targetKeyFor(item);
|
|
36
|
+
const rows = byTarget.get(key) || [];
|
|
37
|
+
rows.push(item);
|
|
38
|
+
byTarget.set(key, rows);
|
|
39
|
+
}
|
|
40
|
+
return [...byTarget.entries()]
|
|
41
|
+
.filter(([, rows]) => rows.length > 1)
|
|
42
|
+
.map(([target, rows]) => ({
|
|
43
|
+
target,
|
|
44
|
+
count: rows.length,
|
|
45
|
+
source_ids: rows.map((row) => row.source_id || row.queue_id || '').filter(Boolean),
|
|
46
|
+
policy: 'queue_first',
|
|
47
|
+
action: 'Ask user before applying any official update to this target.',
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function reviewReason(item, hasConflict) {
|
|
52
|
+
const reasons = [];
|
|
53
|
+
const targetLayer = item.target_layer || inferTargetLayer(item);
|
|
54
|
+
if (hasConflict) reasons.push('target conflict');
|
|
55
|
+
if (item.requires_user_decision) reasons.push('requires user decision');
|
|
56
|
+
if (item.sensitivity === 'private') reasons.push('private source');
|
|
57
|
+
if (['current', 'fact_ledger', 'task_memory'].includes(targetLayer)) reasons.push('official memory layer');
|
|
58
|
+
if (String(item.flush_level || inferFlushLevel(item)).toLowerCase() === 'full') reasons.push('permanent save (needs confirmation)');
|
|
59
|
+
return [...new Set(reasons)];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function summarizeQueueItem(item, reasons = []) {
|
|
63
|
+
const priv = item.sensitivity === 'private';
|
|
64
|
+
return {
|
|
65
|
+
source_id: item.source_id || '',
|
|
66
|
+
title: item.title || '',
|
|
67
|
+
session_id: item.session_id || '',
|
|
68
|
+
raw_path: priv ? '[redacted:private]' : (item.raw_path || ''),
|
|
69
|
+
target_layer: item.target_layer || inferTargetLayer(item),
|
|
70
|
+
target_path: priv ? '' : (item.target_path || ''),
|
|
71
|
+
sensitivity: item.sensitivity || 'internal',
|
|
72
|
+
flush_level: item.flush_level || inferFlushLevel(item),
|
|
73
|
+
reasons,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function reviewQueueCommand(args) {
|
|
78
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
79
|
+
const vaultCfg = vaultConfig(root);
|
|
80
|
+
const sessionId = String(args['session-id'] || '');
|
|
81
|
+
const queueRel = String(args.queue || vaultCfg.writeback_queue);
|
|
82
|
+
const top = Number(args.top || 12);
|
|
83
|
+
|
|
84
|
+
const pending = readJsonl(path.join(root, queueRel)).filter((row) => {
|
|
85
|
+
if (!row || row.status !== 'pending') return false;
|
|
86
|
+
return !sessionId || row.session_id === sessionId;
|
|
87
|
+
});
|
|
88
|
+
const conflicts = detectConflicts(pending);
|
|
89
|
+
const conflictKeys = new Set(conflicts.map((c) => c.target));
|
|
90
|
+
const safe = [];
|
|
91
|
+
const needsConfirmation = [];
|
|
92
|
+
for (const item of pending) {
|
|
93
|
+
const reasons = reviewReason(item, conflictKeys.has(targetKeyFor(item)));
|
|
94
|
+
const summary = summarizeQueueItem(item, reasons);
|
|
95
|
+
(reasons.length === 0 ? safe : needsConfirmation).push(summary);
|
|
96
|
+
}
|
|
97
|
+
const candidates = deepCompileCandidates(root, vaultCfg, { top });
|
|
98
|
+
|
|
99
|
+
const payload = {
|
|
100
|
+
ok: true,
|
|
101
|
+
command: 'review-queue',
|
|
102
|
+
durable_write: false,
|
|
103
|
+
queue: queueRel,
|
|
104
|
+
manifest: args.manifest || vaultCfg.source_digest_manifest,
|
|
105
|
+
session_id: sessionId || null,
|
|
106
|
+
pending_count: pending.length,
|
|
107
|
+
safe_count: safe.length,
|
|
108
|
+
needs_confirmation_count: needsConfirmation.length,
|
|
109
|
+
conflict_count: conflicts.length,
|
|
110
|
+
deep_compile_candidate_count: candidates.length,
|
|
111
|
+
safe,
|
|
112
|
+
needs_confirmation: needsConfirmation,
|
|
113
|
+
conflicts,
|
|
114
|
+
deep_compile_candidates: candidates,
|
|
115
|
+
user_explanation: 'This is the review desk. Safe items can move through normal Standard review. Confirmation items need user approval. Deep Compile candidates are important raw sources worth reading more carefully before official memory changes.',
|
|
116
|
+
};
|
|
117
|
+
if (args.json) return { json: true, payload };
|
|
118
|
+
return {
|
|
119
|
+
text: [
|
|
120
|
+
'# Review Queue',
|
|
121
|
+
'',
|
|
122
|
+
`- Pending: ${payload.pending_count}`,
|
|
123
|
+
`- Safe: ${payload.safe_count}`,
|
|
124
|
+
`- Needs confirmation: ${payload.needs_confirmation_count}`,
|
|
125
|
+
`- Conflicts: ${payload.conflict_count}`,
|
|
126
|
+
`- Deep Compile candidates: ${payload.deep_compile_candidate_count}`,
|
|
127
|
+
'',
|
|
128
|
+
payload.user_explanation,
|
|
129
|
+
].join('\n'),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { absPath, sha256 } from './fs.mjs';
|
|
2
|
+
import { lessonCandidates } from './lessons.mjs';
|
|
3
|
+
import { inferSensitivityFromPath } from './safety.mjs';
|
|
4
|
+
import { buildWatchReport } from './watch.mjs';
|
|
5
|
+
|
|
6
|
+
const REVIEW_EVENT_TYPES = new Set(['correction', 'review', 'rollback']);
|
|
7
|
+
|
|
8
|
+
export async function reviewCommand(args) {
|
|
9
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
10
|
+
if (args.apply || args.write || args.promote || args.daemon || args.follow) {
|
|
11
|
+
throw new Error('Public alpha supports review worker reports only. It cannot write, promote, follow, or run as a daemon.');
|
|
12
|
+
}
|
|
13
|
+
if (args['include-private']) {
|
|
14
|
+
throw new Error('Review worker reports are always private-clean. Use watch --include-private for local inspection only.');
|
|
15
|
+
}
|
|
16
|
+
const report = buildReviewWorkerReport(root, {
|
|
17
|
+
area: args.area || '',
|
|
18
|
+
top: Number(args.top || 10),
|
|
19
|
+
sinceMinutes: Number(args['since-minutes'] || 1440),
|
|
20
|
+
});
|
|
21
|
+
if (args.json) return { json: true, payload: report };
|
|
22
|
+
return {
|
|
23
|
+
text: [
|
|
24
|
+
'# Neurain review worker',
|
|
25
|
+
'',
|
|
26
|
+
`- Root: ${root}`,
|
|
27
|
+
`- Area: ${report.area || 'all'}`,
|
|
28
|
+
'- Durable write: no',
|
|
29
|
+
'- Model calls: no',
|
|
30
|
+
`- Review items: ${report.review_items.length}`,
|
|
31
|
+
`- Candidate proposals: ${report.candidate_proposals.length}`,
|
|
32
|
+
`- Blocked items: ${report.blocked_items.length}`,
|
|
33
|
+
'',
|
|
34
|
+
'## Suggested actions',
|
|
35
|
+
...(report.suggested_actions.length ? report.suggested_actions.map((item) => `- [${item.priority}] ${item.title}`) : ['- none']),
|
|
36
|
+
'',
|
|
37
|
+
'## Candidate proposals',
|
|
38
|
+
...(report.candidate_proposals.length ? report.candidate_proposals.map((item) => `- ${item.proposal_id}: ${item.title} (${item.decision})`) : ['- none']),
|
|
39
|
+
].join('\n'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildReviewWorkerReport(root, {
|
|
44
|
+
area = '',
|
|
45
|
+
top = 10,
|
|
46
|
+
sinceMinutes = 1440,
|
|
47
|
+
} = {}) {
|
|
48
|
+
const numericTop = Math.max(1, Math.min(Number(top || 10), 50));
|
|
49
|
+
const watch = buildWatchReport(root, {
|
|
50
|
+
area,
|
|
51
|
+
top: numericTop,
|
|
52
|
+
sinceMinutes,
|
|
53
|
+
pollOnce: true,
|
|
54
|
+
includePrivate: false,
|
|
55
|
+
});
|
|
56
|
+
const candidates = lessonCandidates(root, { limit: Math.min(numericTop, 10), area });
|
|
57
|
+
const candidateProposals = candidates.map((candidate) => proposalFromLessonCandidate(candidate));
|
|
58
|
+
const eventProposals = eventSequenceProposals(watch.recent_events, numericTop);
|
|
59
|
+
const reviewItems = [
|
|
60
|
+
...watch.review_triggers.map((trigger) => reviewItemFromTrigger(trigger)),
|
|
61
|
+
...candidateProposals.map((proposal) => reviewItemFromProposal(proposal)),
|
|
62
|
+
...eventProposals.map((proposal) => reviewItemFromProposal(proposal)),
|
|
63
|
+
];
|
|
64
|
+
const blockedItems = [
|
|
65
|
+
...candidateProposals.filter((proposal) => proposal.decision === 'blocked'),
|
|
66
|
+
...eventProposals.filter((proposal) => proposal.decision === 'blocked'),
|
|
67
|
+
...unsafeWatchEvents(watch),
|
|
68
|
+
];
|
|
69
|
+
const suggestedActions = suggestedActionsFor({ watch, candidateProposals, eventProposals, blockedItems });
|
|
70
|
+
|
|
71
|
+
const receipt = {
|
|
72
|
+
report_hash: sha256(stableJson({
|
|
73
|
+
watch: {
|
|
74
|
+
review_triggers: watch.review_triggers,
|
|
75
|
+
recent_events: watch.recent_events.map((event) => ({
|
|
76
|
+
event_id: event.event_id,
|
|
77
|
+
type: event.type,
|
|
78
|
+
redacted: event.redacted,
|
|
79
|
+
source_ids_withheld: event.source_ids_withheld,
|
|
80
|
+
})),
|
|
81
|
+
},
|
|
82
|
+
candidateProposals: candidateProposals.map((proposal) => proposal.proposal_id),
|
|
83
|
+
eventProposals: eventProposals.map((proposal) => proposal.proposal_id),
|
|
84
|
+
})),
|
|
85
|
+
source: 'deterministic-local-report',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
command: 'review',
|
|
91
|
+
root,
|
|
92
|
+
area: area || null,
|
|
93
|
+
durable_write: false,
|
|
94
|
+
model_calls: false,
|
|
95
|
+
external_tool_calls: false,
|
|
96
|
+
write_policy: 'read_only_review_report',
|
|
97
|
+
recursion_guard: {
|
|
98
|
+
enabled: true,
|
|
99
|
+
starts_nested_review_worker: false,
|
|
100
|
+
reason: 'review worker alpha reports only and performs no writes or model calls',
|
|
101
|
+
},
|
|
102
|
+
watch_report: watch,
|
|
103
|
+
review_items: reviewItems.slice(0, numericTop * 3),
|
|
104
|
+
candidate_proposals: candidateProposals,
|
|
105
|
+
event_sequence_proposals: eventProposals,
|
|
106
|
+
blocked_items: blockedItems,
|
|
107
|
+
suggested_actions: suggestedActions,
|
|
108
|
+
receipt,
|
|
109
|
+
next_step: suggestedActions.length
|
|
110
|
+
? 'Review suggested actions manually. Durable promotion still requires an explicit CLI confirmation phrase.'
|
|
111
|
+
: 'No review-worker action needed from the current local signals.',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function proposalFromLessonCandidate(candidate) {
|
|
116
|
+
const privateSource = (candidate.source_ids || []).some((source) => inferSensitivityFromPath(source) === 'private');
|
|
117
|
+
const unsafe = !candidate.safety?.promotion_allowed || candidate.max_sensitivity === 'private' || privateSource;
|
|
118
|
+
const usefulness = usefulnessForCandidate(candidate);
|
|
119
|
+
const risk = unsafe ? 90 : candidate.scope !== 'global' ? 65 : 25;
|
|
120
|
+
const areaScoped = candidate.scope !== 'global';
|
|
121
|
+
return {
|
|
122
|
+
proposal_id: `review-proposal-${sha256(`candidate:${candidate.id}:${candidate.candidate_hash}`).slice(0, 12)}`,
|
|
123
|
+
kind: 'lesson_candidate',
|
|
124
|
+
title: candidate.title,
|
|
125
|
+
source_candidate_ids: [candidate.id],
|
|
126
|
+
source_event_ids: [],
|
|
127
|
+
source_paths: unsafe ? [] : candidate.source_ids || [],
|
|
128
|
+
source_paths_withheld: unsafe,
|
|
129
|
+
usefulness_score: usefulness,
|
|
130
|
+
risk_score: risk,
|
|
131
|
+
decision: unsafe ? 'blocked' : 'manual_review',
|
|
132
|
+
blocked_reason: unsafe ? blockedReason(candidate, privateSource) : null,
|
|
133
|
+
recommendation: unsafe
|
|
134
|
+
? 'Do not promote. Sanitize or keep local/private until a human explicitly reclassifies it.'
|
|
135
|
+
: areaScoped
|
|
136
|
+
? 'Manual review allowed as an area-local lesson. Do not promote globally without reclassifying the source.'
|
|
137
|
+
: 'Manual review allowed. Promotion still requires `neurain lessons promote ... --confirm "1건 저장 진행"`.',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function eventSequenceProposals(events, limit) {
|
|
142
|
+
const reviewEvents = events.filter((event) => REVIEW_EVENT_TYPES.has(event.type)).slice(0, limit);
|
|
143
|
+
return reviewEvents.map((event) => {
|
|
144
|
+
const blocked = event.redacted || event.source_ids_withheld || !event.safety?.cross_host_allowed;
|
|
145
|
+
return {
|
|
146
|
+
proposal_id: `review-proposal-${sha256(`event:${event.event_id}:${event.type}`).slice(0, 12)}`,
|
|
147
|
+
kind: 'event_sequence',
|
|
148
|
+
title: titleForEvent(event),
|
|
149
|
+
source_candidate_ids: [],
|
|
150
|
+
source_event_ids: [event.event_id],
|
|
151
|
+
source_paths: blocked ? [] : event.source_ids || [],
|
|
152
|
+
source_paths_withheld: blocked,
|
|
153
|
+
usefulness_score: event.type === 'correction' || event.type === 'rollback' ? 85 : 70,
|
|
154
|
+
risk_score: blocked ? 85 : 30,
|
|
155
|
+
decision: blocked ? 'blocked' : 'manual_review',
|
|
156
|
+
blocked_reason: blocked ? 'event is private, unsafe, or withheld from prompt context' : null,
|
|
157
|
+
recommendation: blocked
|
|
158
|
+
? 'Keep this as a private review signal. Do not create a global lesson from it.'
|
|
159
|
+
: 'Review this event sequence for a possible lesson or capability routing hint.',
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function reviewItemFromTrigger(trigger) {
|
|
165
|
+
return {
|
|
166
|
+
item_id: `review-item-${sha256(`trigger:${trigger.id}:${trigger.reason}`).slice(0, 12)}`,
|
|
167
|
+
kind: 'trigger',
|
|
168
|
+
priority: trigger.priority,
|
|
169
|
+
title: trigger.reason,
|
|
170
|
+
source_event_ids: trigger.source_event_ids || [],
|
|
171
|
+
source_candidate_ids: trigger.source_candidate_ids || [],
|
|
172
|
+
source_paths: trigger.source_paths || [],
|
|
173
|
+
source_paths_withheld: Boolean(trigger.source_paths_withheld),
|
|
174
|
+
decision: trigger.id === 'unsafe-events' || trigger.id === 'journal-integrity' ? 'manual_review_required' : 'manual_review',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function reviewItemFromProposal(proposal) {
|
|
179
|
+
return {
|
|
180
|
+
item_id: `review-item-${proposal.proposal_id.slice(-12)}`,
|
|
181
|
+
kind: proposal.kind,
|
|
182
|
+
priority: proposal.decision === 'blocked' ? 'high' : proposal.usefulness_score >= 80 ? 'medium' : 'low',
|
|
183
|
+
title: proposal.title,
|
|
184
|
+
source_event_ids: proposal.source_event_ids,
|
|
185
|
+
source_candidate_ids: proposal.source_candidate_ids,
|
|
186
|
+
source_paths: proposal.source_paths,
|
|
187
|
+
source_paths_withheld: Boolean(proposal.source_paths_withheld),
|
|
188
|
+
decision: proposal.decision,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function unsafeWatchEvents(watch) {
|
|
193
|
+
return watch.recent_events
|
|
194
|
+
.filter((event) => event.redacted || event.source_ids_withheld || !event.safety?.cross_host_allowed)
|
|
195
|
+
.map((event) => ({
|
|
196
|
+
proposal_id: `blocked-event-${sha256(event.event_id).slice(0, 12)}`,
|
|
197
|
+
kind: 'watch_event',
|
|
198
|
+
title: `${event.type} event withheld from prompt context`,
|
|
199
|
+
source_event_ids: [event.event_id],
|
|
200
|
+
source_candidate_ids: [],
|
|
201
|
+
source_paths: [],
|
|
202
|
+
source_paths_withheld: true,
|
|
203
|
+
usefulness_score: 50,
|
|
204
|
+
risk_score: 90,
|
|
205
|
+
decision: 'blocked',
|
|
206
|
+
blocked_reason: 'event is private, redacted, or not cross-host safe',
|
|
207
|
+
recommendation: 'Keep as private/local review signal only.',
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function suggestedActionsFor({ watch, candidateProposals, eventProposals, blockedItems }) {
|
|
212
|
+
const actions = [];
|
|
213
|
+
if (!watch.journal_integrity.ok) {
|
|
214
|
+
actions.push({
|
|
215
|
+
id: 'fix-journal-integrity',
|
|
216
|
+
priority: 'high',
|
|
217
|
+
title: 'Inspect journal integrity before trusting review output',
|
|
218
|
+
command: 'neurain journal verify <folder> --json',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (blockedItems.length) {
|
|
222
|
+
actions.push({
|
|
223
|
+
id: 'review-blocked-private-or-unsafe-items',
|
|
224
|
+
priority: 'high',
|
|
225
|
+
title: `${blockedItems.length} blocked private or unsafe signal(s) need manual handling`,
|
|
226
|
+
command: 'neurain watch <folder> --poll-once --json',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const reviewableCandidates = candidateProposals.filter((proposal) => proposal.decision === 'manual_review');
|
|
230
|
+
if (reviewableCandidates.length) {
|
|
231
|
+
actions.push({
|
|
232
|
+
id: 'review-lesson-candidates',
|
|
233
|
+
priority: 'medium',
|
|
234
|
+
title: `${reviewableCandidates.length} lesson candidate proposal(s) are ready for manual review`,
|
|
235
|
+
command: 'neurain lessons candidates <folder> --json',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const reviewableEvents = eventProposals.filter((proposal) => proposal.decision === 'manual_review');
|
|
239
|
+
if (reviewableEvents.length) {
|
|
240
|
+
actions.push({
|
|
241
|
+
id: 'review-event-sequences',
|
|
242
|
+
priority: 'medium',
|
|
243
|
+
title: `${reviewableEvents.length} event-derived proposal(s) may become lessons or routing hints`,
|
|
244
|
+
command: 'neurain review <folder> --json',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (!actions.length && watch.review_triggers.length) {
|
|
248
|
+
actions.push({
|
|
249
|
+
id: 'inspect-watch-report',
|
|
250
|
+
priority: 'low',
|
|
251
|
+
title: 'Inspect watch report for low-priority local changes',
|
|
252
|
+
command: 'neurain watch <folder> --poll-once --json',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return actions;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function usefulnessForCandidate(candidate) {
|
|
259
|
+
if (candidate.lesson_type === 'verification') return 85;
|
|
260
|
+
if (candidate.lesson_type === 'safety') return 80;
|
|
261
|
+
if (candidate.lesson_type === 'tool_usage') return 75;
|
|
262
|
+
return 70;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function blockedReason(candidate, privateSource) {
|
|
266
|
+
if (candidate.max_sensitivity === 'private' || privateSource) return 'candidate is private or derived from a private path';
|
|
267
|
+
if (!candidate.safety?.promotion_allowed) return 'candidate failed safety gate';
|
|
268
|
+
if (candidate.scope !== 'global') return 'candidate is area-scoped and cannot become a global lesson automatically';
|
|
269
|
+
return 'candidate is blocked by review policy';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function titleForEvent(event) {
|
|
273
|
+
if (event.type === 'correction') return 'Review repeated correction for a possible lesson';
|
|
274
|
+
if (event.type === 'rollback') return 'Review rollback event for a safety lesson';
|
|
275
|
+
return 'Review event sequence for possible workflow improvement';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function stableJson(value) {
|
|
279
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
280
|
+
if (value && typeof value === 'object') {
|
|
281
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
282
|
+
}
|
|
283
|
+
return JSON.stringify(value);
|
|
284
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// `route` command (W-B, read-only). Classifies source text into area / domain /
|
|
2
|
+
// entity candidates, sensitivity, write intent, and a requires-user-decision
|
|
3
|
+
// flag, optionally enriched with a session's scope + handoff path. Writes nothing.
|
|
4
|
+
// Faithful port of the vault neurain-route tool; the route shape comes from the
|
|
5
|
+
// shared classify core so route, plan-writeback, and capture stay consistent.
|
|
6
|
+
import { absPath } from './fs.mjs';
|
|
7
|
+
import { vaultConfig } from './config.mjs';
|
|
8
|
+
import { loadSessionState } from './vault_state.mjs';
|
|
9
|
+
import { classifyText, firstLineTitle, normalizeAreaId } from './classify.mjs';
|
|
10
|
+
import { readArgInput } from './envelope.mjs';
|
|
11
|
+
|
|
12
|
+
function handoffPathFor(vaultCfg, sessionId, session) {
|
|
13
|
+
return (session && session.handoff_path) || `${vaultCfg.session_handoff_dir}/${sessionId}.md`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function routeCommand(args) {
|
|
17
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
18
|
+
const vaultCfg = vaultConfig(root);
|
|
19
|
+
const sessionId = String(args['session-id'] || '');
|
|
20
|
+
|
|
21
|
+
let session = null;
|
|
22
|
+
if (sessionId) {
|
|
23
|
+
let state = { sessions: {} };
|
|
24
|
+
try { state = loadSessionState(root, vaultCfg); } catch (error) {
|
|
25
|
+
return done(args, { ok: false, command: 'route', durable_write: false, error: error.message });
|
|
26
|
+
}
|
|
27
|
+
session = (state.sessions || {})[sessionId];
|
|
28
|
+
if (!session) return done(args, { ok: false, command: 'route', durable_write: false, error: `unknown session "${sessionId}"` });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const text = readArgInput(args, root);
|
|
32
|
+
if (!text.trim()) {
|
|
33
|
+
return done(args, { ok: false, command: 'route', durable_write: false, error: 'No input provided. Pass --text, positional text, or --file.' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const route = classifyText(root, text, {
|
|
37
|
+
area: normalizeAreaId(root, args.area || (session && session.area), vaultCfg),
|
|
38
|
+
sensitivity: args.sensitivity,
|
|
39
|
+
intent: args.intent,
|
|
40
|
+
}, { vaultCfg });
|
|
41
|
+
|
|
42
|
+
const payload = {
|
|
43
|
+
ok: true,
|
|
44
|
+
command: 'route',
|
|
45
|
+
durable_write: false,
|
|
46
|
+
title: args.title || firstLineTitle(text, 'Untitled route'),
|
|
47
|
+
source_type: args.type || 'memo',
|
|
48
|
+
...(session
|
|
49
|
+
? { session_id: sessionId, scope: session.scope || '', handoff_path: handoffPathFor(vaultCfg, sessionId, session) }
|
|
50
|
+
: {}),
|
|
51
|
+
...route,
|
|
52
|
+
};
|
|
53
|
+
return done(args, payload);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function done(args, payload) {
|
|
57
|
+
if (args.json) return { json: true, payload };
|
|
58
|
+
if (!payload.ok) return { text: `# Neurain Route\n\n- ${payload.error}` };
|
|
59
|
+
const lines = [
|
|
60
|
+
'# Neurain Route',
|
|
61
|
+
'',
|
|
62
|
+
`- Title: ${payload.title}`,
|
|
63
|
+
`- Source type: ${payload.source_type}`,
|
|
64
|
+
];
|
|
65
|
+
if (payload.session_id) lines.push(`- Session: ${payload.session_id}`);
|
|
66
|
+
lines.push(`- Areas: ${payload.area_candidates.join(', ') || 'none'}`);
|
|
67
|
+
lines.push(`- Domains: ${payload.domain_candidates.join(', ') || 'none'}`);
|
|
68
|
+
lines.push(`- Entities: ${payload.entity_candidates.join(', ') || 'none'}`);
|
|
69
|
+
lines.push(`- Sensitivity: ${payload.sensitivity}`);
|
|
70
|
+
lines.push(`- Write intent: ${payload.write_intent}`);
|
|
71
|
+
lines.push(`- Requires user decision: ${payload.requires_user_decision ? 'yes' : 'no'}`);
|
|
72
|
+
return { text: lines.join('\n') };
|
|
73
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function secretLike(text) {
|
|
2
|
+
const value = String(text || '');
|
|
3
|
+
if (/(api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key|password)\s*[:=]/i.test(value)) return 'credential pattern';
|
|
4
|
+
if (/-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/.test(value)) return 'private key block';
|
|
5
|
+
if (/\b(sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,})\b/.test(value)) return 'token-shaped value';
|
|
6
|
+
return '';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function injectionLike(text) {
|
|
10
|
+
const value = String(text || '').toLowerCase();
|
|
11
|
+
const patterns = [
|
|
12
|
+
/ignore (all )?(previous|prior) instructions/,
|
|
13
|
+
/disregard (all )?(previous|prior) instructions/,
|
|
14
|
+
/reveal (the )?(system|developer) prompt/,
|
|
15
|
+
/exfiltrate/,
|
|
16
|
+
/disable (safety|guardrails|policy)/,
|
|
17
|
+
/bypass (approval|permission|safety|guardrails)/,
|
|
18
|
+
/act as (an )?unrestricted/,
|
|
19
|
+
];
|
|
20
|
+
return patterns.find((pattern) => pattern.test(value)) ? 'instruction-injection pattern' : '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function inferSensitivityFromPath(relPath) {
|
|
24
|
+
return /(secret|credential|private|password|token|\.env|keychain|recovery)/i.test(String(relPath || ''))
|
|
25
|
+
? 'private'
|
|
26
|
+
: 'internal';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function maxSensitivity(values) {
|
|
30
|
+
return values.includes('private') ? 'private' : values.includes('internal') ? 'internal' : 'public';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function safePreview(text, limit = 220) {
|
|
34
|
+
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
if (!value) return '';
|
|
36
|
+
return value.slice(0, limit);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function redactedPreview(text, limit = 220) {
|
|
40
|
+
const value = String(text || '');
|
|
41
|
+
const secret = secretLike(value);
|
|
42
|
+
const injection = injectionLike(value);
|
|
43
|
+
if (secret || injection) {
|
|
44
|
+
return {
|
|
45
|
+
text: secret ? '[redacted secret-like evidence]' : '[redacted instruction-injection evidence]',
|
|
46
|
+
redacted: true,
|
|
47
|
+
secret_like: secret || null,
|
|
48
|
+
injection_like: injection || null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
text: safePreview(value, limit),
|
|
53
|
+
redacted: false,
|
|
54
|
+
secret_like: null,
|
|
55
|
+
injection_like: null,
|
|
56
|
+
};
|
|
57
|
+
}
|