greprag 5.47.0 → 5.49.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/dist/commands/assistant-reminder.d.ts +9 -0
- package/dist/commands/assistant-reminder.js +20 -0
- package/dist/commands/assistant-reminder.js.map +1 -0
- package/dist/commands/checkpoint-reminder.d.ts +14 -0
- package/dist/commands/checkpoint-reminder.js +50 -0
- package/dist/commands/checkpoint-reminder.js.map +1 -0
- package/dist/commands/crush.d.ts +9 -0
- package/dist/commands/crush.js +39 -0
- package/dist/commands/crush.js.map +1 -1
- package/dist/commands/frontdesk-reminder.d.ts +23 -0
- package/dist/commands/frontdesk-reminder.js +40 -0
- package/dist/commands/frontdesk-reminder.js.map +1 -0
- package/dist/commands/inbox-watch-supervisor.d.ts +25 -0
- package/dist/commands/inbox-watch-supervisor.js +112 -5
- package/dist/commands/inbox-watch-supervisor.js.map +1 -1
- package/dist/commands/init.js +47 -38
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/memory-reflex.d.ts +115 -0
- package/dist/commands/memory-reflex.js +243 -0
- package/dist/commands/memory-reflex.js.map +1 -0
- package/dist/commands/opencode-relay.d.ts +19 -4
- package/dist/commands/opencode-relay.js +56 -5
- package/dist/commands/opencode-relay.js.map +1 -1
- package/dist/commands/reminder-registry.d.ts +6 -2
- package/dist/commands/reminder-registry.js +20 -3
- package/dist/commands/reminder-registry.js.map +1 -1
- package/dist/commands/reminder-types.d.ts +27 -0
- package/dist/commands/setup-reminder.d.ts +9 -0
- package/dist/commands/setup-reminder.js +20 -0
- package/dist/commands/setup-reminder.js.map +1 -0
- package/dist/commands/watcher-registry.js +4 -2
- package/dist/commands/watcher-registry.js.map +1 -1
- package/dist/hook.js +329 -109
- package/dist/hook.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/opencode-plugin-crush.d.ts +88 -0
- package/dist/opencode-plugin-crush.js +193 -0
- package/dist/opencode-plugin-crush.js.map +1 -0
- package/dist/opencode-plugin.bundle.js +2132 -0
- package/dist/opencode-plugin.d.ts +6 -0
- package/dist/opencode-plugin.js +184 -51
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/worktree-state.js +5 -5
- package/dist/worktree-state.js.map +1 -1
- package/package.json +3 -2
- package/scripts/bundle-opencode-plugin.mjs +54 -0
package/dist/hook.js
CHANGED
|
@@ -77,6 +77,7 @@ const guard_1 = require("./guard");
|
|
|
77
77
|
// live env (ALL I/O) and emits; the modules are pure.
|
|
78
78
|
const reminder_registry_1 = require("./commands/reminder-registry");
|
|
79
79
|
const friction_reminder_1 = require("./commands/friction-reminder");
|
|
80
|
+
const memory_reflex_1 = require("./commands/memory-reflex");
|
|
80
81
|
const worktree_state_1 = require("./worktree-state");
|
|
81
82
|
// Ingress-trigger bridge, Adapter A (LOCAL/state half) — the Stop hook computes
|
|
82
83
|
// the deterministic state values (stress / turnCount / keyword-seen) from the
|
|
@@ -784,6 +785,37 @@ async function store(input, source = 'claude-code') {
|
|
|
784
785
|
if (source === 'codex') {
|
|
785
786
|
turn.toolCalls.push(...(0, codex_hook_events_1.readCodexSubagentToolCalls)(input));
|
|
786
787
|
}
|
|
788
|
+
// Memory-reflex EFFICACY capture (adr/memory-reflex.md). If a memory injection was surfaced
|
|
789
|
+
// this turn: (1) score the FREE overlap proxy locally → bump the `used` numerator, and (2)
|
|
790
|
+
// carry the injection onto the turn POST so the server-side Flash-Lite judge (Chip A) can
|
|
791
|
+
// score it accurately. Consume-once: the stash is always cleared, so a turn with no injection
|
|
792
|
+
// never reuses a stale sample. adr: adr/memory-reflex.md
|
|
793
|
+
let memoryInjectionForStore = null;
|
|
794
|
+
try {
|
|
795
|
+
const effShort = (0, session_id_1.truncateSessionId)(input.session_id);
|
|
796
|
+
if (effShort) {
|
|
797
|
+
const pending = consumeMemoryInjection(effShort);
|
|
798
|
+
if (pending) {
|
|
799
|
+
memoryInjectionForStore = pending.hitText;
|
|
800
|
+
if ((0, memory_reflex_1.injectionWasUsed)(pending.hitText, turn.userPrompt, turn.agentResponse)) {
|
|
801
|
+
recordMemoryUsed(pending.projectId);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch { /* efficacy capture is best-effort — never block the turn */ }
|
|
807
|
+
// Self-report scan (P1b, adr/memory-reflex.md). If the agent emitted a [[recall: X]] marker
|
|
808
|
+
// (taught by the memory announce) it's flagging its OWN context gap → stash the topic so the
|
|
809
|
+
// NEXT turn's notify pulls + injects it (the self-report trigger). Best-effort.
|
|
810
|
+
try {
|
|
811
|
+
const srShort = (0, session_id_1.truncateSessionId)(input.session_id);
|
|
812
|
+
if (srShort && anchor.projectId) {
|
|
813
|
+
const topic = (0, memory_reflex_1.extractRecallMarker)(turn.agentResponse);
|
|
814
|
+
if (topic)
|
|
815
|
+
stashSelfReport(srShort, anchor.projectId, topic);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
catch { /* best-effort — never block the turn */ }
|
|
787
819
|
// Empty turn — nothing to capture.
|
|
788
820
|
if (!turn.userPrompt && !turn.agentResponse && turn.toolCalls.length === 0)
|
|
789
821
|
return;
|
|
@@ -820,6 +852,12 @@ async function store(input, source = 'claude-code') {
|
|
|
820
852
|
status: turn.status,
|
|
821
853
|
userPrompt,
|
|
822
854
|
agentResponse,
|
|
855
|
+
// The memory injection surfaced this turn (when any) — the SEAM the Flash-Lite efficacy
|
|
856
|
+
// judge (Chip A) consumes server-side at ingest to score used÷injected. Scrubbed + capped
|
|
857
|
+
// like the other text fields. Omitted entirely on the common no-injection turn.
|
|
858
|
+
...(memoryInjectionForStore
|
|
859
|
+
? { memoryInjection: capField((0, secret_scrubber_1.scrubString)(memoryInjectionForStore, redaction)) }
|
|
860
|
+
: {}),
|
|
823
861
|
toolCalls,
|
|
824
862
|
filesTouched: turn.filesTouched,
|
|
825
863
|
artifacts: artifactRefs,
|
|
@@ -828,6 +866,20 @@ async function store(input, source = 'claude-code') {
|
|
|
828
866
|
provenance,
|
|
829
867
|
});
|
|
830
868
|
}
|
|
869
|
+
// ---------- Main ---------------------------------------------------------
|
|
870
|
+
// ---------- Recap (SessionStart) ------------------------------------------
|
|
871
|
+
// `ByPeriodRow`, `stripRecapNoise`, `fmtHoursAgo`, and the recap body
|
|
872
|
+
// renderer (`buildRecapBody`) are now imported from `./opencode-plugin-helpers`
|
|
873
|
+
// — see adr/opencode-monitor-relay.md 2026-06-06 (g). The recap content
|
|
874
|
+
// pushed to the system prompt is the same for Claude/Codex and the opencode
|
|
875
|
+
// plugin; per-harness wrappers (preamble, output mode, transform-hook push)
|
|
876
|
+
// stay local.
|
|
877
|
+
/** Fetch a slim summary of open checkpoints for the given project. Returns
|
|
878
|
+
* up to `limit` rows so the checkpoint module can compute the overflow
|
|
879
|
+
* (3-most-recent + "N more" hint). Returns [] on any error — the hook
|
|
880
|
+
* must never block on a checkpoint query failure. (OpenCheckpointSlim +
|
|
881
|
+
* renderCheckpointHint now live in commands/checkpoint-reminder.ts; the hook
|
|
882
|
+
* owns only this fetch.) */
|
|
831
883
|
async function fetchOpenCheckpoints(apiUrl, apiKey, projectId, limit = 10) {
|
|
832
884
|
try {
|
|
833
885
|
const url = `${apiUrl}/v1/checkpoints/${encodeURIComponent(projectId)}`
|
|
@@ -844,33 +896,6 @@ async function fetchOpenCheckpoints(apiUrl, apiKey, projectId, limit = 10) {
|
|
|
844
896
|
return [];
|
|
845
897
|
}
|
|
846
898
|
}
|
|
847
|
-
/** Render a YYYY-MM-DD date in UTC for the session-start hint. */
|
|
848
|
-
function fmtCheckpointDate(iso) {
|
|
849
|
-
return iso.slice(0, 10);
|
|
850
|
-
}
|
|
851
|
-
/** Format the open-checkpoints hint block. Shape per design doc § Hint
|
|
852
|
-
* placement: 3-most-recent rows + a single overflow line when there are
|
|
853
|
-
* more. Returns null when there are zero open checkpoints — caller
|
|
854
|
-
* treats it as silent. */
|
|
855
|
-
function renderCheckpointHint(open, projectName) {
|
|
856
|
-
if (open.length === 0)
|
|
857
|
-
return null;
|
|
858
|
-
// Server returns DESC by created_at; take the 3 most recent.
|
|
859
|
-
const top = open.slice(0, 3);
|
|
860
|
-
const overflow = open.length - top.length;
|
|
861
|
-
const lines = [];
|
|
862
|
-
lines.push(`[${open.length} checkpoint${open.length === 1 ? '' : 's'} open in ${projectName}:`);
|
|
863
|
-
for (const cp of top) {
|
|
864
|
-
lines.push(` · ${cp.nodeId} ${cp.title} (since ${fmtCheckpointDate(cp.createdAt)})`);
|
|
865
|
-
}
|
|
866
|
-
if (overflow > 0) {
|
|
867
|
-
lines.push(` · ${overflow} more — greprag checkpoint list]`);
|
|
868
|
-
}
|
|
869
|
-
else {
|
|
870
|
-
lines.push(` View one: greprag checkpoint show <id>]`);
|
|
871
|
-
}
|
|
872
|
-
return lines.join('\n');
|
|
873
|
-
}
|
|
874
899
|
/** Fetch unread inbox count for the given project context. Project-scoped
|
|
875
900
|
* count includes tenant-level messages (mail addressed to the user, not
|
|
876
901
|
* any specific project, still shows up). Returns 0 on any error so the
|
|
@@ -931,6 +956,44 @@ async function fetchSessionUnread(apiUrl, apiKey, short) {
|
|
|
931
956
|
return 0;
|
|
932
957
|
}
|
|
933
958
|
}
|
|
959
|
+
/** Fetch top episodic-memory hits for a query (the memory-reflex inline path), project-
|
|
960
|
+
* scoped, with a HARD timeout so a slow search never blocks the turn. POSTs the same
|
|
961
|
+
* `/v1/memory/query` the CLI `memory search` uses, with the de-pollution `session-turn`
|
|
962
|
+
* provenance filter. Returns [] on any error/abort → the reflex stays silent. The
|
|
963
|
+
* confidence gate + framing live in buildMemoryInjection (commands/memory-reflex.ts).
|
|
964
|
+
* adr: adr/memory-reflex.md */
|
|
965
|
+
async function fetchMemoryHits(apiUrl, apiKey, projectId, query, limit = 3, timeoutMs = 2000) {
|
|
966
|
+
const ctl = new AbortController();
|
|
967
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
968
|
+
try {
|
|
969
|
+
const res = await fetch(`${apiUrl}/v1/memory/query`, {
|
|
970
|
+
method: 'POST',
|
|
971
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
972
|
+
body: JSON.stringify({ query, limit, projectId, filters: { provenance: 'session-turn' } }),
|
|
973
|
+
signal: ctl.signal,
|
|
974
|
+
});
|
|
975
|
+
if (!res.ok)
|
|
976
|
+
return { hits: [], quiet: false };
|
|
977
|
+
const data = await res.json();
|
|
978
|
+
// The server's Flash-Lite efficacy verdict rides the same response (adr/memory-reflex.md).
|
|
979
|
+
const quiet = data.memoryReflexQuiet === true;
|
|
980
|
+
if (!data.ok || !Array.isArray(data.nodes))
|
|
981
|
+
return { hits: [], quiet };
|
|
982
|
+
return {
|
|
983
|
+
hits: data.nodes.map(n => ({
|
|
984
|
+
content: n.content, score: n.score, confidence: n.confidence ?? null,
|
|
985
|
+
shape: n.shape ?? null, createdAt: n.createdAt ?? null, projectName: n.projectName ?? null,
|
|
986
|
+
})),
|
|
987
|
+
quiet,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
return { hits: [], quiet: false };
|
|
992
|
+
}
|
|
993
|
+
finally {
|
|
994
|
+
clearTimeout(timer);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
934
997
|
/** Identity-drift warning rate limiter.
|
|
935
998
|
*
|
|
936
999
|
* The drift warning ("stored ID ≠ git-derived ID, run `greprag doctor`") used
|
|
@@ -1095,34 +1158,29 @@ async function recap(input, mode = 'plain') {
|
|
|
1095
1158
|
// (and then read) another session's session-targeted DM. Live inbox now
|
|
1096
1159
|
// belongs exclusively to Monitor watchers; the agent learns about its own
|
|
1097
1160
|
// mail via the watcher's session-scoped stream.
|
|
1098
|
-
// Open-checkpoints
|
|
1099
|
-
//
|
|
1161
|
+
// Open-checkpoints — checkpoints are operator-triggered landmarks, not message-shape
|
|
1162
|
+
// inbox traffic, no privacy issue. The hook owns ONLY this fetch; the checkpoint module
|
|
1163
|
+
// (commands/checkpoint-reminder.ts) renders the list → hint block in its announce.
|
|
1100
1164
|
const openCheckpoints = await fetchOpenCheckpoints(cfg.apiUrl, cfg.apiKey, anchor.projectId, 10);
|
|
1101
|
-
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
// deduped. This is NOT the v5.6.1 eavesdrop vector — see fetchFrontDeskUnread.
|
|
1107
|
-
// (Suppressing it when a live receptionist is attached is a clean follow-up.)
|
|
1108
|
-
// adr: adr/address-grammar.md (2026-06-10), docs/front-desk-switchboard.md §5
|
|
1165
|
+
// Front-desk awareness — a discreet count of cold opens + inbound email waiting at the
|
|
1166
|
+
// shared tenant front desk, so a stranger's first contact isn't stranded until someone
|
|
1167
|
+
// runs `greprag inbox`. Content is NEVER injected — just the count + the door, formatted
|
|
1168
|
+
// by the front-desk module's announce. NOT the v5.6.1 eavesdrop vector — see
|
|
1169
|
+
// fetchFrontDeskUnread. adr: adr/address-grammar.md (2026-06-10), docs/front-desk-switchboard.md §5
|
|
1109
1170
|
const frontDeskUnread = await fetchFrontDeskUnread(cfg.apiUrl, cfg.apiKey);
|
|
1110
|
-
const frontDeskHint = frontDeskUnread > 0
|
|
1111
|
-
? `[📬 ${frontDeskUnread} message${frontDeskUnread === 1 ? '' : 's'} at the front desk — \`greprag inbox --front-desk\` to read]`
|
|
1112
|
-
: null;
|
|
1113
1171
|
// Assistant doctrine auto-load — fires ONLY for the flagged assistant project
|
|
1114
|
-
// (isAssistantProject), so a normal project sees zero change.
|
|
1115
|
-
//
|
|
1116
|
-
// guaranteeing the loop loads on every session start. adr: adr/assistant-role.md
|
|
1172
|
+
// (isAssistantProject), so a normal project sees zero change. The hook owns the
|
|
1173
|
+
// doctrine-file read; the assistant-doctrine module routes the text. adr: adr/assistant-role.md
|
|
1117
1174
|
const assistantDoctrine = (0, project_anchor_1.isAssistantProject)(anchor)
|
|
1118
1175
|
? (0, assistant_doctrine_1.buildAssistantDoctrineContext)(cwd)
|
|
1119
1176
|
: null;
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1177
|
+
// ── THE single SessionStart announce assembly (docs/reminder-interrupt.md §Registry
|
|
1178
|
+
// spec) ──────────────────────────────────────────────────────────────────────────────
|
|
1179
|
+
// Every agent-facing announce — setup-warning, front-desk, checkpoint, assistant-doctrine,
|
|
1180
|
+
// watcher-arm, mechanic — is now a registry module. The hook did the I/O above; here it
|
|
1181
|
+
// stuffs the results into ONE env and collectAnnounces renders them in registry order.
|
|
1182
|
+
// Loaded once + re-fired on compact (recap matcher ''). The Mechanic kill switch drops
|
|
1183
|
+
// ONLY the mechanic announce; a missing session id drops ONLY watcher-arm.
|
|
1126
1184
|
const announceShort = (0, session_id_1.truncateSessionId)(input.session_id) || '';
|
|
1127
1185
|
const announceEnv = {
|
|
1128
1186
|
short: announceShort, turnCount: 0, stress: 0, armed: false, sessionUnread: 0,
|
|
@@ -1134,25 +1192,17 @@ async function recap(input, mode = 'plain') {
|
|
|
1134
1192
|
catch {
|
|
1135
1193
|
return false;
|
|
1136
1194
|
} })(),
|
|
1195
|
+
projectName: anchor.projectName,
|
|
1196
|
+
setupWarning,
|
|
1197
|
+
frontDeskUnread,
|
|
1198
|
+
openCheckpoints,
|
|
1199
|
+
assistantDoctrine,
|
|
1137
1200
|
};
|
|
1138
1201
|
let announceReg = mechanicKilled() ? reminder_registry_1.REGISTRY.filter(m => m.id !== 'mechanic-friction') : reminder_registry_1.REGISTRY;
|
|
1139
1202
|
if (!announceShort)
|
|
1140
1203
|
announceReg = announceReg.filter(m => m.id !== 'watcher-arm');
|
|
1141
|
-
const
|
|
1142
|
-
const preamble = () =>
|
|
1143
|
-
const lines = [];
|
|
1144
|
-
if (setupWarning)
|
|
1145
|
-
lines.push(setupWarning);
|
|
1146
|
-
if (frontDeskHint)
|
|
1147
|
-
lines.push(frontDeskHint);
|
|
1148
|
-
if (checkpointHint)
|
|
1149
|
-
lines.push(checkpointHint);
|
|
1150
|
-
if (assistantDoctrine)
|
|
1151
|
-
lines.push(assistantDoctrine);
|
|
1152
|
-
if (mechanicAnnounce)
|
|
1153
|
-
lines.push(mechanicAnnounce);
|
|
1154
|
-
return lines.length ? lines.join('\n') + '\n' : '';
|
|
1155
|
-
};
|
|
1204
|
+
const announceBlock = (0, reminder_registry_1.collectAnnounces)(announceEnv, announceReg).join('\n\n') || null;
|
|
1205
|
+
const preamble = () => (announceBlock ? announceBlock + '\n' : '');
|
|
1156
1206
|
// Per-project opt-out — agent flips session_start_recap=false to suppress
|
|
1157
1207
|
// the episodic injection for a specific project without disabling the
|
|
1158
1208
|
// hook globally. Setup warning + checkpoint hint still fire above.
|
|
@@ -1180,24 +1230,8 @@ async function recap(input, mode = 'plain') {
|
|
|
1180
1230
|
return;
|
|
1181
1231
|
}
|
|
1182
1232
|
const parts = [];
|
|
1183
|
-
if (
|
|
1184
|
-
parts.push(
|
|
1185
|
-
parts.push('');
|
|
1186
|
-
}
|
|
1187
|
-
if (frontDeskHint) {
|
|
1188
|
-
parts.push(frontDeskHint);
|
|
1189
|
-
parts.push('');
|
|
1190
|
-
}
|
|
1191
|
-
if (checkpointHint) {
|
|
1192
|
-
parts.push(checkpointHint);
|
|
1193
|
-
parts.push('');
|
|
1194
|
-
}
|
|
1195
|
-
if (assistantDoctrine) {
|
|
1196
|
-
parts.push(assistantDoctrine);
|
|
1197
|
-
parts.push('');
|
|
1198
|
-
}
|
|
1199
|
-
if (mechanicAnnounce) {
|
|
1200
|
-
parts.push(mechanicAnnounce);
|
|
1233
|
+
if (announceBlock) {
|
|
1234
|
+
parts.push(announceBlock);
|
|
1201
1235
|
parts.push('');
|
|
1202
1236
|
}
|
|
1203
1237
|
parts.push(body);
|
|
@@ -1336,13 +1370,168 @@ function recordFrictionFire(projectId, tier, turnCount) {
|
|
|
1336
1370
|
}
|
|
1337
1371
|
catch { /* counter is best-effort — never block the turn */ }
|
|
1338
1372
|
}
|
|
1373
|
+
/** Local path for this project's memory-reflex fire-counter — the data the /mechanic
|
|
1374
|
+
* excessiveness monitor reads (fires · injected · silent · per-trigger). Beside the
|
|
1375
|
+
* friction counter under ~/.greprag/state. */
|
|
1376
|
+
function memoryReflexStatsPath(projectId) {
|
|
1377
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
1378
|
+
return path.join(home, '.greprag', 'state', `memory-reflex-${projectId}.json`);
|
|
1379
|
+
}
|
|
1380
|
+
/** Tally one memory-reflex fire (best-effort). `didInject` splits fire→injected vs silent
|
|
1381
|
+
* so the monitor can read the inject/fire ratio (firing without finding = noise). */
|
|
1382
|
+
function recordMemoryFire(projectId, trigger, didInject, turnCount) {
|
|
1383
|
+
try {
|
|
1384
|
+
const file = memoryReflexStatsPath(projectId);
|
|
1385
|
+
let prior = null;
|
|
1386
|
+
try {
|
|
1387
|
+
prior = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
1388
|
+
}
|
|
1389
|
+
catch { /* none yet */ }
|
|
1390
|
+
const next = (0, memory_reflex_1.tallyMemoryFire)(prior, trigger, didInject, turnCount, new Date().toISOString());
|
|
1391
|
+
const dir = path.dirname(file);
|
|
1392
|
+
if (!fs.existsSync(dir))
|
|
1393
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1394
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
|
|
1395
|
+
}
|
|
1396
|
+
catch { /* counter is best-effort — never block the turn */ }
|
|
1397
|
+
}
|
|
1398
|
+
/** Stash for the memory-reflex efficacy capture — the raw hit content injected THIS turn, so
|
|
1399
|
+
* the Stop hook can score the response's overlap against it (the `used` numerator). Keyed by
|
|
1400
|
+
* session (Stop has the short id); carries projectId so Stop needn't re-derive it. Written at
|
|
1401
|
+
* inject-time, consumed + deleted at Stop. adr: adr/memory-reflex.md */
|
|
1402
|
+
function memoryReflexPendingPath(short) {
|
|
1403
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
1404
|
+
return path.join(home, '.greprag', 'state', `memory-reflex-pending-${short}.json`);
|
|
1405
|
+
}
|
|
1406
|
+
function stashMemoryInjection(short, projectId, hitText, turnCount) {
|
|
1407
|
+
try {
|
|
1408
|
+
const file = memoryReflexPendingPath(short);
|
|
1409
|
+
const dir = path.dirname(file);
|
|
1410
|
+
if (!fs.existsSync(dir))
|
|
1411
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1412
|
+
fs.writeFileSync(file, JSON.stringify({ projectId, hitText, turnCount }) + '\n');
|
|
1413
|
+
}
|
|
1414
|
+
catch { /* best-effort — a missed stash just means no efficacy sample this turn */ }
|
|
1415
|
+
}
|
|
1416
|
+
/** Read + DELETE the pending injection stash (consume-once, so a turn with no injection never
|
|
1417
|
+
* reuses a stale sample). Returns null on the common no-stash path. */
|
|
1418
|
+
function consumeMemoryInjection(short) {
|
|
1419
|
+
try {
|
|
1420
|
+
const file = memoryReflexPendingPath(short);
|
|
1421
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
1422
|
+
try {
|
|
1423
|
+
fs.unlinkSync(file);
|
|
1424
|
+
}
|
|
1425
|
+
catch { /* already gone — fine */ }
|
|
1426
|
+
const p = JSON.parse(raw);
|
|
1427
|
+
if (!p || typeof p.projectId !== 'string' || typeof p.hitText !== 'string')
|
|
1428
|
+
return null;
|
|
1429
|
+
return { projectId: p.projectId, hitText: p.hitText };
|
|
1430
|
+
}
|
|
1431
|
+
catch {
|
|
1432
|
+
return null; // no stash this turn (the common case)
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
/** Bump the memory-reflex EFFICACY numerator (the response drew on the injection) — best-effort. */
|
|
1436
|
+
function recordMemoryUsed(projectId) {
|
|
1437
|
+
try {
|
|
1438
|
+
const file = memoryReflexStatsPath(projectId);
|
|
1439
|
+
let prior = null;
|
|
1440
|
+
try {
|
|
1441
|
+
prior = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
1442
|
+
}
|
|
1443
|
+
catch { /* none yet */ }
|
|
1444
|
+
const next = (0, memory_reflex_1.tallyMemoryUsed)(prior);
|
|
1445
|
+
const dir = path.dirname(file);
|
|
1446
|
+
if (!fs.existsSync(dir))
|
|
1447
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1448
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
|
|
1449
|
+
}
|
|
1450
|
+
catch { /* counter is best-effort */ }
|
|
1451
|
+
}
|
|
1452
|
+
/** Self-report stash (P1b, adr/memory-reflex.md) — the topic the agent flagged via a
|
|
1453
|
+
* `[[recall: X]]` marker LAST turn, so the NEXT turn's notify pulls + injects it. Separate
|
|
1454
|
+
* from the efficacy-injection stash. Keyed by session; carries projectId. Written at Stop,
|
|
1455
|
+
* consumed-once at the next notify. */
|
|
1456
|
+
function selfReportPendingPath(short) {
|
|
1457
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
1458
|
+
return path.join(home, '.greprag', 'state', `memory-reflex-selfreport-${short}.json`);
|
|
1459
|
+
}
|
|
1460
|
+
function stashSelfReport(short, projectId, topic) {
|
|
1461
|
+
try {
|
|
1462
|
+
const file = selfReportPendingPath(short);
|
|
1463
|
+
const dir = path.dirname(file);
|
|
1464
|
+
if (!fs.existsSync(dir))
|
|
1465
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1466
|
+
fs.writeFileSync(file, JSON.stringify({ projectId, topic }) + '\n');
|
|
1467
|
+
}
|
|
1468
|
+
catch { /* best-effort — a missed self-report just means no pull this round */ }
|
|
1469
|
+
}
|
|
1470
|
+
/** Read + DELETE the self-report stash (consume-once). Null on the common no-marker path. */
|
|
1471
|
+
function consumeSelfReport(short) {
|
|
1472
|
+
try {
|
|
1473
|
+
const file = selfReportPendingPath(short);
|
|
1474
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
1475
|
+
try {
|
|
1476
|
+
fs.unlinkSync(file);
|
|
1477
|
+
}
|
|
1478
|
+
catch { /* already gone — fine */ }
|
|
1479
|
+
const p = JSON.parse(raw);
|
|
1480
|
+
if (!p || typeof p.projectId !== 'string' || typeof p.topic !== 'string' || !p.topic)
|
|
1481
|
+
return null;
|
|
1482
|
+
return { projectId: p.projectId, topic: p.topic };
|
|
1483
|
+
}
|
|
1484
|
+
catch {
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
/** Local cache of the SERVER's auto-quiet verdict for this project (the
|
|
1489
|
+
* mechanicKilled() analog for the memory-reflex). The Flash-Lite efficacy judge
|
|
1490
|
+
* decides the threshold server-side and ships the boolean on the /v1/memory/query
|
|
1491
|
+
* response; the hook caches it here and `memoryReflexQuieted` honors it with a
|
|
1492
|
+
* TTL (quietCacheSaysSilent). Beside the fire-counter under ~/.greprag/state. */
|
|
1493
|
+
function memoryReflexQuietPath(projectId) {
|
|
1494
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
1495
|
+
return path.join(home, '.greprag', 'state', `memory-reflex-quiet-${projectId}.json`);
|
|
1496
|
+
}
|
|
1497
|
+
/** Has the server's efficacy judge QUIETED this project's memory-reflex (low
|
|
1498
|
+
* used÷injected over a meaningful sample)? Reads the local cache; honors it only
|
|
1499
|
+
* while fresh (TTL) so a recovered reflex re-probes instead of staying silenced.
|
|
1500
|
+
* Fail-quiet: any error reads as NOT quieted (the reflex keeps working) —
|
|
1501
|
+
* mirrors mechanicKilled()'s fail-open posture. */
|
|
1502
|
+
function memoryReflexQuieted(projectId) {
|
|
1503
|
+
try {
|
|
1504
|
+
const raw = fs.readFileSync(memoryReflexQuietPath(projectId), 'utf-8');
|
|
1505
|
+
return (0, memory_reflex_1.quietCacheSaysSilent)(JSON.parse(raw), Date.now());
|
|
1506
|
+
}
|
|
1507
|
+
catch {
|
|
1508
|
+
return false;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
/** Cache the server's auto-quiet verdict from a /v1/memory/query response — written
|
|
1512
|
+
* whenever the reflex actually probes (fired the search). Best-effort. */
|
|
1513
|
+
function cacheMemoryReflexQuiet(projectId, quiet) {
|
|
1514
|
+
try {
|
|
1515
|
+
const file = memoryReflexQuietPath(projectId);
|
|
1516
|
+
const dir = path.dirname(file);
|
|
1517
|
+
if (!fs.existsSync(dir))
|
|
1518
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1519
|
+
const payload = { quiet, cachedAt: new Date().toISOString() };
|
|
1520
|
+
fs.writeFileSync(file, JSON.stringify(payload) + '\n');
|
|
1521
|
+
}
|
|
1522
|
+
catch { /* best-effort — a missed cache just means the reflex re-probes next turn */ }
|
|
1523
|
+
}
|
|
1339
1524
|
/** The reminder-interrupt registry CONTAINER call (docs/reminder-interrupt.md §Registry
|
|
1340
1525
|
* spec) — invoked from the proven-registered `notify` hook (claude-code path). Assembles
|
|
1341
1526
|
* the live env (the ONLY i/o: armed via isLocallyArmed, this session's own unread, the
|
|
1342
|
-
* Stop-stashed stress/turnCount) and fires every module
|
|
1343
|
-
* watcher-arm (unarmed → nag if a peer messaged you, else
|
|
1344
|
-
* the stress-gated mechanic-friction
|
|
1345
|
-
*
|
|
1527
|
+
* Stop-stashed stress/turnCount, the front-desk envelope notice) and fires every module
|
|
1528
|
+
* whose detector reports deficient: watcher-arm (unarmed → nag if a peer messaged you, else
|
|
1529
|
+
* nudge; silent when armed), the stress-gated mechanic-friction, front-desk (a soft
|
|
1530
|
+
* "you've got mail" when a new envelope landed), and memory-reflex (auto-surfaced episodic
|
|
1531
|
+
* memory when THIS prompt signals recall — an inline, time-capped, confidence-gated search).
|
|
1532
|
+
* The announce-only modules (setup-warning / checkpoint / assistant-doctrine) stay silent per
|
|
1533
|
+
* turn — their env signals are SessionStart-only. Stacking; emitted once. Fail-open — any miss
|
|
1534
|
+
* → silent. The Mechanic kill switch drops ONLY the mechanic module; the rest keep working. */
|
|
1346
1535
|
async function injectReminders(input, cfg, short) {
|
|
1347
1536
|
try {
|
|
1348
1537
|
const cwd = input.cwd || process.cwd();
|
|
@@ -1361,9 +1550,50 @@ async function injectReminders(input, cfg, short) {
|
|
|
1361
1550
|
: {};
|
|
1362
1551
|
const turnCount = typeof state.turnCount === 'number' ? state.turnCount : 0;
|
|
1363
1552
|
const stress = typeof state.stress === 'number' ? state.stress : 0;
|
|
1553
|
+
// Front-desk envelope notice — the per-turn half of the front-desk module's pair (the
|
|
1554
|
+
// SessionStart count is the announce half). Body-free, self-dedups per machine, returns
|
|
1555
|
+
// null when nothing new. This is the network call the retired `mail` hook used to make;
|
|
1556
|
+
// it moves here so the whole announce/reminder surface rides ONE detector loop.
|
|
1557
|
+
const frontDeskNotice = await (0, front_desk_mail_1.buildFrontDeskNotice)(cfg.apiUrl, cfg.apiKey);
|
|
1558
|
+
// Memory-reflex (keyword layer) — recall intent in THIS prompt fires an inline,
|
|
1559
|
+
// time-capped, project-scoped search; a hit above the confidence gate is surfaced
|
|
1560
|
+
// inline (top 1-2, capped) so the agent sees it AS it forms its reply. A miss stays
|
|
1561
|
+
// silent (the search result is its own precision filter — no banner-blindness). Every
|
|
1562
|
+
// fire is tallied for the /mechanic excessiveness monitor. Gated on recall-intent, so
|
|
1563
|
+
// most turns skip the search entirely. docs/reminder-interrupt.md (memory-reflex).
|
|
1564
|
+
let memoryHit = null;
|
|
1565
|
+
const prompt = typeof input.prompt === 'string' ? input.prompt : '';
|
|
1566
|
+
// Pick the memory query + trigger (the hybrid): a self-report marker the agent emitted LAST
|
|
1567
|
+
// turn ([[recall: X]], stashed at Stop) takes precedence over recall-intent in THIS prompt —
|
|
1568
|
+
// the agent explicitly flagged its own gap. One injection per turn.
|
|
1569
|
+
let memQuery = null;
|
|
1570
|
+
let memTrigger = 'keyword';
|
|
1571
|
+
const selfReport = anchor.projectId ? consumeSelfReport(short) : null;
|
|
1572
|
+
if (selfReport) {
|
|
1573
|
+
memQuery = selfReport.topic;
|
|
1574
|
+
memTrigger = 'selfReport';
|
|
1575
|
+
}
|
|
1576
|
+
else if ((0, memory_reflex_1.hasRecallIntent)(prompt)) {
|
|
1577
|
+
memQuery = prompt.slice(0, 500);
|
|
1578
|
+
memTrigger = 'keyword';
|
|
1579
|
+
}
|
|
1580
|
+
// Auto-quiet gate (adr/memory-reflex.md): if the server's Flash-Lite efficacy judge has
|
|
1581
|
+
// QUIETED this project's reflex (low used÷injected over a meaningful sample), skip the search
|
|
1582
|
+
// entirely — the cached verdict expires on a TTL so a recovered reflex re-probes. mechanicKilled() analog.
|
|
1583
|
+
if (anchor.projectId && memQuery && !memoryReflexQuieted(anchor.projectId)) {
|
|
1584
|
+
const { hits, quiet } = await fetchMemoryHits(cfg.apiUrl, cfg.apiKey, anchor.projectId, memQuery);
|
|
1585
|
+
cacheMemoryReflexQuiet(anchor.projectId, quiet); // refresh the verdict from this probe
|
|
1586
|
+
memoryHit = (0, memory_reflex_1.buildMemoryInjection)(hits);
|
|
1587
|
+
recordMemoryFire(anchor.projectId, memTrigger, !!memoryHit, turnCount);
|
|
1588
|
+
// Stash what was injected so the Stop hook can score whether the response used it
|
|
1589
|
+
// (the efficacy numerator). Only when something was actually surfaced.
|
|
1590
|
+
if (memoryHit)
|
|
1591
|
+
stashMemoryInjection(short, anchor.projectId, (0, memory_reflex_1.gatedHitText)(hits), turnCount);
|
|
1592
|
+
}
|
|
1364
1593
|
const env = {
|
|
1365
1594
|
short, turnCount, stress, armed, sessionUnread,
|
|
1366
1595
|
ownerPid, alias: (0, session_id_1.readIdentityAlias)(), assistant,
|
|
1596
|
+
frontDeskNotice, memoryHit,
|
|
1367
1597
|
};
|
|
1368
1598
|
// Kill switch drops ONLY the mechanic module — watcher-arm keeps working.
|
|
1369
1599
|
const reg = mechanicKilled() ? reminder_registry_1.REGISTRY.filter(m => m.id !== 'mechanic-friction') : reminder_registry_1.REGISTRY;
|
|
@@ -1390,41 +1620,31 @@ async function injectReminders(input, cfg, short) {
|
|
|
1390
1620
|
async function frictionReminder(_input) {
|
|
1391
1621
|
// intentionally empty — see injectReminders (runs from notify)
|
|
1392
1622
|
}
|
|
1393
|
-
/**
|
|
1394
|
-
* Each turn, ask the server for ENVELOPE-ONLY pending front-desk mail and
|
|
1395
|
-
* inject a one-line "you've got mail from X" notice for any arrival not yet
|
|
1396
|
-
* announced on this machine. It feeds the human sign-off gate — it NEVER
|
|
1397
|
-
* ingests body (front-desk store contract invariant 2/3); the data path is
|
|
1398
|
-
* body-free end to end (envelope type has no body field, server projection
|
|
1399
|
-
* withholds it). Self-silences once each record is announced; the agent stops
|
|
1400
|
-
* the loop by acting on the mail (`greprag email` marks it read).
|
|
1623
|
+
/** Front-desk turn hook — wire as a UserPromptSubmit hook.
|
|
1401
1624
|
*
|
|
1402
|
-
*
|
|
1403
|
-
*
|
|
1625
|
+
* NOTICE ROLE RETIRED 2026-06-20 — the envelope-only "you've got mail from X" notice
|
|
1626
|
+
* moved into the reminder-interrupt registry (front-desk module, fired per turn from
|
|
1627
|
+
* `injectReminders` → the same detector loop as watcher-arm + mechanic-friction), so the
|
|
1628
|
+
* whole announce/reminder surface has ONE assembly. docs/reminder-interrupt.md §Registry spec.
|
|
1404
1629
|
*
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
1408
|
-
*
|
|
1409
|
-
*
|
|
1630
|
+
* What REMAINS here is the attachment auto-save DRAIN — a deliberate side-effect (it WRITES
|
|
1631
|
+
* files to disk), kept OUT of the pure announce/reminder registry by design (the registry
|
|
1632
|
+
* modules are pure; all I/O + side-effects live in the hook). Auto-save (Chip E): when a
|
|
1633
|
+
* project opts in (`email_autosave=true` in .greprag/project.json, or env
|
|
1634
|
+
* GREPRAG_EMAIL_AUTOSAVE), drain new attachments to the configured dir each turn and emit a
|
|
1635
|
+
* one-line saved summary. Default OFF — no opt-in, no pull. Best-effort: a drain failure
|
|
1636
|
+
* never blocks the turn. Unconfigured / no API key → silent. */
|
|
1410
1637
|
async function mail(input) {
|
|
1411
1638
|
const cwd = input.cwd || process.cwd();
|
|
1412
1639
|
const cfg = getConfig(cwd);
|
|
1413
1640
|
if (!cfg.enabled || !cfg.apiKey)
|
|
1414
1641
|
return; // unconfigured → silent
|
|
1415
|
-
const parts = [];
|
|
1416
|
-
const notice = await (0, front_desk_mail_1.buildFrontDeskNotice)(cfg.apiUrl, cfg.apiKey);
|
|
1417
|
-
if (notice)
|
|
1418
|
-
parts.push(notice);
|
|
1419
1642
|
try {
|
|
1420
1643
|
const auto = await maybeAutoSaveAttachments(cwd, cfg.apiUrl, cfg.apiKey);
|
|
1421
1644
|
if (auto)
|
|
1422
|
-
|
|
1645
|
+
writeAdditionalContext(input.hook_event_name || 'UserPromptSubmit', auto);
|
|
1423
1646
|
}
|
|
1424
1647
|
catch { /* drain is best-effort — never block a turn */ }
|
|
1425
|
-
if (parts.length) {
|
|
1426
|
-
writeAdditionalContext(input.hook_event_name || 'UserPromptSubmit', parts.join('\n\n'));
|
|
1427
|
-
}
|
|
1428
1648
|
}
|
|
1429
1649
|
/** Per-turn attachment auto-save. Returns a one-line summary of freshly-saved
|
|
1430
1650
|
* files, or null when auto-save is off or nothing new landed. Gated on an
|