dsh-deja 0.22.1 → 0.22.2

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 CHANGED
@@ -8,7 +8,7 @@ DeepSeek Harness can already search its own sessions — that is what the built-
8
8
  `session-query` subsystem does. This plugin answers the other question: what you
9
9
  did in the *other* agents on this machine.
10
10
 
11
- deja indexes the session files that forty other coding agents — Claude Code,
11
+ deja indexes the session files that forty-one other coding agents — Claude Code,
12
12
  Codex, Cursor, opencode, Antigravity, Grok Build, Kimi, Cline and Zed among
13
13
  them — already write to disk. Nothing has to have been recorded ahead of time: the history is already
14
14
  there, including the months before deja was installed. If you moved to dsh last
@@ -64,6 +64,16 @@ command that failed it gets what this machine ran after the same error before,
64
64
  a compaction keeps what the session was in the middle of, and a session that
65
65
  ends drops its live stamp.
66
66
 
67
+ In `dsh web` you also see the lines deja prints for the person in Claude Code:
68
+ the "you have been here" receipt when a prompt was answered from history, and
69
+ at session start what was recalled, the first-build note and the weekly note.
70
+ They sit in a small panel above the input until the next prompt, or until you
71
+ close it. The model gets exactly what it got before; these lines never reach
72
+ it. tui, headless and sdk profiles have no page, so nothing changes there:
73
+ the notes still come with the reply to `/deja`. A line that waits more than
74
+ ten minutes for the page is dropped. `deja install dsh-auto` writes the same
75
+ panel from these files; with both installed you get one panel.
76
+
67
77
  ```yaml
68
78
  - insert:
69
79
  - id: deja
@@ -81,6 +91,13 @@ model: a later listener in that waterfall rebuilds its answer from the payload
81
91
  and the added message is dropped with nothing reported. This was settled by
82
92
  reading the requests dsh 0.1.1-rc.2 actually sent.
83
93
 
94
+ The person's lines take one deja call with the recall, not two: where dsh
95
+ serves the page, the plugin asks for the hook's JSON reply, hands `additionalContext` to the model and keeps
96
+ `systemMessage` on the host until the page asks for it at `/api/deja-notes`,
97
+ behind dsh's own login. Anywhere else it keeps asking for `--plain`, because
98
+ deja marks a note shown when it prints it. The browser half is `client.js`,
99
+ plain JS in dsh's module wrapper, so the package has no build step.
100
+
84
101
  ## What it does not do
85
102
 
86
103
  No LLM, no embeddings. The index is a local BM25 store over files
package/client.js ADDED
@@ -0,0 +1,304 @@
1
+ // dsh-deja, browser half: the lines deja writes for the person.
2
+ //
3
+ // The host half hands the model its recall and keeps deja's own lines for the
4
+ // person — the "you have been here" receipt, the build and week notes — on a
5
+ // board behind /api/deja-notes. This draws them in a small card just above the
6
+ // input, the way Claude Code prints a hook's systemMessage. Plain JS in dsh's
7
+ // module wrapper, so the package needs no build step: React comes from the
8
+ // shell's module table, colours and sizes from dsh's own theme variables, so
9
+ // light and dark both follow the app.
10
+ //
11
+ // Nothing here may break the page. A host without the route answers 404, a
12
+ // page without a session draws nothing, and any failure leaves the dock empty.
13
+ // Text is always inserted as text, and a link is only drawn for an http(s)
14
+ // address.
15
+ window.__ModuleLoader__.load({
16
+ id: "dsh-deja",
17
+ factory: (require) => {
18
+ var module = { exports: {} };
19
+ const React = require("react");
20
+ const h = React.createElement;
21
+
22
+ // The panels above the input — dsh's queue and todo docks sit here.
23
+ const SLOT = "conversation.input.dock";
24
+ // Lines shown above the footer before "more": the receipt and one note.
25
+ // With the footer that is three lines at most.
26
+ const VISIBLE = 2;
27
+
28
+ // ---- what the lines say ----------------------------------------------
29
+
30
+ // The card's header already names deja, so the name in front of each line
31
+ // is dropped when it is drawn. The server's text is not changed.
32
+ function stripName(line) {
33
+ const rest = String(line).replace(/^deja(?:-vu)?(?:\s+—\s+|\s*:\s+|\s+)/i, "");
34
+ if (rest === line || !rest) return line;
35
+ return rest.charAt(0).toUpperCase() + rest.slice(1);
36
+ }
37
+
38
+ // safeHref accepts an http(s) address, with or without its scheme, and
39
+ // nothing else: no javascript:, no data:, no relative path.
40
+ function safeHref(raw) {
41
+ let text = String(raw || "").trim();
42
+ if (!text || /\s/.test(text)) return null;
43
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(text)) text = "https://" + text;
44
+ let url;
45
+ try {
46
+ url = new URL(text);
47
+ } catch {
48
+ return null;
49
+ }
50
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
51
+ if (!url.hostname || !url.hostname.includes(".")) return null;
52
+ return url.href;
53
+ }
54
+
55
+ const RECEIPT = /^deja-vu\s+—\s+(you have been here|this was done on another machine):\s+"(.+)"\s+\((.+)\)$/;
56
+ // "label: address · label: address", every part a link.
57
+ const LINK_PART = /^([A-Za-z][\w ]{0,20}):\s+(\S+)$/;
58
+ // A sentence that ends on its address: " — github.com/…".
59
+ const TRAILING_LINK = /^(.*\S)\s+—\s+(\S+)$/;
60
+
61
+ // parseNotes sorts the board's lines into what the card draws: the
62
+ // receipt, plain notes, a row of links and a sentence that is a link.
63
+ function parseNotes(lines) {
64
+ const out = { receipt: null, notes: [], links: [], linked: [] };
65
+ for (const raw of lines || []) {
66
+ const line = String(raw || "").trim();
67
+ if (!line) continue;
68
+ const r = RECEIPT.exec(line);
69
+ if (r) {
70
+ // Only the brand goes; the lead says what the quote is.
71
+ out.receipt = { lead: r[1].charAt(0).toUpperCase() + r[1].slice(1) + ":", topic: r[2], meta: r[3].replace(/\bvia:\s+/, "via ") };
72
+ continue;
73
+ }
74
+ const parts = line.split(/\s+·\s+/);
75
+ const links = parts.map((p) => {
76
+ const m = LINK_PART.exec(p);
77
+ const href = m && safeHref(m[2]);
78
+ return href ? { label: m[1].charAt(0).toUpperCase() + m[1].slice(1), href } : null;
79
+ });
80
+ // Link parts at the end of a line ("… · docs: address") go to the
81
+ // link row; a line of nothing but links is all link row.
82
+ let keep = parts.length;
83
+ while (keep > 0 && links[keep - 1]) keep--;
84
+ out.links.push(...links.slice(keep));
85
+ if (keep === 0) continue;
86
+ const rest = keep < parts.length ? parts.slice(0, keep).join(" · ") : line;
87
+ const t = TRAILING_LINK.exec(rest);
88
+ const href = t && safeHref(t[2]);
89
+ if (href) {
90
+ out.linked.push({ text: t[1], href });
91
+ continue;
92
+ }
93
+ out.notes.push(stripName(rest));
94
+ }
95
+ return out;
96
+ }
97
+
98
+ // ---- fetching ----------------------------------------------------------
99
+
100
+ function notesUrl(sessionId) {
101
+ // Relative, as dsh's own /api calls are: the page may be mounted below
102
+ // the server root.
103
+ return "api/deja-notes?session=" + encodeURIComponent(String(sessionId));
104
+ }
105
+
106
+ function pull(sessionId) {
107
+ return fetch(notesUrl(sessionId), {
108
+ headers: { accept: "application/json" },
109
+ credentials: "same-origin",
110
+ cache: "no-store",
111
+ })
112
+ .then((res) => (res.ok ? res.json() : null))
113
+ .then((body) => (body && Array.isArray(body.notes) ? body.notes.filter((n) => typeof n === "string") : []))
114
+ .catch(() => []);
115
+ }
116
+
117
+ // ---- drawing -----------------------------------------------------------
118
+
119
+ // Styled with dsh's theme variables, as its own todo and queue docks are,
120
+ // so the card follows light and dark. Fallbacks only matter on a host that
121
+ // defines none of them.
122
+ const STYLES = `
123
+ .deja-card {
124
+ box-sizing: border-box;
125
+ width: calc(100% - 2 * var(--dsh-composer-side-clearance, 16px) - 4 * var(--dsh-composer-dock-inset, 8px));
126
+ max-width: calc(var(--dsh-composer-card-max-width, 780px) - 4 * var(--dsh-composer-dock-inset, 8px));
127
+ max-height: 40vh;
128
+ overflow-y: auto;
129
+ margin: 0 auto;
130
+ padding: 6px 12px 8px;
131
+ border-radius: var(--dsw-radius-lg, 10px);
132
+ background: var(--dsw-specific-menu, Canvas);
133
+ backdrop-filter: var(--dsw-menu-backdrop-filter, none);
134
+ box-shadow: var(--dsw-elevation-panel, none);
135
+ color: var(--dsw-alias-label-secondary, GrayText);
136
+ font-size: 12px;
137
+ line-height: 18px;
138
+ overflow-wrap: anywhere;
139
+ animation: deja-card-in 160ms ease-out;
140
+ }
141
+ @keyframes deja-card-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
142
+ @media (prefers-reduced-motion: reduce) { .deja-card { animation: none; } }
143
+ .deja-head { display: flex; align-items: center; gap: 8px; height: 20px; }
144
+ .deja-name { flex: 1 1 auto; color: var(--dsw-alias-label-caption, GrayText); font-size: 11px; font-weight: 500; letter-spacing: .02em; }
145
+ .deja-close {
146
+ flex: none; display: grid; place-items: center; width: 20px; height: 20px; margin-right: -6px;
147
+ padding: 0; border: 0; border-radius: 999px; background: transparent; cursor: pointer;
148
+ color: var(--dsw-alias-label-tertiary, GrayText); font: inherit; font-size: 14px; line-height: 1;
149
+ }
150
+ .deja-close:hover { background: var(--dsw-alias-interactive-bg-hover, transparent); }
151
+ .deja-close:focus-visible, .deja-more:focus-visible, .deja-card a:focus-visible {
152
+ outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary, Highlight));
153
+ outline-offset: 1px;
154
+ }
155
+ .deja-receipt { margin-top: 2px; font-size: 13px; line-height: 20px; }
156
+ .deja-topic { color: var(--dsw-alias-label-primary, CanvasText); }
157
+ .deja-lead { color: var(--dsw-alias-label-tertiary, GrayText); }
158
+ .deja-meta { color: var(--dsw-alias-label-tertiary, GrayText); font-size: 12px; margin-left: 8px; white-space: nowrap; }
159
+ .deja-note { color: var(--dsw-alias-label-secondary, GrayText); margin-top: 2px; }
160
+ .deja-detail { color: var(--dsw-alias-label-caption, GrayText); }
161
+ .deja-foot { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; margin-top: 4px; font-size: 12px; }
162
+ .deja-card a { color: var(--dsw-alias-link, var(--dsw-alias-state-business-primary, LinkText)); text-decoration: none; }
163
+ .deja-card a:hover { text-decoration: underline; }
164
+ .deja-linked a { color: var(--dsw-alias-label-tertiary, GrayText); }
165
+ .deja-more {
166
+ padding: 0; border: 0; background: transparent; cursor: pointer; font: inherit;
167
+ color: var(--dsw-alias-label-tertiary, GrayText); text-decoration: underline; text-underline-offset: 2px;
168
+ }
169
+ `;
170
+
171
+ function installStyles() {
172
+ const style = document.createElement("style");
173
+ style.dataset.plugin = "dsh-deja";
174
+ style.textContent = STYLES;
175
+ document.head.appendChild(style);
176
+ return () => style.remove();
177
+ }
178
+
179
+ function link(href, label, key) {
180
+ return h("a", { key, href, target: "_blank", rel: "noopener noreferrer" }, label);
181
+ }
182
+
183
+ // A note reads as its claim and then its detail: the part after the first
184
+ // " — " or " · " is the detail ("today: memory arrived 3 times"), drawn quieter.
185
+ function noteRow(text, key) {
186
+ const cut = /\s+(—|·)\s+/.exec(text);
187
+ if (!cut) return h("div", { key, className: "deja-note" }, text);
188
+ return h(
189
+ "div",
190
+ { key, className: "deja-note" },
191
+ text.slice(0, cut.index),
192
+ h("span", { className: "deja-detail" }, " " + text.slice(cut.index).trim()),
193
+ );
194
+ }
195
+
196
+ function Card({ lines, onClose }) {
197
+ const [open, setOpen] = React.useState(false);
198
+ const parsed = parseNotes(lines);
199
+ const body = [];
200
+ if (parsed.receipt) {
201
+ const r = parsed.receipt;
202
+ body.push(
203
+ h(
204
+ "div",
205
+ { key: "receipt", className: "deja-receipt" },
206
+ h("span", { className: "deja-lead" }, r.lead + " "),
207
+ h("span", { className: "deja-topic" }, "“" + r.topic + "”"),
208
+ h("span", { className: "deja-meta" }, r.meta),
209
+ ),
210
+ );
211
+ }
212
+ parsed.notes.forEach((n, i) => body.push(noteRow(n, "n" + i)));
213
+ // Links stay in view: the docs, the tracker and the one-time sentence
214
+ // share one small footer row rather than queueing behind "more".
215
+ const footer = [
216
+ ...parsed.links.map((l, i) => link(l.href, l.label, "a" + i)),
217
+ ...parsed.linked.map((l, i) => h("span", { key: "s" + i, className: "deja-linked" }, link(l.href, l.text))),
218
+ ];
219
+ if (body.length === 0 && footer.length === 0) return null;
220
+ const hidden = open ? 0 : Math.max(0, body.length - VISIBLE);
221
+ if (hidden > 0) {
222
+ footer.unshift(h("button", { key: "more", type: "button", className: "deja-more", onClick: () => setOpen(true) }, hidden + " more"));
223
+ }
224
+ return h(
225
+ "div",
226
+ { className: "deja-card", "data-deja-notes": "", role: "status" },
227
+ h(
228
+ "div",
229
+ { className: "deja-head" },
230
+ h("span", { className: "deja-name" }, "deja-vu"),
231
+ h("button", { type: "button", className: "deja-close", title: "Dismiss", "aria-label": "Dismiss deja-vu notes", onClick: onClose }, "×"),
232
+ ),
233
+ hidden > 0 ? body.slice(0, VISIBLE) : body,
234
+ footer.length > 0 ? h("div", { className: "deja-foot" }, footer) : null,
235
+ );
236
+ }
237
+
238
+ function DejaNotes(props) {
239
+ const sessionId = props && props.sessionId;
240
+ const useSession = props && typeof props.useSession === "function" ? props.useSession : null;
241
+ const running = useSession ? useSession((s) => Boolean(s && s.running)) : null;
242
+ const [lines, setLines] = React.useState([]);
243
+
244
+ // A new session or a new turn starts clean: a receipt answers the prompt
245
+ // it came with, and left standing under the next one it would name the
246
+ // wrong question. The session check stands on its own, because dsh may
247
+ // keep this component and hand it another idle session.
248
+ React.useEffect(() => {
249
+ setLines([]);
250
+ }, [sessionId]);
251
+ React.useEffect(() => {
252
+ if (running === true) setLines([]);
253
+ }, [running]);
254
+
255
+ React.useEffect(() => {
256
+ if (!sessionId) return undefined;
257
+ let alive = true;
258
+ const ask = () =>
259
+ pull(sessionId).then((notes) => {
260
+ if (alive && notes.length > 0) setLines((held) => [...held, ...notes].slice(-8));
261
+ });
262
+ ask();
263
+ // The recall runs while the turn's context is assembled, which may be
264
+ // a moment after the page sees the turn start; the end of the turn
265
+ // asks again. A host without useSession gets a slow poll instead.
266
+ const timers = [];
267
+ if (running === true) timers.push(setTimeout(ask, 800), setTimeout(ask, 2500));
268
+ const poll = running === null ? setInterval(ask, 4000) : null;
269
+ return () => {
270
+ alive = false;
271
+ timers.forEach(clearTimeout);
272
+ if (poll) clearInterval(poll);
273
+ };
274
+ }, [sessionId, running]);
275
+
276
+ if (lines.length === 0) return null;
277
+ return h(Card, { lines, onClose: () => setLines([]) });
278
+ }
279
+
280
+ function apply(ctx) {
281
+ try {
282
+ if (typeof ctx.effect === "function") ctx.effect(installStyles, "dsh-deja: styles");
283
+ else installStyles();
284
+ } catch {}
285
+ try {
286
+ ctx.slots.inject(SLOT, () => {
287
+ try {
288
+ return ctx.slots.register({ name: SLOT, id: "deja-notes", order: 900 }, DejaNotes);
289
+ } catch {
290
+ return () => {};
291
+ }
292
+ });
293
+ } catch {}
294
+ }
295
+
296
+ module.exports.inject = ["slots"];
297
+ module.exports.apply = apply;
298
+ // For the tests; the host reads only inject and apply.
299
+ module.exports.parseNotes = parseNotes;
300
+ module.exports.stripName = stripName;
301
+ module.exports.safeHref = safeHref;
302
+ return module.exports;
303
+ },
304
+ });
package/docs/zh.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  DeepSeek Harness 自带的 `session-query` 已经可以检索它自己的会话。这个插件回答的是另一个问题:你在这台机器上**其他**智能体里做过什么。
8
8
 
9
- deja 索引 Claude Code、Codex、Cursor、opencode、Antigravity、Grok Build、Kimi、Cline、Zed 等其他四十个智能体本来就写在磁盘上的会话文件。不需要提前录制:历史已经在那里,包括安装 deja 之前的那些月份。如果你上周才转到 dsh,上周之前做过的事仍然可以在 dsh 里查到。
9
+ deja 索引 Claude Code、Codex、Cursor、opencode、Antigravity、Grok Build、Kimi、Cline、Zed 等其他四十一个智能体本来就写在磁盘上的会话文件。不需要提前录制:历史已经在那里,包括安装 deja 之前的那些月份。如果你上周才转到 dsh,上周之前做过的事仍然可以在 dsh 里查到。
10
10
 
11
11
  ## 安装
12
12
 
@@ -44,6 +44,8 @@ dsh plugin --profile web add github:vshulcz/deja-vu#path:extensions/dsh
44
44
 
45
45
  同一套召回也接上了 `deja install dsh-auto` 接的那些:读、改、写一个文件之后,模型会拿到以前的会话对这个文件说过什么;命令失败之后,会拿到这台机器上次遇到同样错误后跑过的命令;压缩时保留会话正在做的事;会话结束时撤掉它的在线标记。
46
46
 
47
+ 在 `dsh web` 里,你也能看到 deja 在 Claude Code 里给人看的那几行:某个提问由历史回答时的 “you have been here” 回执,以及会话开始时召回了什么、首次建索引的说明和每周小结。它们显示在输入框上方的小面板里,直到下一次提问或你手动关闭。模型收到的内容与之前完全相同,这几行不会进入模型上下文。tui、headless 和 sdk 配置没有页面,因此没有变化:这些说明仍然随 `/deja` 的回复一起显示。等待页面超过十分钟的行会被丢弃。`deja install dsh-auto` 用同一份文件写出同一个面板;两者都装时也只有一个面板。
48
+
47
49
  ```yaml
48
50
  - insert:
49
51
  - id: deja
package/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // dsh answers questions about its own sessions through the built-in
4
4
  // session-query subsystem. This plugin answers the other question: what you did
5
- // in Claude Code, Codex, Cursor, opencode and thirty-seven more agents, on this
5
+ // in Claude Code, Codex, Cursor, opencode and thirty-eight more agents, on this
6
6
  // machine, before dsh existed. The index is deja's; this file is the seam.
7
7
 
8
8
  import { createRequire } from "node:module";
@@ -12,7 +12,7 @@ import { existsSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { join } from "node:path";
14
14
 
15
- import { argv, contributions, exitStatus, guarded, resultText } from "./lib.js";
15
+ import { argv, contributions, exitStatus, guarded, newestEvents, noteBoard, resultText, splitHookReply } from "./lib.js";
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
18
 
@@ -95,7 +95,9 @@ function tools(ctx) {
95
95
  if (!defineTool) return;
96
96
 
97
97
  // Every tool here answers with the text deja printed, so one output
98
- // declaration serves them all. The schema is plain JSON Schema — a
98
+ // declaration serves them all. Printed with --framed: search, ctx, blame,
99
+ // fix and how write for a person, and without the frame a tag planted in a
100
+ // transcript reached the model as though deja had closed its own block. The schema is plain JSON Schema — a
99
101
  // schemastery instance is rejected as "schema must be a value schema object",
100
102
  // because the host validates that this is an ordinary JSON record.
101
103
  const TEXT_OUTPUT = {
@@ -124,7 +126,7 @@ function tools(ctx) {
124
126
  // spend the model's context on a tail nobody reads.
125
127
  const asked = Number.isFinite(args.limit) ? Math.trunc(args.limit) : 5;
126
128
  const limit = String(Math.min(20, Math.max(1, asked)));
127
- return Promise.resolve(answer(run(argv("search", ["--json", "--limit", limit], args.query))));
129
+ return Promise.resolve(answer(run(["--framed", ...argv("search", ["--json", "--limit", limit], args.query)])));
128
130
  },
129
131
  })));
130
132
 
@@ -141,7 +143,7 @@ function tools(ctx) {
141
143
  },
142
144
  output: TEXT_OUTPUT,
143
145
  execute(args) {
144
- return Promise.resolve(answer(run(argv("ctx", [], args.query))));
146
+ return Promise.resolve(answer(run(["--framed", ...argv("ctx", [], args.query)])));
145
147
  },
146
148
  })));
147
149
 
@@ -158,7 +160,7 @@ function tools(ctx) {
158
160
  },
159
161
  output: TEXT_OUTPUT,
160
162
  execute(args) {
161
- return Promise.resolve(answer(run(argv("blame", ["--json"], args.path))));
163
+ return Promise.resolve(answer(run(["--framed", ...argv("blame", ["--json"], args.path)])));
162
164
  },
163
165
  })));
164
166
 
@@ -175,7 +177,7 @@ function tools(ctx) {
175
177
  },
176
178
  output: TEXT_OUTPUT,
177
179
  execute(args) {
178
- return Promise.resolve(answer(run(argv("fix", [], args.error))));
180
+ return Promise.resolve(answer(run(["--framed", ...argv("fix", [], args.error)])));
179
181
  },
180
182
  })));
181
183
 
@@ -192,7 +194,7 @@ function tools(ctx) {
192
194
  },
193
195
  output: TEXT_OUTPUT,
194
196
  execute(args) {
195
- return Promise.resolve(answer(run(argv("how", [], args.what))));
197
+ return Promise.resolve(answer(run(["--framed", ...argv("how", [], args.what)])));
196
198
  },
197
199
  })));
198
200
 
@@ -216,7 +218,7 @@ function tools(ctx) {
216
218
  })));
217
219
  }
218
220
 
219
- function command(ctx) {
221
+ function command(ctx, board) {
220
222
  guarded(() => ctx.commands.register({
221
223
  name: "deja",
222
224
  description: "Search this machine's past AI coding sessions",
@@ -232,9 +234,12 @@ function command(ctx) {
232
234
  // of the words people most want history about is `install`.
233
235
  const out = run(argv("search", [], query));
234
236
  if (!INSTALLED) return { kind: "error", text: MISSING };
235
- // dsh shows a person nothing the hooks print — the digest goes to the
236
- // model — so the reply to /deja is where deja's own notes reach them.
237
- const notes = run(["hook-context", "--notes"]);
237
+ // Outside dsh web a person sees nothing the hooks print — the digest
238
+ // goes to the model — so the reply to /deja is where deja's own notes
239
+ // reach them. In dsh web the session's lines may still be waiting on the
240
+ // board for a page that has not asked; they go here, once, instead.
241
+ const waiting = board ? board.take(sessionId(invocation && invocation.agent)) : [];
242
+ const notes = [...waiting, run(["hook-context", "--notes"])].filter(Boolean).join("\n");
238
243
  return { kind: "success", text: notes ? answer(out) + "\n\n" + notes : answer(out) };
239
244
  },
240
245
  }));
@@ -270,7 +275,7 @@ function userText(message) {
270
275
  // one-shot on the session id in deja's ledger, which survives a resume in a new
271
276
  // process; the Set is the in-process guard that keeps the long-lived web and
272
277
  // tui profiles from spawning deja on every assembly of a session already shown.
273
- function autoDigest(ctx) {
278
+ function autoDigest(ctx, board) {
274
279
  const seen = new Set();
275
280
 
276
281
  guarded(() =>
@@ -283,9 +288,12 @@ function autoDigest(ctx) {
283
288
  const sid = sessionId(agent);
284
289
  if (sid && seen.has(sid)) return "";
285
290
  if (sid) seen.add(sid);
286
- return run(
291
+ return askHook(
287
292
  ["hook-context", "--plain"],
293
+ ["hook-context"],
288
294
  JSON.stringify({ session_id: sid, cwd: sessionCwd(agent), source: "startup", deja_once: true }),
295
+ sid,
296
+ board,
289
297
  );
290
298
  },
291
299
  }),
@@ -311,7 +319,20 @@ function sessionCwd(agent) {
311
319
  return (header && header.cwd) || process.cwd();
312
320
  }
313
321
 
314
- function autoRecall(ctx) {
322
+ // askHook runs a hook once for both readers. Where a page will show the
323
+ // person's lines, deja is asked for its JSON reply: additionalContext, the
324
+ // text --plain prints, goes to the model and systemMessage waits on the board.
325
+ // Anywhere else — tui, headless, sdk — it stays --plain. deja marks a note
326
+ // shown when it prints it, so asking for notes nobody will read spends them,
327
+ // and `/deja` and the next Claude Code session would not see them either.
328
+ function askHook(plain, json, payload, sid, board) {
329
+ if (!board.hasReader()) return run(plain, payload);
330
+ const reply = splitHookReply(run(json, payload));
331
+ board.push(sid, reply.notes);
332
+ return reply.context;
333
+ }
334
+
335
+ function autoRecall(ctx, board) {
315
336
  let asked = "";
316
337
  let askedIn = "";
317
338
  let askedBy = "";
@@ -339,7 +360,13 @@ function autoRecall(ctx) {
339
360
  asked = prompt;
340
361
  askedIn = cwd;
341
362
  askedBy = sid;
342
- recalled = run(["hook-prompt", "--plain"], JSON.stringify({ prompt, cwd, session_id: sid }));
363
+ recalled = askHook(
364
+ ["hook-prompt", "--plain"],
365
+ ["hook-prompt"],
366
+ JSON.stringify({ prompt, cwd, session_id: sid }),
367
+ sid,
368
+ board,
369
+ );
343
370
  }
344
371
  // Silence is the common case: this speaks only when the history answers.
345
372
  return recalled;
@@ -348,6 +375,39 @@ function autoRecall(ctx) {
348
375
  );
349
376
  }
350
377
 
378
+ // The person's half. dsh shows nobody what a plugin hands the model, and has
379
+ // no notice channel a host plugin can write to, so deja's lines wait on a board
380
+ // and the browser half (client.js) asks for them under the composer. The route
381
+ // rides dsh's own /api mount, which applies the same Host/Origin fence and
382
+ // browser login as every other call the page makes. Only a profile that serves
383
+ // that page — the connection service and a web server both — registers it;
384
+ // headless, tui, sdk and an older dsh never do, and keep asking deja for
385
+ // --plain. A second copy of the plugin finds the path taken and stands down.
386
+ const NOTES_PATH = "/api/deja-notes";
387
+
388
+ function notesRoute(ctx, board) {
389
+ if (typeof ctx.inject !== "function") return;
390
+ try {
391
+ ctx.inject(["connection", "webServer"], (c) => {
392
+ const registered = guarded(() =>
393
+ c.connection.fetch.register({
394
+ path: NOTES_PATH,
395
+ methods: ["GET"],
396
+ requestBody: "buffered",
397
+ fetch: async (request) => {
398
+ let sid = "";
399
+ try {
400
+ sid = bareSessionId(new URL(request.url).searchParams.get("session"));
401
+ } catch {}
402
+ return Response.json({ notes: sid ? board.take(sid) : [] }, { headers: { "cache-control": "no-store" } });
403
+ },
404
+ }),
405
+ );
406
+ if (registered) board.setReader();
407
+ });
408
+ } catch {}
409
+ }
410
+
351
411
  // runAsync is run for a seam dsh awaits, so the event loop goes on while deja
352
412
  // answers. It never rejects.
353
413
  function runAsync(args, input) {
@@ -519,10 +579,7 @@ function noteMessage(text) {
519
579
  // carried it in, so both are read. Anything a plugin contributed is skipped:
520
580
  // those carry a source of their own.
521
581
  function lastHumanText(agent) {
522
- const events = agent && agent.session && agent.session.events;
523
- if (!Array.isArray(events)) return "";
524
- for (let i = events.length - 1; i >= 0; i--) {
525
- const event = events[i];
582
+ for (const event of newestEvents(agent && agent.session)) {
526
583
  if (!event) continue;
527
584
  if (event.type === "user/message" && isHuman(event.data)) return userText(event.data);
528
585
  if (event.type === "agent/inbox/spliced") {
@@ -568,11 +625,13 @@ function apply(ctx, config) {
568
625
  { command: installedByCLI("command.js"), auto: installedByCLI("auto.js") },
569
626
  config,
570
627
  );
628
+ const board = noteBoard();
571
629
  if (adds.tools) tools(ctx);
572
- if (adds.command) command(ctx);
630
+ if (adds.command) command(ctx, board);
573
631
  if (adds.recall) {
574
- autoDigest(ctx);
575
- autoRecall(ctx);
632
+ autoDigest(ctx, board);
633
+ autoRecall(ctx, board);
634
+ notesRoute(ctx, board);
576
635
  toolNotes(ctx);
577
636
  sessionEnds(ctx);
578
637
  }
package/lib.js CHANGED
@@ -80,3 +80,108 @@ export function contributions(wiring, config) {
80
80
  recall: settings.autoRecall !== false && !wired.auto,
81
81
  };
82
82
  }
83
+
84
+ // newestEvents walks a dsh session's log from the end, whichever shape the
85
+ // host gives it. 0.1.x exposes the log as session.events; 0.2.0 made the array
86
+ // private and reads one event at a time with eventAt(seq), seq being the next
87
+ // free position. Reading only session.events found nothing on 0.2.0, so the
88
+ // per-prompt recall never ran there.
89
+ export function* newestEvents(session) {
90
+ if (!session) return;
91
+ if (Array.isArray(session.events)) {
92
+ for (let i = session.events.length - 1; i >= 0; i--) yield session.events[i];
93
+ return;
94
+ }
95
+ if (typeof session.eventAt !== "function") return;
96
+ let top;
97
+ try {
98
+ top = Number(session.seq);
99
+ } catch {
100
+ return;
101
+ }
102
+ if (!Number.isSafeInteger(top)) return;
103
+ for (let i = top - 1; i >= 0; i--) {
104
+ let event;
105
+ try {
106
+ event = session.eventAt(i);
107
+ } catch {
108
+ return;
109
+ }
110
+ yield event;
111
+ }
112
+ }
113
+
114
+ // splitHookReply reads what `deja hook-prompt` or `deja hook-context` printed
115
+ // in its JSON form and splits it by reader: additionalContext is for the model,
116
+ // the same text --plain prints, and systemMessage is the line Claude Code
117
+ // shows the person. One spawn answers both.
118
+ //
119
+ // The envelope is the last line that parses as one, so a stray line printed
120
+ // ahead of it does not carry the person's line into the model's context with
121
+ // it. Output with no envelope at all is all context, so a deja that printed
122
+ // plain text still reaches the model.
123
+ export function splitHookReply(stdout) {
124
+ const text = String(stdout || "").trim();
125
+ if (!text) return { context: "", notes: "" };
126
+ const lines = text.split("\n");
127
+ for (let i = lines.length - 1; i >= 0; i--) {
128
+ const line = lines[i].trim();
129
+ if (!line.startsWith("{")) continue;
130
+ let reply;
131
+ try {
132
+ reply = JSON.parse(line);
133
+ } catch {
134
+ continue;
135
+ }
136
+ if (!reply || typeof reply !== "object" || Array.isArray(reply)) continue;
137
+ if (!("hookSpecificOutput" in reply) && !("systemMessage" in reply)) continue;
138
+ const out = reply.hookSpecificOutput;
139
+ const context = out && typeof out.additionalContext === "string" ? out.additionalContext.trim() : "";
140
+ const notes = typeof reply.systemMessage === "string" ? reply.systemMessage.trim() : "";
141
+ return { context, notes };
142
+ }
143
+ return { context: text, notes: "" };
144
+ }
145
+
146
+ // noteBoard holds deja's lines for the person until the page asks for them.
147
+ // The recall runs in the host and the person reads a browser, so each
148
+ // session's lines wait here and are handed over once: take() empties the
149
+ // session's list. Two tabs on one session race for them, and the first to ask
150
+ // gets them; the other shows nothing, which is the same once rule deja keeps.
151
+ //
152
+ // Bounded three ways, because a page that never asks must not grow the host's
153
+ // memory, and a session reopened tomorrow must not open on yesterday's receipt:
154
+ // lines per session, sessions held, and how long a line waits.
155
+ export function noteBoard({ perSession = 8, sessions = 64, ttlMs = 10 * 60 * 1000, now = Date.now } = {}) {
156
+ const board = new Map();
157
+ let reader = false;
158
+ return {
159
+ push(sid, text) {
160
+ const at = now();
161
+ const lines = String(text || "")
162
+ .split("\n")
163
+ .map((line) => line.trim())
164
+ .filter(Boolean)
165
+ .map((line) => ({ line, at }));
166
+ if (!sid || lines.length === 0) return;
167
+ const held = board.get(sid) || [];
168
+ board.delete(sid);
169
+ board.set(sid, [...held, ...lines].slice(-perSession));
170
+ while (board.size > sessions) board.delete(board.keys().next().value);
171
+ },
172
+ take(sid) {
173
+ const held = board.get(sid) || [];
174
+ board.delete(sid);
175
+ const fresh = now() - ttlMs;
176
+ return held.filter((n) => n.at >= fresh).map((n) => n.line);
177
+ },
178
+ // Whether anything will ever read the board. deja marks its notes shown
179
+ // when it prints them, so asking for them with no page to show them on
180
+ // spends them: `/deja` and the next Claude Code session would not see
181
+ // them either.
182
+ hasReader: () => reader,
183
+ setReader: () => {
184
+ reader = true;
185
+ },
186
+ };
187
+ }
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "dsh-deja",
3
- "version": "0.22.1",
4
- "description": "deja-vu memory for DeepSeek Harness: the session history of forty other coding agents, searchable and recalled before each step — a local index, no LLM.",
3
+ "version": "0.22.2",
4
+ "description": "deja-vu memory for DeepSeek Harness: the session history of forty-one other coding agents, searchable and recalled before each step — a local index, no LLM.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
+ "./client": "./client.js",
9
10
  "./package.json": "./package.json"
10
11
  },
11
12
  "files": [
12
13
  "index.js",
13
14
  "lib.js",
15
+ "client.js",
14
16
  "cordis.patch.yml",
15
17
  "README.md",
16
18
  "docs/zh.md",
@@ -54,15 +56,19 @@
54
56
  "bundle": {
55
57
  "patch": "./cordis.patch.yml"
56
58
  },
59
+ "client": {
60
+ "platform": "web"
61
+ },
57
62
  "compatibility": {
58
- "dsh": "0.1.1-rc.2",
63
+ "dsh": "0.2.0-rc.2",
59
64
  "dshReleases": {
60
- "0.1.1-rc.2": "compatible"
65
+ "0.1.1-rc.2": "compatible",
66
+ "0.2.0-rc.2": "compatible"
61
67
  }
62
68
  }
63
69
  },
64
70
  "dependencies": {
65
- "@vshulcz/deja-vu": "^0.22.1"
71
+ "@vshulcz/deja-vu": "^0.22.2"
66
72
  },
67
73
  "scripts": {
68
74
  "test": "node --test test/*.test.js"