ruvnet-brain 4.6.1 → 4.6.3
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/README.md +4 -3
- package/bin/nightly-refresh.mjs +5 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/agentdb-recall-process.mjs +118 -0
- package/plugin/scripts/agentdb-recall-reader.mjs +22 -0
- package/plugin/scripts/agentdb-recall.mjs +82 -99
- package/plugin/scripts/continuity-events.mjs +6 -5
- package/plugin/scripts/continuity-journal.mjs +22 -16
- package/plugin/scripts/developer-update-maintenance.mjs +133 -6
- package/plugin/scripts/developer-update.mjs +37 -7
- package/plugin/scripts/native-turn-transcript.mjs +51 -0
- package/plugin/scripts/nightly-scheduler.mjs +27 -6
- package/plugin/scripts/project-capture-queue.mjs +2 -0
- package/plugin/scripts/project-memory-enrollment.mjs +203 -0
- package/plugin/scripts/project-progression-reader.mjs +4 -4
- package/plugin/scripts/project-progression-session-start.mjs +7 -27
- package/plugin/scripts/project-progression-store.mjs +18 -6
- package/plugin/scripts/project-store-resolver.mjs +44 -6
- package/plugin/scripts/session-snapshot-hook.mjs +7 -0
- package/plugin/scripts/session-start-core.mjs +6 -0
- package/plugin/scripts/turn-outcome-capture.mjs +33 -19
- package/plugin/scripts/turn-transport-journal.mjs +3 -1
- package/scripts/release-qualification-contract.mjs +3 -3
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Updated: 2026-10-10 10:41:47 EDT | Version 4.6.
|
|
1
|
+
Updated: 2026-10-10 10:41:47 EDT | Version 4.6.2
|
|
2
2
|
Created: 2026-06-29 22:36:38 EDT
|
|
3
3
|
|
|
4
4
|
<div align="center">
|
|
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
9
9
|
|
|
10
10
|
### Discover the stack. Read the source. Build with confidence.
|
|
11
11
|
|
|
12
|
-
[](plugin/.claude-plugin/plugin.json)
|
|
13
13
|
|
|
14
14
|
**A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
|
|
15
15
|
|
|
@@ -259,7 +259,8 @@ The **4.6.0 published-corpus baseline** is **199 public stores · 162,680 public
|
|
|
259
259
|
Those are bundle census values, not a claim that every upstream repository is fresh today.
|
|
260
260
|
Later corpus publication may advance the knowledge generation independently of code changes.
|
|
261
261
|
|
|
262
|
-
See [4.6.
|
|
262
|
+
See [4.6.2 native update repair notes](docs/RELEASE-NOTES-4.6.2.md),
|
|
263
|
+
[4.6.1 README/admin notes](docs/RELEASE-NOTES-4.6.1.md) and
|
|
263
264
|
[4.6.0 coordinated-update notes](docs/RELEASE-NOTES-4.6.0.md) and the authoritative
|
|
264
265
|
[update and release policy](CONTRIBUTING.md).
|
|
265
266
|
|
package/bin/nightly-refresh.mjs
CHANGED
|
@@ -80,7 +80,11 @@ try {
|
|
|
80
80
|
if (registration.mode === 'developer-suite') {
|
|
81
81
|
const coordinator = await import(pathToFileURL(modules['developer-update.mjs'].path).href);
|
|
82
82
|
const receipt = await coordinator.runDeveloperUpdate({ mode: 'apply', env: { ...process.env,
|
|
83
|
-
RUVNET_NIGHTLY_IDENTITY: registration.identity, RUVNET_NIGHTLY: '1'
|
|
83
|
+
RUVNET_NIGHTLY_IDENTITY: registration.identity, RUVNET_NIGHTLY: '1',
|
|
84
|
+
RUVNET_NIGHTLY_REGISTRATION: registrationPath,
|
|
85
|
+
RUVNET_NIGHTLY_NODE_PATH: registration.nodePath,
|
|
86
|
+
RUVNET_NIGHTLY_RUNNER_PATH: registration.runnerPath,
|
|
87
|
+
RUVNET_NIGHTLY_RUNNER_SHA256: registration.runnerSha256 } });
|
|
84
88
|
console.log(JSON.stringify(receipt));
|
|
85
89
|
process.exit(0);
|
|
86
90
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.6.
|
|
3
|
+
"version": "4.6.3",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.6.
|
|
4
|
+
"version": "4.6.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Owned, deadline-bound native reads and global Ruflo commands for canonical recall. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { rufloInvocation } from './ruflo-bin.mjs';
|
|
7
|
+
export function searchJsonResult(stdout) {
|
|
8
|
+
const s = String(stdout || '');
|
|
9
|
+
// Live Ruflo prints warnings AFTER the JSON object as well as logs before it.
|
|
10
|
+
const start = s.indexOf('{'); const end = s.lastIndexOf('}');
|
|
11
|
+
if (start < 0 || end < start) return { valid: false, rows: [] };
|
|
12
|
+
try { const rows = JSON.parse(s.slice(start, end + 1)).results;
|
|
13
|
+
return Array.isArray(rows) && rows.every(r => r && typeof r.key === 'string' && r.key)
|
|
14
|
+
? { valid: true, rows } : { valid: false, rows: [] };
|
|
15
|
+
} catch { return { valid: false, rows: [] }; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function killGroup(child) {
|
|
19
|
+
try { if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL'); else child.kill('SIGKILL'); }
|
|
20
|
+
catch { try { child.kill('SIGKILL'); } catch { /* already gone */ } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One ruflo search, bounded by an absolute deadline. Resolves { rows, state } — never rejects. */
|
|
24
|
+
export function searchOnce({ ruflo, store, args, deadline, env, scratch, operation = 'search', signal, invocation }) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const remaining = deadline - Date.now();
|
|
27
|
+
if (signal?.aborted || remaining <= 0) { resolve({ rows: [], state: signal?.aborted ? 'unavailable' : 'timed out' }); return; }
|
|
28
|
+
let cwd;
|
|
29
|
+
try { cwd = fs.mkdtempSync(path.join(scratch(store.path), 'run-')); } catch { resolve({ rows: [], state: 'unavailable' }); return; }
|
|
30
|
+
const cleanup = () => { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later as a stale run- dir */ } };
|
|
31
|
+
let inv;
|
|
32
|
+
try { inv = invocation ?? rufloInvocation(ruflo, ['memory', operation, '--path', store.path, ...args]); }
|
|
33
|
+
catch { cleanup(); resolve({ rows: [], state: 'unavailable' }); return; }
|
|
34
|
+
let child;
|
|
35
|
+
try {
|
|
36
|
+
child = spawn(inv.executable, inv.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined },
|
|
37
|
+
stdio: ['ignore', 'pipe', 'ignore'], detached: process.platform !== 'win32', windowsHide: true });
|
|
38
|
+
} catch { cleanup(); resolve({ rows: [], state: 'unavailable' }); return; }
|
|
39
|
+
let out = '', outputBytes = 0;
|
|
40
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
41
|
+
let done = false, retiring = null, retirementTimer, timer;
|
|
42
|
+
const finish = (state, retirement = process.platform === 'win32' ? 'direct-child-only' : 'confirmed') => {
|
|
43
|
+
if (done) return;
|
|
44
|
+
done = true;
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
clearTimeout(retirementTimer);
|
|
47
|
+
signal?.removeEventListener('abort', cancel);
|
|
48
|
+
cleanup();
|
|
49
|
+
const parsed = operation === 'search' ? searchJsonResult(out) : null;
|
|
50
|
+
if (state === 'ok' && parsed && !parsed.valid) state = 'unavailable';
|
|
51
|
+
resolve({ rows: state === 'ok' && parsed ? parsed.rows : [], value: state === 'ok' ? out.trim() : '', state, retirement });
|
|
52
|
+
};
|
|
53
|
+
const retire = state => {
|
|
54
|
+
if (done || retiring) return;
|
|
55
|
+
retiring = state; killGroup(child);
|
|
56
|
+
retirementTimer = setTimeout(() => finish('unavailable', 'unconfirmed'), Math.max(1, deadline - Date.now()));
|
|
57
|
+
};
|
|
58
|
+
const cancel = () => retire('unavailable');
|
|
59
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
60
|
+
// Reserve cleanup inside the same caller deadline; pipe inheritance cannot report success.
|
|
61
|
+
timer = setTimeout(() => retire('timed out'), Math.max(1, deadline - Date.now() - 100));
|
|
62
|
+
child.stdout.on('data', c => { if (done || retiring) return; outputBytes += c.length;
|
|
63
|
+
if (outputBytes > 1 << 20) return retire('unavailable');
|
|
64
|
+
try { out += decoder.decode(c, { stream: true }); } catch { retire('unavailable'); } });
|
|
65
|
+
child.on('error', () => retire('unavailable'));
|
|
66
|
+
child.on('close', code => {
|
|
67
|
+
killGroup(child); // Successful parent exit must not leave an owned detached grandchild.
|
|
68
|
+
let valid = true; try { out += decoder.decode(); } catch { valid = false; }
|
|
69
|
+
const settle = () => {
|
|
70
|
+
if (done) return;
|
|
71
|
+
let alive = false;
|
|
72
|
+
if (process.platform !== 'win32') {
|
|
73
|
+
try { process.kill(-child.pid, 0); alive = true; } catch (error) { alive = error.code !== 'ESRCH'; }
|
|
74
|
+
}
|
|
75
|
+
if (alive && Date.now() < deadline) { retirementTimer = setTimeout(settle, 5); return; }
|
|
76
|
+
finish(alive || !valid ? 'unavailable' : retiring ?? (code === 0 ? 'ok' : 'failed'), alive ? 'unconfirmed' : undefined);
|
|
77
|
+
};
|
|
78
|
+
clearTimeout(retirementTimer); settle();
|
|
79
|
+
});
|
|
80
|
+
if (signal?.aborted) cancel();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Isolate synchronous read-only SQLite work inside the same cancellable process budget. */
|
|
85
|
+
export async function nativeRead({ store, requests, namespaces, deadline, env, scratch, signal }) {
|
|
86
|
+
const result = await searchOnce({ store, deadline, env, scratch, signal, operation: 'native',
|
|
87
|
+
invocation: { executable: process.execPath, args: [fileURLToPath(new URL('./agentdb-recall-reader.mjs', import.meta.url)),
|
|
88
|
+
store.path, JSON.stringify({ requests, namespaces, deadline })] } });
|
|
89
|
+
if (result.state !== 'ok') return { ok: false, fatal: true, state: result.state };
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(result.value);
|
|
92
|
+
if (typeof parsed.ok !== 'boolean') throw new Error('invalid native result');
|
|
93
|
+
return { ...parsed, state: parsed.ok ? 'ok' : 'unavailable' };
|
|
94
|
+
} catch { return { ok: false, fatal: true, state: 'unavailable' }; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function exactReads({ candidates, bin, deadline, env, scratch, signal }) {
|
|
98
|
+
const groups = new Map();
|
|
99
|
+
for (const candidate of candidates) {
|
|
100
|
+
const storePath = candidate.storePath;
|
|
101
|
+
if (!groups.has(storePath)) groups.set(storePath, []);
|
|
102
|
+
groups.get(storePath).push(candidate);
|
|
103
|
+
}
|
|
104
|
+
const results = new Map();
|
|
105
|
+
await Promise.all([...groups].map(async ([storePath, rows]) => {
|
|
106
|
+
const native = await nativeRead({ store: { path: storePath }, requests: rows.map(({ namespace, key }) => ({ namespace, key })),
|
|
107
|
+
deadline, env, scratch, signal });
|
|
108
|
+
await Promise.all(rows.map(async (row, index) => {
|
|
109
|
+
const result = native.ok ? { state: 'ok', value: native.value[index] ?? '' }
|
|
110
|
+
: native.fatal ? { state: native.state, value: '' }
|
|
111
|
+
: await searchOnce({ ruflo: bin, store: { path: storePath }, deadline, env, scratch, signal,
|
|
112
|
+
operation: 'retrieve', args: ['-k', row.key, '-n', row.namespace, '--value-only'] });
|
|
113
|
+
results.set(row, result);
|
|
114
|
+
}));
|
|
115
|
+
}));
|
|
116
|
+
return results;
|
|
117
|
+
}
|
|
118
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Bounded read-only projection of canonical Ruflo rows; never opens a second store.
|
|
2
|
+
* Run as an owned child so SQLite/filesystem stalls can be retired at the recall deadline.
|
|
3
|
+
* Unsupported schema stands down to global Ruflo; malformed/duplicate rows fail closed.
|
|
4
|
+
*/
|
|
5
|
+
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const [storePath, input] = process.argv.slice(2);
|
|
9
|
+
const { requests, namespaces, deadline } = JSON.parse(input);
|
|
10
|
+
if (!Number.isFinite(deadline) || Date.now() >= deadline
|
|
11
|
+
|| requests && (!Array.isArray(requests) || requests.length > 32
|
|
12
|
+
|| requests.some(row => typeof row?.namespace !== 'string' || typeof row?.key !== 'string' || !row.key))
|
|
13
|
+
|| namespaces && (!Array.isArray(namespaces) || namespaces.length > 8 || namespaces.some(value => typeof value !== 'string'))
|
|
14
|
+
|| Boolean(requests) === Boolean(namespaces)) throw new Error('invalid bounded read');
|
|
15
|
+
const result = withProgressionReader(storePath, reader => requests
|
|
16
|
+
? requests.map(({ namespace, key }) => reader.readContent(namespace, key))
|
|
17
|
+
: Object.fromEntries(namespaces.map(namespace => [namespace, reader.listKeys(namespace, { maxEntries: 100000 })])),
|
|
18
|
+
{ deadlineAt: deadline });
|
|
19
|
+
process.stdout.write(JSON.stringify(result));
|
|
20
|
+
} catch {
|
|
21
|
+
process.stdout.write(JSON.stringify({ ok: false, fatal: true }));
|
|
22
|
+
}
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
/** Prompt-time canonical AgentDB recall (ADR-101, G-022).
|
|
3
3
|
* Every nonempty human prompt searches curated signal, turn outcomes and project/default namespaces in
|
|
4
4
|
* .swarm/memory.db. Recalled records are untrusted evidence, never instructions.
|
|
5
|
-
* Global Ruflo
|
|
5
|
+
* Global Ruflo ranks semantic matches; an isolated read-only child prunes empty namespaces
|
|
6
|
+
* and batches exact values through the schema-checked canonical reader. All processes share one
|
|
6
7
|
* <=2s deadline. ground-ruvnet.sh delivers <=600 bytes on each eligible prompt.
|
|
7
8
|
*/
|
|
8
9
|
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
9
11
|
import path from 'node:path';
|
|
10
12
|
import crypto from 'node:crypto';
|
|
11
|
-
import { spawn } from 'node:child_process';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import { resolveRuflo
|
|
14
|
+
import { resolveRuflo } from './ruflo-bin.mjs';
|
|
14
15
|
import { rufloCwdFor, rufloScratchRoot } from './project-progression-store.mjs';
|
|
15
16
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
16
17
|
import { isHarnessGenerated, readStdinBounded } from './hook-input.mjs';
|
|
@@ -19,7 +20,9 @@ import { learningTarget, LEARNING_NAMESPACE } from './learning-store.mjs';
|
|
|
19
20
|
import { safeAction } from './learning-queue.mjs';
|
|
20
21
|
|
|
21
22
|
import { redactText } from './continuity-events.mjs';
|
|
22
|
-
import {
|
|
23
|
+
import { resolveTurnDb } from './turn-outcome-capture.mjs';
|
|
24
|
+
import { maskExcludedPaths } from './turn-capture-privacy.mjs';
|
|
25
|
+
import { searchOnce, nativeRead, exactReads, searchJsonResult } from './agentdb-recall-process.mjs';
|
|
23
26
|
import { makeLesson } from './lesson-store.mjs';
|
|
24
27
|
export const STORE_FILES = Object.freeze(['memory.db']);
|
|
25
28
|
export const DEFAULT_DEADLINE_MS = 1900;
|
|
@@ -39,9 +42,9 @@ export function recallBinding({ binding = {}, projectRoot, storePath, prompt } =
|
|
|
39
42
|
requestDigest: binding.requestDigest ?? crypto.createHash('sha256').update(String(prompt)).digest('hex') };
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
/** Bounded
|
|
43
|
-
function enumerateCanonicalKeys({ storePath, namespaces, deadline, signal }) {
|
|
44
|
-
return
|
|
45
|
+
/** Bounded structural enumeration uses the same cancellable read-only canonical provider. */
|
|
46
|
+
function enumerateCanonicalKeys({ storePath, namespaces, deadline, signal, env, scratch }) {
|
|
47
|
+
return nativeRead({ store: { path: storePath }, namespaces, deadline, signal, env, scratch });
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
export function applicableRecallLesson(value, projectRoot) {
|
|
@@ -98,17 +101,6 @@ export function parseSearchJson(stdout) {
|
|
|
98
101
|
return searchJsonResult(stdout).rows;
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
function searchJsonResult(stdout) {
|
|
102
|
-
const s = String(stdout || '');
|
|
103
|
-
// Live Ruflo prints warnings AFTER the JSON object as well as logs before it.
|
|
104
|
-
const start = s.indexOf('{'); const end = s.lastIndexOf('}');
|
|
105
|
-
if (start < 0 || end < start) return { valid: false, rows: [] };
|
|
106
|
-
try { const rows = JSON.parse(s.slice(start, end + 1)).results;
|
|
107
|
-
return Array.isArray(rows) && rows.every(r => r && typeof r.key === 'string' && r.key)
|
|
108
|
-
? { valid: true, rows } : { valid: false, rows: [] };
|
|
109
|
-
} catch { return { valid: false, rows: [] }; }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
104
|
function clean(value, limit) {
|
|
113
105
|
// Redact before truncation so a clipped token cannot escape recognition.
|
|
114
106
|
const text = redactText(value).replace(/[\u0000-\u001f\u007f`]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
@@ -195,7 +187,7 @@ export function learningObservationExcerpt(raw) {
|
|
|
195
187
|
}
|
|
196
188
|
|
|
197
189
|
export function pickRows(results, limit = 3) {
|
|
198
|
-
const candidates = results.flatMap((r) => r.rows.filter((p) => p.namespace === r.namespace && (!NOISE_KEY.test(p.key) || p.namespace === 'turns' && /^turn[-_]/i.test(p.key))
|
|
190
|
+
const candidates = results.flatMap((r) => r.rows.filter((p) => p.namespace === r.namespace && (!NOISE_KEY.test(p.key) || p.namespace === 'turns' && /^turn[-_]/i.test(p.key) || p.namespace === 'continuity-events' && /^cevt-.*-(?:decision|lesson|open-item)-/i.test(p.key))
|
|
199
191
|
&& (!r.family || p.key.toLowerCase().includes(r.family))
|
|
200
192
|
&& Number.isFinite(p.score) && p.score >= MIN_RELEVANCE).map((p) => ({ ...p, storePath: r.storePath, targeted: Boolean(r.family) })));
|
|
201
193
|
const signal = (p) => SIGNAL_NAMESPACES.has(p.namespace) ? 2 : p.namespace === 'turns' ? 1 : p.namespace === LEARNING_NAMESPACE ? -1 : 0;
|
|
@@ -227,76 +219,15 @@ export function formatBlock({ picks, status }) {
|
|
|
227
219
|
return render(limit);
|
|
228
220
|
}
|
|
229
221
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
222
|
+
/** Semantic ranking gets a bounded slice; exact curated reads keep the rest so load cannot erase all history. */
|
|
223
|
+
export const SEARCH_BUDGET_SHARE = 0.8;
|
|
224
|
+
export function searchDeadlineFor(started, deadline) { return Math.min(deadline, started + Math.floor((deadline - started) * SEARCH_BUDGET_SHARE)); }
|
|
234
225
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return new Promise((resolve) => {
|
|
238
|
-
const remaining = deadline - Date.now();
|
|
239
|
-
if (signal?.aborted || remaining <= 0) { resolve({ rows: [], state: signal?.aborted ? 'unavailable' : 'timed out' }); return; }
|
|
240
|
-
let cwd;
|
|
241
|
-
try { cwd = fs.mkdtempSync(path.join(scratch(store.path), 'run-')); } catch { resolve({ rows: [], state: 'unavailable' }); return; }
|
|
242
|
-
const cleanup = () => { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later as a stale run- dir */ } };
|
|
243
|
-
let inv;
|
|
244
|
-
try { inv = rufloInvocation(ruflo, ['memory', operation, '--path', store.path, ...args]); }
|
|
245
|
-
catch { cleanup(); resolve({ rows: [], state: 'unavailable' }); return; }
|
|
246
|
-
let child;
|
|
247
|
-
try {
|
|
248
|
-
child = spawn(inv.executable, inv.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' },
|
|
249
|
-
stdio: ['ignore', 'pipe', 'ignore'], detached: process.platform !== 'win32', windowsHide: true });
|
|
250
|
-
} catch { cleanup(); resolve({ rows: [], state: 'unavailable' }); return; }
|
|
251
|
-
let out = '', outputBytes = 0;
|
|
252
|
-
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
253
|
-
let done = false, retiring = null, retirementTimer, timer;
|
|
254
|
-
const finish = (state, retirement = process.platform === 'win32' ? 'direct-child-only' : 'confirmed') => {
|
|
255
|
-
if (done) return;
|
|
256
|
-
done = true;
|
|
257
|
-
clearTimeout(timer);
|
|
258
|
-
clearTimeout(retirementTimer);
|
|
259
|
-
signal?.removeEventListener('abort', cancel);
|
|
260
|
-
cleanup();
|
|
261
|
-
const parsed = operation === 'search' ? searchJsonResult(out) : null;
|
|
262
|
-
if (state === 'ok' && parsed && !parsed.valid) state = 'unavailable';
|
|
263
|
-
resolve({ rows: state === 'ok' && parsed ? parsed.rows : [], value: state === 'ok' ? out.trim() : '', state, retirement });
|
|
264
|
-
};
|
|
265
|
-
const retire = state => {
|
|
266
|
-
if (done || retiring) return;
|
|
267
|
-
retiring = state; killGroup(child);
|
|
268
|
-
retirementTimer = setTimeout(() => finish('unavailable', 'unconfirmed'), Math.max(1, deadline - Date.now()));
|
|
269
|
-
};
|
|
270
|
-
const cancel = () => retire('unavailable');
|
|
271
|
-
signal?.addEventListener('abort', cancel, { once: true });
|
|
272
|
-
// Reserve cleanup inside the same caller deadline; pipe inheritance cannot report success.
|
|
273
|
-
timer = setTimeout(() => retire('timed out'), Math.max(1, deadline - Date.now() - 100));
|
|
274
|
-
child.stdout.on('data', c => { if (done || retiring) return; outputBytes += c.length;
|
|
275
|
-
if (outputBytes > 1 << 20) return retire('unavailable');
|
|
276
|
-
try { out += decoder.decode(c, { stream: true }); } catch { retire('unavailable'); } });
|
|
277
|
-
child.on('error', () => retire('unavailable'));
|
|
278
|
-
child.on('close', code => {
|
|
279
|
-
killGroup(child); // Successful parent exit must not leave an owned detached grandchild.
|
|
280
|
-
let valid = true; try { out += decoder.decode(); } catch { valid = false; }
|
|
281
|
-
const settle = () => {
|
|
282
|
-
if (done) return;
|
|
283
|
-
let alive = false;
|
|
284
|
-
if (process.platform !== 'win32') {
|
|
285
|
-
try { process.kill(-child.pid, 0); alive = true; } catch (error) { alive = error.code !== 'ESRCH'; }
|
|
286
|
-
}
|
|
287
|
-
if (alive && Date.now() < deadline) { retirementTimer = setTimeout(settle, 5); return; }
|
|
288
|
-
finish(alive || !valid ? 'unavailable' : retiring ?? (code === 0 ? 'ok' : 'failed'), alive ? 'unconfirmed' : undefined);
|
|
289
|
-
};
|
|
290
|
-
clearTimeout(retirementTimer); settle();
|
|
291
|
-
});
|
|
292
|
-
if (signal?.aborted) cancel();
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
async function consequentialRecall({ prompt, root, store, bin, scratch, env, deadline, binding, signal, enumerateKeys }) {
|
|
226
|
+
async function consequentialRecall({ prompt, root, store, bin, scratch, env, deadline, binding, signal, enumerateKeys, exclusions, rankedSearch = true }) {
|
|
227
|
+
const searchDeadline = searchDeadlineFor(Date.now(), deadline);
|
|
297
228
|
const phaseBinding = recallBinding({ binding, projectRoot: root, storePath: store.path, prompt });
|
|
298
229
|
const namespaces = [...new Set([path.basename(root), 'default', 'lessons', 'continuity-events'])];
|
|
299
|
-
const enumeration = await enumerateKeys({ storePath: store.path, namespaces, deadline, signal });
|
|
230
|
+
const enumeration = await enumerateKeys({ storePath: store.path, namespaces, deadline, signal, env, scratch });
|
|
300
231
|
if (!enumeration?.ok || signal?.aborted || Date.now() >= deadline) return { block: formatBlock({ picks: [], status: 'unavailable structural enumeration' }), picks: [], stores: [store],
|
|
301
232
|
status: { 'memory.db': 'unavailable' }, outcome: signal?.aborted ? 'unavailable' : Date.now() >= deadline ? 'timed-out' : 'unavailable', categories: { state: 'unavailable', decisions: 'unavailable', lessons: 'unavailable' } };
|
|
302
233
|
const keys = enumeration.value;
|
|
@@ -306,13 +237,21 @@ async function consequentialRecall({ prompt, root, store, bin, scratch, env, dea
|
|
|
306
237
|
const decisionKeys = decisionNamespaces.flatMap(namespace => keys[namespace].filter(key => /^decision[-_]|^cevt-.*-decision-/i.test(key)).map(key => ({ category: 'decisions', namespace, key })));
|
|
307
238
|
let relevantDecisions = decisionKeys;
|
|
308
239
|
let decisionAvailability = 'ok-empty';
|
|
309
|
-
if (decisionKeys.length > 8) {
|
|
240
|
+
if (decisionKeys.length > 8 && !rankedSearch) {
|
|
241
|
+
relevantDecisions = decisionKeys.slice(-8);
|
|
242
|
+
decisionAvailability = 'unavailable'; // unranked newest slice only; not a claim about all decisions
|
|
243
|
+
} else if (decisionKeys.length > 8) {
|
|
310
244
|
const searched = await Promise.all(decisionNamespaces.map(async namespace => ({ namespace,
|
|
311
|
-
...await searchOnce({ ruflo: bin, store, deadline, env, scratch, signal, args: ['--format', 'json', '-q', recallQuery(prompt), '-n', namespace, '--limit', '12'] }) })));
|
|
312
|
-
|
|
245
|
+
...await searchOnce({ ruflo: bin, store, deadline: searchDeadline, env, scratch, signal, args: ['--format', 'json', '-q', recallQuery(prompt), '-n', namespace, '--limit', '12'] }) })));
|
|
246
|
+
const rankingFailed = searched.some(row => row.state !== 'ok');
|
|
247
|
+
if (rankingFailed) decisionAvailability = searched.some(row => row.state === 'timed out') ? 'timed-out' : 'unavailable';
|
|
313
248
|
relevantDecisions = searched.flatMap(result => result.rows.filter(row => row.namespace === result.namespace && Number.isFinite(row.score) && row.score >= MIN_RELEVANCE
|
|
314
249
|
&& decisionKeys.some(key => key.namespace === row.namespace && key.key === row.key)).map(row => ({ category: 'decisions', namespace: row.namespace, key: row.key, score: row.score })))
|
|
315
250
|
.sort((a, b) => b.score - a.score).slice(0, 8);
|
|
251
|
+
if (rankingFailed && !relevantDecisions.length) {
|
|
252
|
+
// Semantic ranking is gone, not the history: read the newest bounded slice natively and keyword-match it exactly.
|
|
253
|
+
relevantDecisions = decisionKeys.slice(-8);
|
|
254
|
+
}
|
|
316
255
|
if (!relevantDecisions.length) decisionAvailability = 'unavailable'; // semantic miss is not absent history
|
|
317
256
|
}
|
|
318
257
|
const candidates = [
|
|
@@ -323,9 +262,12 @@ async function consequentialRecall({ prompt, root, store, bin, scratch, env, dea
|
|
|
323
262
|
if (candidates.length > 32) return { block: formatBlock({ picks: [], status: 'unavailable bounded category selection' }), picks: [], stores: [store], status: { 'memory.db': 'unavailable' }, outcome: 'unavailable', categories: { state: 'unavailable', decisions: 'unavailable', lessons: 'unavailable' } };
|
|
324
263
|
const categories = { state: !stateKeys.length && keys[path.basename(root)].includes('project-state-current') ? 'unavailable' : 'ok-empty', decisions: decisionAvailability, lessons: 'ok-empty' };
|
|
325
264
|
const records = [];
|
|
265
|
+
candidates.forEach(candidate => { candidate.storePath = store.path; });
|
|
266
|
+
const exactResults = await exactReads({ candidates, bin, deadline, env, scratch, signal });
|
|
326
267
|
await Promise.all(candidates.map(async candidate => {
|
|
327
|
-
const exact =
|
|
268
|
+
const exact = exactResults.get(candidate);
|
|
328
269
|
if (exact.state !== 'ok' || !exact.value || exact.value.startsWith('[WARN]')) { categories[candidate.category] = /timed out/.test(exact.state) ? 'timed-out' : 'unavailable'; return; }
|
|
270
|
+
if (maskExcludedPaths(exact.value, exclusions, root) !== exact.value) return;
|
|
329
271
|
let structured; try { structured = JSON.parse(exact.value); } catch { structured = null; }
|
|
330
272
|
const declaredRoot = structured?.projectRoot ?? structured?.scope?.projectRoot;
|
|
331
273
|
if (declaredRoot && declaredRoot !== root || structured?.supersededBy || structured?.superseded === true) {
|
|
@@ -347,7 +289,7 @@ async function consequentialRecall({ prompt, root, store, bin, scratch, env, dea
|
|
|
347
289
|
let limit = 350;
|
|
348
290
|
while (limit > 0 && Buffer.byteLength(render(limit)) > CONSEQUENTIAL_BLOCK_MAX_BYTES) limit--;
|
|
349
291
|
const block = render(limit);
|
|
350
|
-
return { block, picks: selected, stores: [store], status: { 'memory.db': outcome }, outcome, categories,
|
|
292
|
+
return { block, picks: selected, stores: [store], status: { 'memory.db': outcome }, outcome, categories, enumerated: true,
|
|
351
293
|
receipt: { schemaVersion: 1, kind: 'canonical-memory-recall', binding: phaseBinding, outcome, categories,
|
|
352
294
|
observedAt: new Date().toISOString(), deadline, queryDigest: crypto.createHash('sha256').update(String(prompt)).digest('hex'),
|
|
353
295
|
records: selected.map(({ category, namespace, key, storePath, valueDigest }) => ({ category, namespace, key, storePath, valueDigest })), authority: false } };
|
|
@@ -365,7 +307,15 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
365
307
|
const budget = Number.isFinite(requested) && requested > 0 ? Math.min(requested, 1900) : DEFAULT_DEADLINE_MS;
|
|
366
308
|
const deadline = Math.min(started + budget, absoluteDeadline ?? Infinity);
|
|
367
309
|
if (deadline <= started) return { ...empty, outcome: 'timed-out' };
|
|
368
|
-
const
|
|
310
|
+
const gitTimeoutMs = Math.max(1, Math.min(600, Math.floor(budget / 3)));
|
|
311
|
+
const privacy = resolveTurnDb({ projectDir, brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain'), gitTimeoutMs, deadlineAt: deadline, signal });
|
|
312
|
+
if (privacy.skipped) {
|
|
313
|
+
const outcome = /opt-out/.test(privacy.skipped) ? 'disabled' : /no project memory db/.test(privacy.skipped) ? 'not-adopted' : 'unavailable';
|
|
314
|
+
return { ...empty, outcome, reason: privacy.skipped, block: outcome === 'unavailable' ? formatBlock({ picks: [], status: 'unavailable privacy policy' }) : '' };
|
|
315
|
+
}
|
|
316
|
+
const exclusions = privacy.contentPathExcludes;
|
|
317
|
+
const root = privacy.projectRoot; const stores = [];
|
|
318
|
+
try { if (fs.statSync(privacy.db).isFile()) stores.push({ name: 'memory.db', path: privacy.db }); } catch { /* absent */ }
|
|
369
319
|
recallBinding({ binding, projectRoot: root, storePath: stores[0]?.path ?? null, prompt });
|
|
370
320
|
let learningStore;
|
|
371
321
|
try {
|
|
@@ -381,14 +331,26 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
381
331
|
const store = stores[0];
|
|
382
332
|
const query = recallQuery(prompt);
|
|
383
333
|
const scratchFor = scratch || ((storePath) => rufloCwdFor(storePath, { root: rufloScratchRoot(env) }));
|
|
384
|
-
if (consequential) return await consequentialRecall({ prompt, root, store, bin, scratch: scratchFor, env, deadline, binding, signal, enumerateKeys });
|
|
385
|
-
const namespaces = [...new Set(['lessons', 'patterns', 'pattern', 'turns', path.basename(root), 'default'])];
|
|
334
|
+
if (consequential) return await consequentialRecall({ prompt, root, store, bin, scratch: scratchFor, env, deadline, binding, signal, enumerateKeys, exclusions });
|
|
335
|
+
const namespaces = [...new Set(['lessons', 'patterns', 'pattern', 'turns', 'continuity-events', path.basename(root), 'default'])];
|
|
386
336
|
const family = /\bhooks?\b|hook.harness/i.test(prompt) ? 'decision-hook-harness-index'
|
|
387
337
|
: /score|grade|north.star/i.test(prompt) ? 'scorecard'
|
|
388
338
|
: /where are we|status|catch me up/i.test(prompt) ? 'project-state-current'
|
|
389
339
|
: /releas|publish|workflow run|dispatch/i.test(prompt) ? 'release'
|
|
390
340
|
: /requirement|always|every prompt/i.test(prompt) ? 'decision-agentdb' : null;
|
|
391
|
-
|
|
341
|
+
// This machine runs loaded (load average 30-60 measured 2026-10-10) and ranked search spawns one Ruflo process
|
|
342
|
+
// per namespace; run concurrently they starve every other read past the deadline. So: ONE native enumeration
|
|
343
|
+
// (reused for the curated pass and for pruning empty namespaces), then exact curated state/lessons/decisions,
|
|
344
|
+
// then spend what remains on ranking.
|
|
345
|
+
const presence = store ? await nativeRead({ store, namespaces, deadline, env, scratch: scratchFor, signal }) : null;
|
|
346
|
+
if (presence?.fatal) { return { ...empty, stores, outcome: presence.state === 'timed out' ? 'timed-out' : 'unavailable',
|
|
347
|
+
block: formatBlock({ picks: [], status: presence.state }), reason: 'namespace enumeration failed' }; }
|
|
348
|
+
const sharedKeys = presence?.ok ? async () => ({ ok: true, value: presence.value }) : enumerateCanonicalKeys;
|
|
349
|
+
const curated = store ? await consequentialRecall({ prompt, root, store, bin, scratch: scratchFor, env, deadline, binding, signal,
|
|
350
|
+
enumerateKeys: sharedKeys, exclusions, rankedSearch: false }).catch(() => null) : null;
|
|
351
|
+
const searchDeadline = searchDeadlineFor(Date.now(), deadline);
|
|
352
|
+
const activeNamespaces = presence?.ok ? namespaces.filter(namespace => presence.value[namespace]?.length) : namespaces;
|
|
353
|
+
const jobs = (store ? activeNamespaces : []).flatMap((namespace) => [{ store, namespace, family: null, args: ['--format', 'json', '-q', query, '-n', namespace, '--limit', '12'] },
|
|
392
354
|
...(family && !SIGNAL_NAMESPACES.has(namespace) && namespace !== 'turns' ? [{ store, namespace, family, args: ['--format', 'json', '-q', family, '-n', namespace, '-t', 'keyword', '--limit', '4'] }] : [])]);
|
|
393
355
|
const words = promptKeywords(prompt, 14);
|
|
394
356
|
const workflowQuery = words.map(word => safeAction('Bash', word))
|
|
@@ -396,7 +358,7 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
396
358
|
if (learningStore && workflowQuery) jobs.push({ store: learningStore, namespace: LEARNING_NAMESPACE, family: null,
|
|
397
359
|
args: ['--format', 'json', '-q', workflowQuery, '-n', LEARNING_NAMESPACE, '-t', 'keyword', '--limit', '4'] });
|
|
398
360
|
const results = await Promise.all(jobs.map(async ({ store: jobStore, namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily, storePath: jobStore.path,
|
|
399
|
-
...await searchOnce({ ruflo: bin, store: jobStore, deadline, env, scratch: scratchFor, args, signal }) })));
|
|
361
|
+
...await searchOnce({ ruflo: bin, store: jobStore, deadline: searchDeadline, env, scratch: scratchFor, args, signal }) })));
|
|
400
362
|
let status = results.every((r) => r.state === 'ok') ? 'ok'
|
|
401
363
|
: results.some((r) => r.state === 'timed out') ? 'timed out' : 'unavailable';
|
|
402
364
|
// Overfetch a bounded six exact values so rejected turn metadata cannot hide
|
|
@@ -404,13 +366,21 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
404
366
|
const candidates = pickRows(results, 6);
|
|
405
367
|
// Ruflo previews are ~60 characters. Read the actual selected values within
|
|
406
368
|
// the same deadline so the block contains useful evidence rather than titles.
|
|
369
|
+
const exactResults = await exactReads({ candidates, bin, deadline, env, scratch: scratchFor, signal });
|
|
407
370
|
const retrieved = await Promise.all(candidates.map(async (p) => {
|
|
408
|
-
const r =
|
|
409
|
-
args: ['-k', p.key, '-n', p.namespace, '--value-only'], signal });
|
|
371
|
+
const r = exactResults.get(p);
|
|
410
372
|
if (r.state === 'ok' && r.value && !r.value.startsWith('[WARN]')) {
|
|
373
|
+
if (maskExcludedPaths(r.value, exclusions, root) !== r.value || maskExcludedPaths(p.key, exclusions, root) !== p.key) return { pick: null, state: r.state };
|
|
374
|
+
let event;
|
|
375
|
+
if (p.namespace === 'continuity-events') {
|
|
376
|
+
try { event = JSON.parse(r.value); } catch { return { pick: null, state: r.state }; }
|
|
377
|
+
if (event.schema !== 'ruvnet-brain.continuity-event' || event.schemaVersion !== 1 || event.project !== root
|
|
378
|
+
|| !['decision', 'lesson', 'open-item'].includes(event.kind) || typeof event.summary !== 'string'
|
|
379
|
+
|| !promptKeywords(prompt, 14).some(word => redactText(event.summary).toLowerCase().includes(word))) return { pick: null, state: r.state };
|
|
380
|
+
}
|
|
411
381
|
const clauses = p.namespace === 'turns' ? turnOutcomeClauses(r.value, prompt) : null;
|
|
412
382
|
const preview = p.namespace === LEARNING_NAMESPACE ? learningObservationExcerpt(r.value)
|
|
413
|
-
: clauses ? turnOutcomeExcerpt(r.value, prompt) : evidenceExcerpt(r.value, p.key, prompt);
|
|
383
|
+
: event ? clean(event.summary, 280) : clauses ? turnOutcomeExcerpt(r.value, prompt) : evidenceExcerpt(r.value, p.key, prompt);
|
|
414
384
|
return { pick: preview ? { ...p, preview, clauses, valueDigest: crypto.createHash('sha256').update(r.value).digest('hex') } : null, state: 'ok' };
|
|
415
385
|
}
|
|
416
386
|
return { pick: null, state: r.state === 'ok' ? 'unavailable' : r.state };
|
|
@@ -427,6 +397,19 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
427
397
|
});
|
|
428
398
|
return unique.length ? [{ ...p, preview: clean('OUTCOME: ' + unique.join(' '), 280) }] : [];
|
|
429
399
|
}).slice(0, 3);
|
|
400
|
+
// Curated exact records were read before ranking and are already verified: they lead, ranked hits fill the
|
|
401
|
+
// rest, deduplicated by (namespace, key). Ranked search failing or finding nothing never discards them, and
|
|
402
|
+
// everything leaves through formatBlock so the prompt path stays within BLOCK_MAX_BYTES.
|
|
403
|
+
const exactCurated = (curated?.picks ?? []).map(p => ({ store: 'memory.db', storePath: p.storePath, key: p.key, namespace: p.namespace,
|
|
404
|
+
score: 1, preview: p.preview, valueDigest: p.valueDigest }));
|
|
405
|
+
const seenKeys = new Set(exactCurated.map(p => `${p.namespace}\u0000${p.key}`));
|
|
406
|
+
const merged = [...exactCurated, ...picks.filter(p => !seenKeys.has(`${p.namespace}\u0000${p.key}`))].slice(0, 3);
|
|
407
|
+
picks.length = 0; picks.push(...merged);
|
|
408
|
+
// A failed curated read of state or lessons is not empty history: it must surface even when ranked search was ok.
|
|
409
|
+
// (Decisions are unranked by design in this pass, so their availability marker is not folded in.)
|
|
410
|
+
if (status === 'ok' && curated?.enumerated && ['state', 'lessons'].some(c => ['timed-out', 'unavailable'].includes(curated.categories?.[c]))) {
|
|
411
|
+
status = ['state', 'lessons'].some(c => curated.categories[c] === 'timed-out') ? 'timed out reading curated records' : 'unavailable curated records';
|
|
412
|
+
}
|
|
430
413
|
if (retrieved.some((r) => r.state !== 'ok')) status = retrieved.some((r) => r.state === 'timed out') ? 'timed out reading exact values' : 'unavailable exact values';
|
|
431
414
|
const outcome = status === 'ok' ? (picks.length ? 'ok-with-results' : 'ok-empty') : /timed out/.test(status) ? 'timed-out' : 'unavailable';
|
|
432
415
|
const receipt = { schemaVersion: 1, kind: 'canonical-memory-recall', outcome,
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* detail.status: 'detected-unconfirmed'`, written only to the project's own local store; the brief never
|
|
29
29
|
* presents one as a standing rule. RUVNET_CONTINUITY_LESSON_DETECT=off turns that detector off.
|
|
30
30
|
*/
|
|
31
|
+
import { nativeTurnLines } from './native-turn-transcript.mjs';
|
|
31
32
|
import crypto from 'node:crypto';
|
|
32
33
|
import fs from 'node:fs';
|
|
33
34
|
import os from 'node:os';
|
|
@@ -265,12 +266,12 @@ function exitOutcome(result) {
|
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
/**
|
|
268
|
-
* Typed events from
|
|
269
|
-
*
|
|
269
|
+
* Typed events from the current native turn. Codex JSONL is normalized mechanically; opaque
|
|
270
|
+
* custom-tool orchestration inputs cannot manufacture a gate outcome or semantic decision.
|
|
270
271
|
*/
|
|
271
272
|
export function collectTurnEvents({ lines = null, lastAssistantMessage = '', host, session, project, env = process.env, at = Date.now() }) {
|
|
272
273
|
const events = [];
|
|
273
|
-
const turn = lines ? currentTurnRecords(lines) : { userMessage: '', records: [] };
|
|
274
|
+
const turn = lines ? currentTurnRecords(nativeTurnLines(lines, host)) : { userMessage: '', records: [] };
|
|
274
275
|
const uses = new Map();
|
|
275
276
|
const assistantTexts = [];
|
|
276
277
|
for (const rec of turn.records) {
|
|
@@ -306,9 +307,9 @@ export function collectTurnEvents({ lines = null, lastAssistantMessage = '', hos
|
|
|
306
307
|
for (const text of assistantTexts) {
|
|
307
308
|
for (const line of text.split('\n')) {
|
|
308
309
|
if (DECISION_LINE.test(line)) {
|
|
309
|
-
events.push(makeEvent({ kind: 'decision', at, host, session, project, source: 'assistant-detected', authoritative: false, summary: line.replace(/\*\*/g, '') }));
|
|
310
|
+
events.push(makeEvent({ kind: 'decision', at, host, session, project, source: 'assistant-detected', authoritative: false, detail: { status: 'detected-unconfirmed' }, summary: line.replace(/\*\*/g, '') }));
|
|
310
311
|
} else if (LESSON_LINE.test(line)) {
|
|
311
|
-
events.push(makeEvent({ kind: 'lesson', at, host, session, project, source: 'assistant-detected', authoritative: false, summary: line.replace(/\*\*/g, '') }));
|
|
312
|
+
events.push(makeEvent({ kind: 'lesson', at, host, session, project, source: 'assistant-detected', authoritative: false, detail: { status: 'detected-unconfirmed' }, summary: line.replace(/\*\*/g, '') }));
|
|
312
313
|
}
|
|
313
314
|
}
|
|
314
315
|
}
|