Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **deja no longer records host notifications as solved work.** In a long cloud session
the only prompts left can be ones the host injects (`<task-notification>`,
`<system-reminder>`, `<wake …>`, another agent's hand-back). `buildSummary` took the
first of them as the session's task, so the repo ledger gained a `summary` claim reading
"<task-notification> <task-type>queued-remote-…" after every check-in, which the next
notification then surfaced as a déjà-vu hit. Such prompts are now skipped on both sides
(`isHarnessPrompt`): the summary uses the first prompt a person typed, or the files
touched, and a notification never triggers a lookup.

## [1.4.2] - 2026-09-24

### Changed
Expand Down
24 changes: 20 additions & 4 deletions src/deja.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,21 @@ const MASKED_RE = /\|\|\s*(true|:)\b|;\s*(true|exit\s+0)\b/;
/** Did this command actually RUN a test suite, with its exit code intact? */
const isTestRun = (command) => TEST_RE.test(command) && !MASKED_RE.test(command);

// Turns the host injects rather than a person typing: a background task finishing, a
// scheduled check-in, a PR notification, another agent's hand-back. In a long cloud
// session these can be the only "prompts" left, and a summary minted from one records
// the wrapper as solved work ("<task-notification> <task-type>queued-remote-…"), which
// then comes back as a déjà-vu hit on the next notification.
const HARNESS_PROMPT_RE =
/^\s*<(task-notification|system-reminder|wake|agent-message|local-command-stdout|local-command-stderr|command-name|command-message)\b/;

/** Was this prompt injected by the host (a notification wrapper), not typed by a person? */
export const isHarnessPrompt = (text) => HARNESS_PROMPT_RE.test(String(text ?? ""));

/**
* Distill a session's normalized event log into a deterministic summary body, or null
* when there is nothing worth remembering (no prompt and no edits). The gist is the
* first user prompt, secret-redacted (redactSecrets — one truth, two verbs) and
* first prompt a person typed (host-injected notifications are skipped), secret-redacted (redactSecrets — one truth, two verbs) and
* whitespace-collapsed; files are the sorted unique edit targets. `tested` reports
* whether a test command exited 0 this session (drives the confirm outcome, NOT the body
* — verification must be evidence, never a self-asserted flag).
Expand All @@ -71,7 +82,11 @@ export function buildSummary(events = []) {
...new Set(events.filter((e) => e.type === "edit" && e.file).map((e) => e.file)),
].sort();
const first = events.find(
(e) => e.type === "prompt" && typeof e.text === "string" && e.text.trim(),
(e) =>
e.type === "prompt" &&
typeof e.text === "string" &&
e.text.trim() &&
!isHarnessPrompt(e.text),
);
const gist = first ? redactSecrets(first.text).replace(/\s+/g, " ").trim().slice(0, 280) : "";
if (!gist && !files.length) return null;
Expand Down Expand Up @@ -184,7 +199,8 @@ export function dejaLine(top, nowDay = 0) {

/**
* Full best-effort advisory for a task: kill-switch check (FORGE_DEJA=0), load, rank,
* format. Returns "" for a disabled switch, an empty query, no hits, or any failure —
* format. Returns "" for a disabled switch, an empty or host-injected query, no hits, or
* any failure —
* safe to call from a hook or a preflight path.
* @param {string} root
* @param {string} task
Expand All @@ -193,7 +209,7 @@ export function dejaLine(top, nowDay = 0) {
*/
export function dejaAdvisory(root, task, nowDay = epochDay()) {
if (process.env.FORGE_DEJA === "0") return "";
if (!task || !String(task).trim()) return "";
if (!task || !String(task).trim() || isHarnessPrompt(task)) return "";
try {
const hits = dejaFromLedger(root, task, { nowDay, budget: 3 });
const line = dejaLine(hits[0], nowDay);
Expand Down
45 changes: 45 additions & 0 deletions test/deja.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
dejaFromLedger,
dejaLine,
dejaLookup,
isHarnessPrompt,
recordSessionSummary,
} from "../src/deja.js";
import { mintClaim, val } from "../src/ledger.js";
Expand Down Expand Up @@ -110,6 +111,50 @@ test("dejaAdvisory: kill switch and empty task both yield silence", () => {
assert.equal(dejaAdvisory(root, " ", 100), "");
});

test("buildSummary: host-injected notification prompts are not the session's task", () => {
const note =
"<task-notification> <task-type>queued-remote-notifications</task-type> <status>pending</status>";
assert.equal(isHarnessPrompt(note), true);
assert.equal(isHarnessPrompt(" <system-reminder>scheduled check-in</system-reminder>"), true);
assert.equal(isHarnessPrompt('<wake reason="external-event">'), true);
assert.equal(
isHarnessPrompt("fix the <Header> overflow"),
false,
"a tag mid-sentence is a person",
);
assert.equal(isHarnessPrompt("<Hero> spacing is off"), false, "a JSX name is not a host wrapper");
// Only notifications and no edits: nothing worth remembering.
assert.equal(buildSummary([{ type: "prompt", text: note }]), null);
// The first prompt a person typed wins over an earlier notification.
const s = buildSummary([
{ type: "prompt", text: note },
{ type: "prompt", text: "tighten the pricing grid gap" },
{ type: "edit", file: "src/Pricing.tsx" },
]);
assert.equal(s.text, "tighten the pricing grid gap");
// Notifications only, but files changed: fall back to the files, never the wrapper.
const f = buildSummary([
{ type: "prompt", text: note },
{ type: "edit", file: "src/a.js" },
]);
assert.equal(f.text, "touched src/a.js");
});

test("dejaAdvisory: a host-injected notification never triggers a déjà-vu lookup", () => {
const root = fixture();
const note = "<task-notification> <task-type>queued-remote-notifications</task-type>";
recordSessionSummary(
root,
"sess-N",
[
{ type: "prompt", text: `${note} x` },
{ type: "edit", file: "a.js" },
],
100,
);
assert.equal(dejaAdvisory(root, note, 101), "");
});

test("recordSessionSummary mints a retrievable summary; passing tests make it verified", () => {
const root = fixture();
const events = [
Expand Down
Loading