dsh-deja 0.21.6 → 0.22.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/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 thirty-four other coding agents — Claude Code,
11
+ deja indexes the session files that forty 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
@@ -58,6 +58,12 @@ whether this machine's history answers the prompt and adds the answer to the
58
58
  runtime context. Silence is the common case — it speaks only when there is
59
59
  something to say.
60
60
 
61
+ The same recall also wires what `deja install dsh-auto` wires: after a read,
62
+ edit or write the model gets what past sessions said about that file, after a
63
+ command that failed it gets what this machine ran after the same error before,
64
+ a compaction keeps what the session was in the middle of, and a session that
65
+ ends drops its live stamp.
66
+
61
67
  ```yaml
62
68
  - insert:
63
69
  - id: deja
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
 
@@ -42,6 +42,8 @@ dsh plugin --profile web add github:vshulcz/deja-vu#path:extensions/dsh
42
42
 
43
43
  以及默认开启的自动召回:每个会话一次,插件把这个项目里以前的会话定下来的事加进来;每一步之前,插件会问 deja 这台机器的历史能否回答当前提问,并把结果加入运行时上下文。多数情况下它保持沉默,只有确实有内容时才开口。
44
44
 
45
+ 同一套召回也接上了 `deja install dsh-auto` 接的那些:读、改、写一个文件之后,模型会拿到以前的会话对这个文件说过什么;命令失败之后,会拿到这台机器上次遇到同样错误后跑过的命令;压缩时保留会话正在做的事;会话结束时撤掉它的在线标记。
46
+
45
47
  ```yaml
46
48
  - insert:
47
49
  - id: deja
package/index.js CHANGED
@@ -2,16 +2,17 @@
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-one more agents, on this
5
+ // in Claude Code, Codex, Cursor, opencode and thirty-seven 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";
9
- import { execFileSync } from "node:child_process";
9
+ import { execFile, execFileSync } from "node:child_process";
10
+ import { randomUUID } from "node:crypto";
10
11
  import { existsSync } from "node:fs";
11
12
  import { homedir } from "node:os";
12
13
  import { join } from "node:path";
13
14
 
14
- import { argv, contributions, guarded } from "./lib.js";
15
+ import { argv, contributions, exitStatus, guarded, resultText } from "./lib.js";
15
16
 
16
17
  const require = createRequire(import.meta.url);
17
18
 
@@ -105,7 +106,7 @@ function tools(ctx) {
105
106
  guarded(() => ctx.tools.register(defineTool({
106
107
  name: "deja_recall",
107
108
  description:
108
- "Search this machine's own past AI coding sessions — every agent used on it, including months before deja was installed. Use before debugging an error or re-implementing anything that may already exist. Match on the most specific token available: an exact error string, function name, file path or flag.",
109
+ "Search this machine's own past AI coding sessions — every agent used on it, including months before deja was installed. Use before debugging an error or re-implementing anything that may already exist, and before the first edit in a task and before calling a change done or ready to merge. Match on the most specific token available: an exact error string, function name, file path or flag.",
109
110
  parameters: {
110
111
  query: {
111
112
  type: "string",
@@ -291,8 +292,14 @@ function autoDigest(ctx) {
291
292
  );
292
293
  }
293
294
 
295
+ // dsh names a session "session-<uuid>" and its log's folder the same; deja
296
+ // indexes it by the uuid, so that is the id every hook is given.
297
+ function bareSessionId(id) {
298
+ return String(id || "").replace(/^session-/, "");
299
+ }
300
+
294
301
  function sessionId(agent) {
295
- return (agent && (agent.sessionId || (agent.session && agent.session.id))) || "";
302
+ return bareSessionId((agent && (agent.sessionId || (agent.session && agent.session.id))) || "");
296
303
  }
297
304
 
298
305
  // The workspace a session belongs to. One web or tui process serves sessions
@@ -307,7 +314,10 @@ function sessionCwd(agent) {
307
314
  function autoRecall(ctx) {
308
315
  let asked = "";
309
316
  let askedIn = "";
317
+ let askedBy = "";
310
318
  let recalled = "";
319
+ const compacted = new Set();
320
+ watchCompaction(ctx, compacted);
311
321
 
312
322
  guarded(() =>
313
323
  ctx.systemPrompt.context({
@@ -319,11 +329,17 @@ function autoRecall(ctx) {
319
329
  const prompt = lastHumanText(agent);
320
330
  if (!prompt) return "";
321
331
  const cwd = sessionCwd(agent);
322
- // The same question asked in another workspace is a different question.
323
- if (prompt !== asked || cwd !== askedIn) {
332
+ const sid = sessionId(agent);
333
+ const afterCompaction = compacted.delete(sid);
334
+ // The same question asked in another workspace is a different question,
335
+ // and so is one asked again after its session compacted: deja then
336
+ // answers with the recovery packet, which only a prompt naming the
337
+ // session gets back.
338
+ if (prompt !== asked || cwd !== askedIn || sid !== askedBy || afterCompaction) {
324
339
  asked = prompt;
325
340
  askedIn = cwd;
326
- recalled = run(["hook-prompt", "--plain"], JSON.stringify({ prompt, cwd }));
341
+ askedBy = sid;
342
+ recalled = run(["hook-prompt", "--plain"], JSON.stringify({ prompt, cwd, session_id: sid }));
327
343
  }
328
344
  // Silence is the common case: this speaks only when the history answers.
329
345
  return recalled;
@@ -332,6 +348,169 @@ function autoRecall(ctx) {
332
348
  );
333
349
  }
334
350
 
351
+ // runAsync is run for a seam dsh awaits, so the event loop goes on while deja
352
+ // answers. It never rejects.
353
+ function runAsync(args, input) {
354
+ return new Promise((resolve) => {
355
+ try {
356
+ const child = execFile(DEJA, args, { encoding: "utf8", timeout: 20000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) =>
357
+ resolve(err ? "" : String(stdout || "").trim()),
358
+ );
359
+ child.on("error", () => resolve(""));
360
+ child.stdin.on("error", () => {});
361
+ child.stdin.end(input);
362
+ } catch {
363
+ resolve("");
364
+ }
365
+ });
366
+ }
367
+
368
+ // Compaction shadows the turns it summarises but leaves them in the session
369
+ // log, so deja reads the session there as compaction/start lands and keeps a
370
+ // recovery packet: the task, each command with how it went, what was still
371
+ // open. The log is written behind the session, so it is flushed first. dsh
372
+ // assembles a step's context before its agent/pre-step waterfall, where the
373
+ // compaction runs, so the step that compacts waits there for the capture and
374
+ // the packet rides the recall of the next step, or of the next turn.
375
+ function watchCompaction(ctx, compacted) {
376
+ if (typeof ctx.on !== "function") return;
377
+ const capturing = new Map();
378
+ const listen = (c) =>
379
+ c.on("session/event", (session, event) => {
380
+ if (!session || !event || event.type !== "compaction/start") return;
381
+ const sid = bareSessionId(session.id || (session.header && session.header.id));
382
+ if (!sid) return;
383
+ const cwd = (session.header && session.header.cwd) || process.cwd();
384
+ // One compaction can follow another inside a step; two captures at once
385
+ // contend for the index and the loser marks the packet unavailable, so
386
+ // each waits for the one before it.
387
+ const before = capturing.get(sid) || Promise.resolve();
388
+ const capture = before.then(async () => {
389
+ try {
390
+ if (c.sessions && typeof c.sessions.flush === "function") await c.sessions.flush(session);
391
+ } catch {}
392
+ await runAsync(["hook-precompact"], JSON.stringify({ session_id: sid, cwd, harness: "deepseek" }));
393
+ compacted.add(sid);
394
+ });
395
+ capturing.set(sid, capture);
396
+ capture.then(() => {
397
+ if (capturing.get(sid) === capture) capturing.delete(sid);
398
+ });
399
+ });
400
+ try {
401
+ if (typeof ctx.inject === "function") ctx.inject(["sessions"], listen);
402
+ else listen(ctx);
403
+ } catch {}
404
+ try {
405
+ ctx.on("agent/pre-step", async (step, next) => {
406
+ const sid = sessionId(step && step.agent);
407
+ if (capturing.has(sid)) await capturing.get(sid);
408
+ const decision = await next();
409
+ if (capturing.has(sid)) await capturing.get(sid);
410
+ return decision;
411
+ });
412
+ } catch {}
413
+ }
414
+
415
+ // The point of action, as `deja install dsh-auto` wires it. tools/post-execute
416
+ // runs on every tool result, and an accept decision's additionalContexts reach
417
+ // the model on the next step: a read, edit or write gets what deja knows about
418
+ // the file, a bash that exited non-zero gets the fix this machine found for
419
+ // that error before. The before-seam, tools/execute, has no channel to the
420
+ // model.
421
+ function toolNotes(ctx) {
422
+ if (typeof ctx.on !== "function") return;
423
+ try {
424
+ ctx.on("tools/post-execute", async (exec, result, next) => {
425
+ const decision = await next();
426
+ try {
427
+ if (!decision || decision.kind !== "accept" || !exec) return decision;
428
+ const note = await toolNote(exec, result);
429
+ if (!note) return decision;
430
+ return {
431
+ ...decision,
432
+ additionalContexts: [...(decision.additionalContexts || []), noteMessage(note)],
433
+ };
434
+ } catch {
435
+ return decision;
436
+ }
437
+ });
438
+ } catch {}
439
+ }
440
+
441
+ // A session is over when dsh disposes of it, or when the process goes: on
442
+ // 0.1.1-rc.2 the headless profile exits without disposing anything, and only
443
+ // process "exit" runs. The digest and the recall stamp a session live, which
444
+ // keeps it out of its own MCP recall; dropping the stamp lets the next session
445
+ // be answered with it now rather than twenty minutes from now.
446
+ function sessionEnds(ctx) {
447
+ if (typeof ctx.on !== "function") return;
448
+ const live = new Set();
449
+ const end = (sid) => {
450
+ if (!sid || !live.has(sid)) return;
451
+ live.delete(sid);
452
+ run(["hook-session-end"], JSON.stringify({ session_id: sid }));
453
+ };
454
+ try {
455
+ ctx.on("session/created", (session) => {
456
+ const sid = bareSessionId(session && session.id);
457
+ if (sid) live.add(sid);
458
+ });
459
+ ctx.on("session/disposed", (session) => end(bareSessionId(session && session.id)));
460
+ process.on("exit", () => {
461
+ for (const sid of [...live]) end(sid);
462
+ });
463
+ } catch {}
464
+ }
465
+
466
+ async function toolNote(exec, result) {
467
+ const args = exec.arguments || {};
468
+ const base = { session_id: sessionId(exec.agent), cwd: sessionCwd(exec.agent) };
469
+ if ((exec.name === "read" || exec.name === "edit" || exec.name === "write") && args.file_path) {
470
+ return runAsync(["hook-tool", "--plain"], JSON.stringify({
471
+ tool_name: exec.name,
472
+ tool_input: { file_path: String(args.file_path) },
473
+ ...base,
474
+ }));
475
+ }
476
+ // The profile's other editor names the file "path"; view is its read.
477
+ if (exec.name === "str_replace_editor" && args.path) {
478
+ return runAsync(["hook-tool", "--plain"], JSON.stringify({
479
+ tool_name: args.command === "view" ? "read" : "edit",
480
+ tool_input: { file_path: String(args.path) },
481
+ ...base,
482
+ }));
483
+ }
484
+ // pwsh is the Windows shell, with the same markers.
485
+ if (exec.name === "bash" || exec.name === "pwsh") {
486
+ const status = exitStatus(resultText(result));
487
+ if (!(result && result.isError) && !status.failed) return "";
488
+ return runAsync(["hook-tool-after", "--plain"], JSON.stringify({
489
+ tool_name: exec.name === "pwsh" ? "powershell" : "bash",
490
+ tool_input: { command: String(args.command || "") },
491
+ tool_response: status.body,
492
+ ...base,
493
+ }));
494
+ }
495
+ return "";
496
+ }
497
+
498
+ // A context message the way dsh's own plugins build one: user role, a plugin
499
+ // source so it is not read back as something the person typed, a fresh id,
500
+ // frozen.
501
+ function noteMessage(text) {
502
+ const message = {
503
+ role: "user",
504
+ content: [{ type: "text", text }],
505
+ source: { kind: "plugin", plugin: "deja", form: "notice" },
506
+ id: randomUUID(),
507
+ };
508
+ Object.freeze(message.content[0]);
509
+ Object.freeze(message.content);
510
+ Object.freeze(message.source);
511
+ return Object.freeze(message);
512
+ }
513
+
335
514
  // lastHumanText is the newest thing the person actually typed. At assembly
336
515
  // time the message has already left the inbox and has not been appended as a
337
516
  // "user/message" yet — the only durable record of it is the inbox splice that
@@ -392,6 +571,8 @@ function apply(ctx, config) {
392
571
  if (adds.recall) {
393
572
  autoDigest(ctx);
394
573
  autoRecall(ctx);
574
+ toolNotes(ctx);
575
+ sessionEnds(ctx);
395
576
  }
396
577
  }
397
578
 
package/lib.js CHANGED
@@ -39,6 +39,29 @@ export function guarded(register) {
39
39
  }
40
40
  }
41
41
 
42
+ // resultText is the text a tool handed back to the model.
43
+ export function resultText(result) {
44
+ const content = result && result.content;
45
+ if (!Array.isArray(content)) return "";
46
+ return content
47
+ .filter((part) => part && part.type === "text" && typeof part.text === "string")
48
+ .map((part) => part.text)
49
+ .join("\n");
50
+ }
51
+
52
+ // exitStatus reads how a dsh command ended. dsh reports it in the text rather
53
+ // than as an error result: a last line of "[exit code: N]" for a non-zero exit,
54
+ // or "[killed by signal: X]". Only the last line counts, since output may quote
55
+ // a marker anywhere else. The marker is cut from the body before the lookup:
56
+ // an error whose last line is "[exit code: 1]" matches nothing on file.
57
+ export function exitStatus(text) {
58
+ const signal = /\n\[killed by signal: [^\]\n]+\]$/.exec(text);
59
+ if (signal) return { body: text.slice(0, signal.index), failed: true };
60
+ const exit = /\n\[exit code: (\d+)\]$/.exec(text);
61
+ if (exit) return { body: text.slice(0, exit.index), failed: exit[1] !== "0" };
62
+ return { body: text, failed: false };
63
+ }
64
+
42
65
  // contributions says what this package adds, given what `deja install` already
43
66
  // wrote into DSH_HOME and what the profile turned off. The rule is the same
44
67
  // everywhere: fill the gaps, never repeat the installer.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-deja",
3
- "version": "0.21.6",
4
- "description": "deja-vu memory for DeepSeek Harness: the session history of thirty-four other coding agents, searchable and recalled before each step — a local index, no LLM.",
3
+ "version": "0.22.0",
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.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -62,7 +62,7 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@vshulcz/deja-vu": "^0.21.6"
65
+ "@vshulcz/deja-vu": "^0.22.0"
66
66
  },
67
67
  "scripts": {
68
68
  "test": "node --test test/*.test.js"