@ericrisco/rsc 2.0.10 → 2.0.12
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/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -56,15 +56,35 @@ function hookEventName(target, event, native) {
|
|
|
56
56
|
return names[event] || event;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
const COMPACTION_HINT = 'rsc memory: consider compacting at the next phase boundary; nothing was compacted automatically.';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Two different things used to share one channel, and that is the whole bug.
|
|
63
|
+
*
|
|
64
|
+
* The resume CONTEXT is information for the model, and it is only ever produced on `start` —
|
|
65
|
+
* `SessionStart`, one of the few events whose output may carry `hookSpecificOutput.additionalContext`.
|
|
66
|
+
* The compaction HINT is a message for the PERSON, and it was going out through the same field on
|
|
67
|
+
* whatever event happened to be running. On `Stop`, `PreCompact` and `SessionEnd` the client rejects
|
|
68
|
+
* that outright ("Hook JSON output validation failed — hookSpecificOutput.hookEventName: expected one
|
|
69
|
+
* of …"), so the hook meant to help ended up printing an error after every turn, and after
|
|
70
|
+
* `/compact` in particular — which is exactly what the hint had just told the person to run.
|
|
71
|
+
*
|
|
72
|
+
* So the hint moves to `systemMessage`, which every event accepts. This needs no table of which
|
|
73
|
+
* event carries what, and it cannot rot when the client adds an event: anything addressed to the
|
|
74
|
+
* person travels by the universal channel, and only genuine model context uses the narrow one.
|
|
75
|
+
*/
|
|
59
76
|
function nativeOutput(target, eventName, context = '', notice = null, compactionHint = false) {
|
|
60
|
-
const hint = compactionHint ?
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
const hint = compactionHint ? COMPACTION_HINT : '';
|
|
78
|
+
if (!context && !hint && !notice) return {};
|
|
79
|
+
if (target === 'cursor') {
|
|
80
|
+
const message = [notice, hint].filter(Boolean).join('\n');
|
|
81
|
+
return { ...(context ? { additional_context: context } : {}), ...(message ? { user_message: message } : {}) };
|
|
82
|
+
}
|
|
83
|
+
if (target === 'opencode') return { context, notice: [notice, hint].filter(Boolean).join('\n') || null };
|
|
84
|
+
const message = [notice, hint].filter(Boolean).join('\n');
|
|
65
85
|
return {
|
|
66
|
-
...(
|
|
67
|
-
...(
|
|
86
|
+
...(message ? { systemMessage: message } : {}),
|
|
87
|
+
...(context ? { hookSpecificOutput: { hookEventName: eventName, additionalContext: context } } : {}),
|
|
68
88
|
};
|
|
69
89
|
}
|
|
70
90
|
|
|
@@ -78,15 +98,18 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
|
|
|
78
98
|
try {
|
|
79
99
|
if (!LOCAL_TARGETS.has(target)) throw new Error(`unsupported memory target: ${target}`);
|
|
80
100
|
if (isRemote(target, native)) return { output: {}, capture: null, remote: true, degraded: false };
|
|
81
|
-
|
|
101
|
+
// Two answers from one path, kept apart on purpose: `project` is where the journal is KEPT (the
|
|
102
|
+
// nearest harness), `here` is where the agent WORKS and therefore who the session is.
|
|
103
|
+
const here = resolve(cwd || native.cwd || process.env.RSC_PROJECT_CWD || process.cwd());
|
|
104
|
+
const project = nearestHarness(here);
|
|
82
105
|
if (!existsSync(project)) throw new Error('project directory unavailable');
|
|
83
106
|
const config = settings || projectSettings(project);
|
|
84
107
|
if (config.enabled === false) return { output: {}, capture: null, remote: false, degraded: false };
|
|
85
108
|
const id = sessionId(native, target);
|
|
86
109
|
const eventName = hookEventName(target, event, native);
|
|
87
110
|
if (event === 'start') {
|
|
88
|
-
const started = capture({ cwd: project, sessionId: id, target, event: 'start', settings: config });
|
|
89
|
-
const resumed = resume({ cwd: project, target, settings: config });
|
|
111
|
+
const started = capture({ cwd: project, worktreeCwd: here, sessionId: id, target, event: 'start', settings: config });
|
|
112
|
+
const resumed = resume({ cwd: project, worktreeCwd: here, target, settings: config });
|
|
90
113
|
return {
|
|
91
114
|
output: nativeOutput(target, eventName, resumed.context),
|
|
92
115
|
capture: started,
|
|
@@ -97,6 +120,7 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
|
|
|
97
120
|
}
|
|
98
121
|
const captureInput = {
|
|
99
122
|
cwd: project,
|
|
123
|
+
worktreeCwd: here,
|
|
100
124
|
sessionId: id,
|
|
101
125
|
target,
|
|
102
126
|
event: event === 'end' ? 'sessionEnd' : event,
|
|
@@ -106,8 +130,13 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
|
|
|
106
130
|
if (typeof native.cost === 'number') captureInput.cost = native.cost;
|
|
107
131
|
if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
|
|
108
132
|
const captured = capture(captureInput);
|
|
133
|
+
// `stop_hook_active` is the client saying "this turn is already running BECAUSE a stop hook
|
|
134
|
+
// asked for it". Speaking again here is how a Stop hook wakes itself: the student's session did
|
|
135
|
+
// nine rounds of hook → "—" → hook before the client broke the loop. Record the turn, say
|
|
136
|
+
// nothing.
|
|
137
|
+
const silenced = event === 'turn' && Boolean(native?.stop_hook_active);
|
|
109
138
|
return {
|
|
110
|
-
output: nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
|
|
139
|
+
output: silenced ? {} : nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
|
|
111
140
|
capture: captured,
|
|
112
141
|
remote: false,
|
|
113
142
|
degraded: false,
|
|
@@ -284,6 +284,12 @@ function readRecords(root) {
|
|
|
284
284
|
|
|
285
285
|
export function capture(input = {}) {
|
|
286
286
|
const cwd = resolve(input.cwd || process.cwd());
|
|
287
|
+
// WHERE the journal lives and WHO the session is are two different questions. `cwd` answers the
|
|
288
|
+
// first — the nearest harness, so a child with no manifest of its own does not scatter journals.
|
|
289
|
+
// The second has to be asked of git from where the agent actually works: asked from the harness,
|
|
290
|
+
// every worktree nested under it came back as the harness itself, and a sibling's record then
|
|
291
|
+
// "matched exactly". See GHSA-8gjp-3r7c-33f4.
|
|
292
|
+
const here = resolve(input.worktreeCwd || cwd);
|
|
287
293
|
const config = settings(input.settings);
|
|
288
294
|
if (!config.enabled) return { record: null, path: null, notice: null, compactionHint: false };
|
|
289
295
|
const store = chooseMemoryRoot(cwd);
|
|
@@ -299,7 +305,7 @@ export function capture(input = {}) {
|
|
|
299
305
|
const recordPath = join(store.root, 'sessions', `${storageId}.json`);
|
|
300
306
|
let anchor = readJson(anchorPath);
|
|
301
307
|
let existing = readJson(recordPath);
|
|
302
|
-
const firstSnapshot = snapshot(
|
|
308
|
+
const firstSnapshot = snapshot(here, null);
|
|
303
309
|
if (!anchor) {
|
|
304
310
|
anchor = {
|
|
305
311
|
sessionId,
|
|
@@ -312,7 +318,7 @@ export function capture(input = {}) {
|
|
|
312
318
|
}
|
|
313
319
|
if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
|
|
314
320
|
|
|
315
|
-
const repo = snapshot(
|
|
321
|
+
const repo = snapshot(here, anchor.baselineHead);
|
|
316
322
|
const editCount = Math.max(0, (existing?.editCount || 0) + (finiteOrNull(input.editDelta, true) || 0));
|
|
317
323
|
const baselineFiles = new Set(anchor.baselineFiles || []);
|
|
318
324
|
const newDirtyPath = repo.files.some((path) => !baselineFiles.has(path));
|
|
@@ -352,11 +358,22 @@ export function capture(input = {}) {
|
|
|
352
358
|
if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
|
|
353
359
|
atomicJson(recordPath, record);
|
|
354
360
|
existing = record;
|
|
361
|
+
// Once per session, not once per turn. `editCount` only ever grows, so `editCount >= threshold`
|
|
362
|
+
// was true for every remaining event of a working afternoon — the twentieth edit turned a useful
|
|
363
|
+
// nudge into a line after every single turn, for good. Worse, compaction does not reset the
|
|
364
|
+
// count, so following the advice changed nothing and the hint kept coming.
|
|
365
|
+
//
|
|
366
|
+
// The flag lives on the ANCHOR and not on the record on purpose: the record's schema is closed
|
|
367
|
+
// and every field of it is required, so a new field there would fail validation on every record
|
|
368
|
+
// already written and `readRecords` would quietly drop the project's whole history.
|
|
369
|
+
let compactionHint = Boolean(config.compactionHint && editCount >= config.editThreshold);
|
|
370
|
+
if (compactionHint && anchor.compactionHintedAt) compactionHint = false;
|
|
371
|
+
else if (compactionHint) atomicJson(anchorPath, { ...anchor, compactionHintedAt: now });
|
|
355
372
|
return {
|
|
356
373
|
record,
|
|
357
374
|
path: recordPath,
|
|
358
375
|
notice: consumeNotice(store),
|
|
359
|
-
compactionHint
|
|
376
|
+
compactionHint,
|
|
360
377
|
};
|
|
361
378
|
}
|
|
362
379
|
|
|
@@ -408,13 +425,19 @@ export function resume(input = {}) {
|
|
|
408
425
|
ensureStore(store.root);
|
|
409
426
|
const now = iso(input.now);
|
|
410
427
|
prune(store.root, now, config);
|
|
411
|
-
const
|
|
412
|
-
const
|
|
428
|
+
const here = resolve(input.worktreeCwd || cwd);
|
|
429
|
+
const current = snapshot(here, null);
|
|
430
|
+
const records = readRecords(store.root).filter((record) => validAnchor(here, record))
|
|
413
431
|
.sort((a, b) => b.timestamps.updatedAt.localeCompare(a.timestamps.updatedAt));
|
|
414
432
|
let record = records.find((candidate) => candidate.branch === current.branch && candidate.worktree === current.worktree) || null;
|
|
415
433
|
let match = record ? 'exact' : 'none';
|
|
434
|
+
// Only ever this worktree's own records. The chain used to end in `|| records[0]` — the newest
|
|
435
|
+
// record in the store, whoever wrote it — which a moved or renamed checkout reached every time,
|
|
436
|
+
// because its stale records match nothing. A shared store is a place to KEEP several worktrees'
|
|
437
|
+
// journals, never a reason to hand one of them to another. No continuation is the honest answer
|
|
438
|
+
// when there is nothing of yours to continue.
|
|
416
439
|
if (!record) {
|
|
417
|
-
record = records.find((candidate) => candidate.worktree === current.worktree) ||
|
|
440
|
+
record = records.find((candidate) => candidate.worktree === current.worktree) || null;
|
|
418
441
|
if (record) match = 'nearby';
|
|
419
442
|
}
|
|
420
443
|
const lessons = selectedLessons(store.root, config);
|