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 +18 -1
- package/client.js +304 -0
- package/docs/zh.md +3 -1
- package/index.js +82 -23
- package/lib.js +105 -0
- package/package.json +11 -5
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
|
|
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-
|
|
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.
|
|
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
|
|
236
|
-
// model — so the reply to /deja is where deja's own notes
|
|
237
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
71
|
+
"@vshulcz/deja-vu": "^0.22.2"
|
|
66
72
|
},
|
|
67
73
|
"scripts": {
|
|
68
74
|
"test": "node --test test/*.test.js"
|