dsh-continual-evolve 0.10.5 → 0.11.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 +25 -31
- package/README.zh.md +18 -21
- package/lib/approval.d.ts +19 -6
- package/lib/approval.js +73 -12
- package/lib/audit-log.d.ts +19 -0
- package/lib/audit-log.js +52 -0
- package/lib/auto.js +1 -0
- package/lib/benchmark-command.d.ts +1 -1
- package/lib/benchmark-command.js +1 -1
- package/lib/command-util.d.ts +70 -0
- package/lib/command-util.js +123 -0
- package/lib/command.d.ts +7 -40
- package/lib/command.js +25 -459
- package/lib/copy.d.ts +4 -41
- package/lib/copy.js +0 -102
- package/lib/declines.d.ts +2 -0
- package/lib/declines.js +4 -0
- package/lib/evolve-event.js +2 -1
- package/lib/extraction-phase.d.ts +132 -0
- package/lib/extraction-phase.js +236 -0
- package/lib/index.d.ts +26 -42
- package/lib/index.js +24 -34
- package/lib/inject.d.ts +13 -119
- package/lib/inject.js +12 -260
- package/lib/injection-caps.d.ts +4 -0
- package/lib/injection-caps.js +4 -0
- package/lib/injection-directory.d.ts +29 -0
- package/lib/injection-directory.js +78 -0
- package/lib/injection-rank.d.ts +85 -0
- package/lib/injection-rank.js +161 -0
- package/lib/listener.d.ts +45 -0
- package/lib/listener.js +199 -0
- package/lib/maintenance-commands.d.ts +23 -0
- package/lib/maintenance-commands.js +136 -0
- package/lib/memory-agent.d.ts +7 -41
- package/lib/memory-agent.js +8 -276
- package/lib/memory-apply.d.ts +44 -0
- package/lib/memory-apply.js +202 -0
- package/lib/memory-manifest.d.ts +16 -0
- package/lib/memory-manifest.js +70 -0
- package/lib/mount-command.js +1 -1
- package/lib/notify.d.ts +9 -20
- package/lib/notify.js +0 -40
- package/lib/plan-command.d.ts +12 -0
- package/lib/plan-command.js +38 -0
- package/lib/prefix-cache.d.ts +2 -1
- package/lib/review-scheduler.d.ts +1 -1
- package/lib/review-scheduler.js +2 -10
- package/lib/runtime-commands.d.ts +14 -0
- package/lib/runtime-commands.js +115 -0
- package/lib/service.d.ts +16 -2
- package/lib/service.js +74 -1
- package/lib/store.d.ts +1 -2
- package/lib/store.js +2 -2
- package/lib/tool.d.ts +7 -0
- package/lib/tool.js +4 -4
- package/lib/turn-snapshot.d.ts +7 -1
- package/lib/turn-snapshot.js +14 -0
- package/lib/types.d.ts +10 -0
- package/lib/types.js +6 -0
- package/lib/wrapup-candidates.d.ts +127 -0
- package/lib/wrapup-candidates.js +161 -0
- package/lib/wrapup-command.d.ts +2 -1
- package/lib/wrapup-command.js +7 -4
- package/lib/wrapup-proposals.d.ts +101 -0
- package/lib/wrapup-proposals.js +281 -0
- package/lib/wrapup.d.ts +8 -212
- package/lib/wrapup.js +4 -438
- package/package.json +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { MEMORY_TYPE_KEY, isArchived, isMemoryType } from "./types.js";
|
|
2
|
+
import { rankEntries } from "./injection-rank.js";
|
|
3
|
+
import { MAX_INJECTED_ENTRIES_PER_KIND } from "./injection-caps.js";
|
|
4
|
+
/** Directory lines injected per build before folding into a counter. */
|
|
5
|
+
export const DEFAULT_DIRECTORY_LINES = 15;
|
|
6
|
+
/** One directory index line: memory lines carry their recall-type hook. */
|
|
7
|
+
export function directoryLine(entry) {
|
|
8
|
+
if (entry.kind === "memory") {
|
|
9
|
+
const type = entry.metadata[MEMORY_TYPE_KEY];
|
|
10
|
+
const hook = isMemoryType(type) ? `:${type}` : "";
|
|
11
|
+
return `- [memory${hook}:${entry.id}] ${entry.title}`;
|
|
12
|
+
}
|
|
13
|
+
return `- [${entry.kind}:${entry.id}] ${entry.title}`;
|
|
14
|
+
}
|
|
15
|
+
/** Positional contract for the directory variadics: the first two arrays
|
|
16
|
+
* must be the content-section kinds (prompt, subagent) — the redundancy
|
|
17
|
+
* check counts exactly those as already visible. Callers pass
|
|
18
|
+
* (prompt, subagent, memory, skill); any other order miscounts. */
|
|
19
|
+
const CONTENT_SECTION_KINDS = 2;
|
|
20
|
+
export function formatEntriesDirectory(...kindEntries) {
|
|
21
|
+
return formatEntriesDirectoryCapped(DEFAULT_DIRECTORY_LINES, ...kindEntries);
|
|
22
|
+
}
|
|
23
|
+
/** {@link formatEntriesDirectory} with an explicit cap (configurable). */
|
|
24
|
+
export function formatEntriesDirectoryCapped(maxLines, ...kindEntries) {
|
|
25
|
+
return formatEntriesDirectoryRanked(maxLines, undefined, ...kindEntries);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* {@link formatEntriesDirectoryCapped} with a relevance query: entries are
|
|
29
|
+
* ordered by {@link rankEntries} (relevance first, then recency) so the cap
|
|
30
|
+
* folds the least relevant entries — never the dictionary tail. An empty
|
|
31
|
+
* query degrades to the valence/recency/stable order, still deterministic.
|
|
32
|
+
*/
|
|
33
|
+
export function formatEntriesDirectoryRanked(maxLines, query, ...kindEntries) {
|
|
34
|
+
const total = kindEntries.flat().filter((e) => !isArchived(e)).length;
|
|
35
|
+
if (total === 0) {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
// Suppressed (every entry already content-visible) renders as "" — the
|
|
39
|
+
// prompt renderer then drops the section entirely.
|
|
40
|
+
const shown = rankedDirectoryEntries(maxLines, query, ...kindEntries);
|
|
41
|
+
if (shown.length === 0) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
const lines = ["# Continual Harness — Entry Directory", "All entries (use evolve_list for full text of any entry):"];
|
|
45
|
+
for (const entry of shown) {
|
|
46
|
+
lines.push(directoryLine(entry));
|
|
47
|
+
}
|
|
48
|
+
const hidden = total - shown.length;
|
|
49
|
+
if (hidden > 0) {
|
|
50
|
+
lines.push(`- …and ${hidden} more entries (evolve_list for the full index)`);
|
|
51
|
+
}
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The directory entries actually shown under the cap, in display order —
|
|
56
|
+
* the single source for both the rendered text and usage accounting (so a
|
|
57
|
+
* memory's injection IS its directory line even with the `:type` hook).
|
|
58
|
+
* Returns [] when the directory is suppressed or empty.
|
|
59
|
+
*/
|
|
60
|
+
export function rankedDirectoryEntries(maxLines, query, ...kindEntries) {
|
|
61
|
+
const allEntries = kindEntries.flat().filter((e) => !isArchived(e));
|
|
62
|
+
if (allEntries.length === 0) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
// Skip the directory only when EVERY entry is already content-visible.
|
|
66
|
+
// Only the first two arrays (prompt, subagent) have curated sections —
|
|
67
|
+
// memories and skills have NO content injection, so they are invisible
|
|
68
|
+
// unless the directory lists them (pre-2026-08-22 the redundancy check
|
|
69
|
+
// wrongly counted them as "already shown", hiding small stores entirely).
|
|
70
|
+
const contentVisible = kindEntries
|
|
71
|
+
.slice(0, CONTENT_SECTION_KINDS)
|
|
72
|
+
.reduce((sum, entries) => sum + Math.min(entries.filter((e) => !isArchived(e)).length, MAX_INJECTED_ENTRIES_PER_KIND), 0);
|
|
73
|
+
if (allEntries.length <= contentVisible) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
return rankEntries(allEntries, query).slice(0, Math.max(maxLines, 1));
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=injection-directory.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injection ranking core: deterministic entry ordering (relevance →
|
|
3
|
+
* negative valence → recency → stable order) and the relevance query
|
|
4
|
+
* composed from the assembling agent's most recent direct user messages.
|
|
5
|
+
* Pure logic — no store access, no host API.
|
|
6
|
+
*/
|
|
7
|
+
import type { HarnessEntry } from "./types.js";
|
|
8
|
+
import { tokenize } from "./search.js";
|
|
9
|
+
/** Recency half-life for the injection ranking: an entry this old scores 0. */
|
|
10
|
+
export declare const RECENCY_HALF_LIFE_MS: number;
|
|
11
|
+
/** At most this many recent user messages feed the relevance query. */
|
|
12
|
+
export declare const MAX_QUERY_MESSAGES = 3;
|
|
13
|
+
/** Query text handed to the relevance scorer is capped at this many chars. */
|
|
14
|
+
export declare const MAX_QUERY_CHARS = 400;
|
|
15
|
+
/** A user-message event's durable shape, loosened for duck typing. */
|
|
16
|
+
export interface UserMessageEventLike {
|
|
17
|
+
type?: string;
|
|
18
|
+
data?: {
|
|
19
|
+
content?: unknown;
|
|
20
|
+
source?: {
|
|
21
|
+
kind?: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** The minimal agent shape the section provider needs (duck-typed). */
|
|
26
|
+
export interface AgentLike {
|
|
27
|
+
id: string;
|
|
28
|
+
session?: {
|
|
29
|
+
header?: {
|
|
30
|
+
parentSession?: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Live append-only session log as the public `session.events` getter.
|
|
34
|
+
* Typed loosely (`unknown[]`) so the real `SessionEvent[]` union from
|
|
35
|
+
* dsh-session is assignable; rows are narrowed to
|
|
36
|
+
* {@link UserMessageEventLike} at read time.
|
|
37
|
+
*/
|
|
38
|
+
events?: readonly unknown[];
|
|
39
|
+
/**
|
|
40
|
+
* Full-log snapshot reader that answers the same rows in the same order
|
|
41
|
+
* where the `events` getter is absent. Called with no arguments for the
|
|
42
|
+
* whole log, which is the read {@link sessionEventsOf} needs.
|
|
43
|
+
*/
|
|
44
|
+
snapshotEvents?: () => readonly unknown[];
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The session's event log through whichever reader the running harness
|
|
49
|
+
* generation exposes. Returns [] when neither is present, so every caller
|
|
50
|
+
* degrades to its empty-log path rather than throwing.
|
|
51
|
+
*/
|
|
52
|
+
export declare function sessionEventsOf(agent: AgentLike | undefined): readonly unknown[];
|
|
53
|
+
/**
|
|
54
|
+
* Normalized recency in [0, 1]: 1 when the entry was just updated, decaying
|
|
55
|
+
* linearly to 0 after {@link RECENCY_HALF_LIFE_MS}. Unparseable timestamps
|
|
56
|
+
* score 0 (never preferred over a timestamped entry).
|
|
57
|
+
*/
|
|
58
|
+
export declare function recencyScore(entry: HarnessEntry, now: number): number;
|
|
59
|
+
/**
|
|
60
|
+
* Rank entries for injection, best first. With no query the ranking is
|
|
61
|
+
* negative-valence first (contradicted entries last), then pure recency
|
|
62
|
+
* (newest first). With a query, entries are scored once against a per-call
|
|
63
|
+
* BM25 index (CJK bigrams; field-weighted title ×2 — see search.ts): any
|
|
64
|
+
* entry with a positive score (≥1 matched token) outranks every hit-less
|
|
65
|
+
* entry (score exactly 0), scores decide the order among relevant entries,
|
|
66
|
+
* the negative-valence counter breaks remaining ties (contradicted entries
|
|
67
|
+
* sink), recency breaks remaining ties, and the stable dictionary order is
|
|
68
|
+
* the final tiebreak, so the result is deterministic. The input is never
|
|
69
|
+
* mutated.
|
|
70
|
+
*/
|
|
71
|
+
export declare function rankEntries(entries: readonly HarnessEntry[], query?: string, now?: number): HarnessEntry[];
|
|
72
|
+
/**
|
|
73
|
+
* Compose the relevance query from the assembling agent's most recent direct
|
|
74
|
+
* user messages (event rows whose `type` is `user/message` and whose source
|
|
75
|
+
* is a human `user`, so injected plugin context and tool results never leak
|
|
76
|
+
* into the query). Returns "" when nothing qualifies — the ranking then
|
|
77
|
+
* falls back to pure recency.
|
|
78
|
+
*/
|
|
79
|
+
export declare function recentUserText(agent: AgentLike | undefined, opts?: {
|
|
80
|
+
maxMessages?: number;
|
|
81
|
+
maxChars?: number;
|
|
82
|
+
}): string;
|
|
83
|
+
/** CJK-bigram tokenizer re-exported for ranking consumers (see search.ts). */
|
|
84
|
+
export { tokenize };
|
|
85
|
+
//# sourceMappingURL=injection-rank.d.ts.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { VALENCE_NEGATIVE_KEY } from "./types.js";
|
|
2
|
+
import { buildRelevanceIndex, relevanceScore, tokenize } from "./search.js";
|
|
3
|
+
/** Recency half-life for the injection ranking: an entry this old scores 0. */
|
|
4
|
+
export const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
5
|
+
/** At most this many recent user messages feed the relevance query. */
|
|
6
|
+
export const MAX_QUERY_MESSAGES = 3;
|
|
7
|
+
/** Query text handed to the relevance scorer is capped at this many chars. */
|
|
8
|
+
export const MAX_QUERY_CHARS = 400;
|
|
9
|
+
/**
|
|
10
|
+
* The session's event log through whichever reader the running harness
|
|
11
|
+
* generation exposes. Returns [] when neither is present, so every caller
|
|
12
|
+
* degrades to its empty-log path rather than throwing.
|
|
13
|
+
*/
|
|
14
|
+
export function sessionEventsOf(agent) {
|
|
15
|
+
const session = agent?.session;
|
|
16
|
+
if (session === undefined) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
if (Array.isArray(session.events)) {
|
|
20
|
+
return session.events;
|
|
21
|
+
}
|
|
22
|
+
const snapshot = session.snapshotEvents?.();
|
|
23
|
+
return Array.isArray(snapshot) ? snapshot : [];
|
|
24
|
+
}
|
|
25
|
+
/** Stable dictionary-order tiebreak used when two entries score equally. */
|
|
26
|
+
function stableCompare(a, b) {
|
|
27
|
+
return [a.path, a.title, a.id].join("\0").localeCompare([b.path, b.title, b.id].join("\0"));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Normalized recency in [0, 1]: 1 when the entry was just updated, decaying
|
|
31
|
+
* linearly to 0 after {@link RECENCY_HALF_LIFE_MS}. Unparseable timestamps
|
|
32
|
+
* score 0 (never preferred over a timestamped entry).
|
|
33
|
+
*/
|
|
34
|
+
export function recencyScore(entry, now) {
|
|
35
|
+
const updatedAt = Date.parse(entry.updated_at);
|
|
36
|
+
if (Number.isNaN(updatedAt)) {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
const age = now - updatedAt;
|
|
40
|
+
if (age <= 0) {
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
return Math.max(0, 1 - age / RECENCY_HALF_LIFE_MS);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Negative-valence counter (P1 效价反馈): entries contradicted by later
|
|
47
|
+
* assessments sink below clean ones at equal relevance/recency — the
|
|
48
|
+
* behavioral analog of a confidence penalty (pi-continuous-learning's
|
|
49
|
+
* contradicted −0.15), without inventing a new score axis.
|
|
50
|
+
*/
|
|
51
|
+
function negativeValence(entry) {
|
|
52
|
+
const value = entry.metadata[VALENCE_NEGATIVE_KEY];
|
|
53
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Rank entries for injection, best first. With no query the ranking is
|
|
57
|
+
* negative-valence first (contradicted entries last), then pure recency
|
|
58
|
+
* (newest first). With a query, entries are scored once against a per-call
|
|
59
|
+
* BM25 index (CJK bigrams; field-weighted title ×2 — see search.ts): any
|
|
60
|
+
* entry with a positive score (≥1 matched token) outranks every hit-less
|
|
61
|
+
* entry (score exactly 0), scores decide the order among relevant entries,
|
|
62
|
+
* the negative-valence counter breaks remaining ties (contradicted entries
|
|
63
|
+
* sink), recency breaks remaining ties, and the stable dictionary order is
|
|
64
|
+
* the final tiebreak, so the result is deterministic. The input is never
|
|
65
|
+
* mutated.
|
|
66
|
+
*/
|
|
67
|
+
export function rankEntries(entries, query, now = Date.now()) {
|
|
68
|
+
const q = (query ?? "").trim();
|
|
69
|
+
if (q.length === 0) {
|
|
70
|
+
return [...entries].sort((a, b) => {
|
|
71
|
+
const valenceDelta = negativeValence(a) - negativeValence(b);
|
|
72
|
+
if (valenceDelta !== 0) {
|
|
73
|
+
return valenceDelta;
|
|
74
|
+
}
|
|
75
|
+
const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
|
|
76
|
+
if (recencyDelta !== 0) {
|
|
77
|
+
return recencyDelta;
|
|
78
|
+
}
|
|
79
|
+
return stableCompare(a, b);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// Precompute scores once: the old comparator re-tokenized both sides on
|
|
83
|
+
// every comparison (O(n log n) tokenizations); one index + one score per
|
|
84
|
+
// entry turns the pass into table lookups.
|
|
85
|
+
const index = buildRelevanceIndex(entries);
|
|
86
|
+
const scores = new Map(entries.map((entry) => [entry, relevanceScore(index, entry, q)]));
|
|
87
|
+
return [...entries].sort((a, b) => {
|
|
88
|
+
const relevanceDelta = (scores.get(b) ?? 0) - (scores.get(a) ?? 0);
|
|
89
|
+
if (relevanceDelta !== 0) {
|
|
90
|
+
return relevanceDelta;
|
|
91
|
+
}
|
|
92
|
+
const valenceDelta = negativeValence(a) - negativeValence(b);
|
|
93
|
+
if (valenceDelta !== 0) {
|
|
94
|
+
return valenceDelta;
|
|
95
|
+
}
|
|
96
|
+
const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
|
|
97
|
+
if (recencyDelta !== 0) {
|
|
98
|
+
return recencyDelta;
|
|
99
|
+
}
|
|
100
|
+
return stableCompare(a, b);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Extract the text of a message's content blocks without depending on the
|
|
105
|
+
* dsh-llm ContentBlock type: string blocks pass through, object blocks
|
|
106
|
+
* contribute their `text` field when present.
|
|
107
|
+
*/
|
|
108
|
+
function extractBlockText(content) {
|
|
109
|
+
if (typeof content === "string") {
|
|
110
|
+
return content;
|
|
111
|
+
}
|
|
112
|
+
if (!Array.isArray(content)) {
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
return content
|
|
116
|
+
.map((block) => {
|
|
117
|
+
if (typeof block === "string") {
|
|
118
|
+
return block;
|
|
119
|
+
}
|
|
120
|
+
if (block !== null && typeof block === "object" && "text" in block && typeof block.text === "string") {
|
|
121
|
+
return block.text;
|
|
122
|
+
}
|
|
123
|
+
return "";
|
|
124
|
+
})
|
|
125
|
+
.filter((text) => text.length > 0)
|
|
126
|
+
.join(" ");
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Compose the relevance query from the assembling agent's most recent direct
|
|
130
|
+
* user messages (event rows whose `type` is `user/message` and whose source
|
|
131
|
+
* is a human `user`, so injected plugin context and tool results never leak
|
|
132
|
+
* into the query). Returns "" when nothing qualifies — the ranking then
|
|
133
|
+
* falls back to pure recency.
|
|
134
|
+
*/
|
|
135
|
+
export function recentUserText(agent, opts) {
|
|
136
|
+
const events = sessionEventsOf(agent);
|
|
137
|
+
if (events.length === 0) {
|
|
138
|
+
return "";
|
|
139
|
+
}
|
|
140
|
+
const maxMessages = opts?.maxMessages ?? MAX_QUERY_MESSAGES;
|
|
141
|
+
const maxChars = opts?.maxChars ?? MAX_QUERY_CHARS;
|
|
142
|
+
const parts = [];
|
|
143
|
+
for (let i = events.length - 1; i >= 0 && parts.length < maxMessages; i -= 1) {
|
|
144
|
+
const event = events[i];
|
|
145
|
+
if (event?.type !== "user/message") {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const source = event.data?.source;
|
|
149
|
+
if (source && source.kind !== "user") {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const text = extractBlockText(event.data?.content).trim();
|
|
153
|
+
if (text.length > 0) {
|
|
154
|
+
parts.unshift(text);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return parts.join(" ").slice(0, maxChars);
|
|
158
|
+
}
|
|
159
|
+
/** CJK-bigram tokenizer re-exported for ranking consumers (see search.ts). */
|
|
160
|
+
export { tokenize };
|
|
161
|
+
//# sourceMappingURL=injection-rank.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The moment-driven extraction listener: registers the host event wiring and
|
|
3
|
+
* the per-session serial scheduler. The extraction logic itself lives in
|
|
4
|
+
* `extraction-phase.ts`; this module owns only WHEN it runs.
|
|
5
|
+
*
|
|
6
|
+
* Moments (refactor 2026-10-03, B verdict):
|
|
7
|
+
* - `session/event` (compaction/start) — before trajectory data is summarized away.
|
|
8
|
+
* - goal blocked streak — `goalBlockedWrapupTurns` consecutive idle probes
|
|
9
|
+
* observing the session goal in phase "blocked" trigger one extraction so
|
|
10
|
+
* the stuck encounter is distilled before the session moves on. The probe
|
|
11
|
+
* is a mechanical in-memory check; it costs no LLM call until the moment fires.
|
|
12
|
+
* - session close — the bounded drain (`sessionCloseDrainMs`) lets an
|
|
13
|
+
* in-flight extraction settle before the owner signal aborts.
|
|
14
|
+
* - manual `/evolve wrapup` — its own consultation flow (`wrapup.ts`).
|
|
15
|
+
*
|
|
16
|
+
* Per-turn snapshot extraction was removed: it burned 90% of the plugin's
|
|
17
|
+
* direct LLM calls for a fifth of the sediment (audit 2026-10-03). A
|
|
18
|
+
* successful turn only advances mechanical counters.
|
|
19
|
+
*
|
|
20
|
+
* All auxiliary work is fire-and-forget with error containment: an automatic
|
|
21
|
+
* failure never disturbs the agent loop, and every decision lands in the
|
|
22
|
+
* shared `reviews.jsonl` audit trail (see `audit-log.ts`).
|
|
23
|
+
*/
|
|
24
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
25
|
+
import type { EvolutionEngine } from "./service.js";
|
|
26
|
+
import { type ReviewScheduler } from "./review-scheduler.js";
|
|
27
|
+
import { type TurnSnapshot } from "./turn-snapshot.js";
|
|
28
|
+
import { type AutoReviewConfig } from "./extraction-phase.js";
|
|
29
|
+
/** Default session-close drain budget: an in-flight extraction gets 15s to settle. */
|
|
30
|
+
export declare const SESSION_CLOSE_DRAIN_MS_DEFAULT = 15000;
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the session-close drain budget: absent/invalid → default, 0 →
|
|
33
|
+
* immediate abort (legacy). Negative or non-finite values fail back to the
|
|
34
|
+
* default rather than silently disabling the drain.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveSessionCloseDrainMs(raw?: number): number;
|
|
37
|
+
/**
|
|
38
|
+
* Bounded session-close drain for one scheduler: wait for in-flight work up
|
|
39
|
+
* to drainMs, then abort the owner signal and drop pending captures. Never
|
|
40
|
+
* rejects — disposal must not hang the host on a stuck model call, and the
|
|
41
|
+
* abort below is idempotent when the drain already settled.
|
|
42
|
+
*/
|
|
43
|
+
export declare function drainSchedulerOnDispose(scheduler: Pick<ReviewScheduler<TurnSnapshot>, "drain" | "shutdown">, drainMs: number): Promise<void>;
|
|
44
|
+
export declare function registerAutoReview(ctx: Context, engine: EvolutionEngine, config: AutoReviewConfig): void;
|
|
45
|
+
//# sourceMappingURL=listener.d.ts.map
|
package/lib/listener.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { goalServiceOf } from "./goal.js";
|
|
2
|
+
import { isGateEnabled } from "./runtime.js";
|
|
3
|
+
import { createReviewScheduler } from "./review-scheduler.js";
|
|
4
|
+
import { captureTurnSnapshot } from "./turn-snapshot.js";
|
|
5
|
+
import { appendReviewRecord, writeArmedMarker } from "./audit-log.js";
|
|
6
|
+
import { runMemoryExtractionPhase, stateFor, } from "./extraction-phase.js";
|
|
7
|
+
/** Default session-close drain budget: an in-flight extraction gets 15s to settle. */
|
|
8
|
+
export const SESSION_CLOSE_DRAIN_MS_DEFAULT = 15000;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the session-close drain budget: absent/invalid → default, 0 →
|
|
11
|
+
* immediate abort (legacy). Negative or non-finite values fail back to the
|
|
12
|
+
* default rather than silently disabling the drain.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveSessionCloseDrainMs(raw) {
|
|
15
|
+
if (raw === undefined)
|
|
16
|
+
return SESSION_CLOSE_DRAIN_MS_DEFAULT;
|
|
17
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0)
|
|
18
|
+
return SESSION_CLOSE_DRAIN_MS_DEFAULT;
|
|
19
|
+
return Math.floor(raw);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Bounded session-close drain for one scheduler: wait for in-flight work up
|
|
23
|
+
* to drainMs, then abort the owner signal and drop pending captures. Never
|
|
24
|
+
* rejects — disposal must not hang the host on a stuck model call, and the
|
|
25
|
+
* abort below is idempotent when the drain already settled.
|
|
26
|
+
*/
|
|
27
|
+
export function drainSchedulerOnDispose(scheduler, drainMs) {
|
|
28
|
+
if (drainMs <= 0) {
|
|
29
|
+
try {
|
|
30
|
+
scheduler.shutdown();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// shutdown is flag-setting plus abort: nothing observable to do
|
|
34
|
+
}
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
}
|
|
37
|
+
let timer;
|
|
38
|
+
const timeout = new Promise((resolve) => {
|
|
39
|
+
timer = setTimeout(resolve, drainMs);
|
|
40
|
+
});
|
|
41
|
+
const settle = () => {
|
|
42
|
+
if (timer !== undefined)
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
try {
|
|
45
|
+
scheduler.shutdown();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// same as above — the abort is best-effort by design
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
return Promise.race([scheduler.drain(), timeout]).then(settle, settle);
|
|
52
|
+
}
|
|
53
|
+
export function registerAutoReview(ctx, engine, config) {
|
|
54
|
+
const perSession = new Map();
|
|
55
|
+
const schedulers = new Map();
|
|
56
|
+
const captureTails = new Map();
|
|
57
|
+
const disposedSessions = new Set();
|
|
58
|
+
const logger = ctx.logger("continual-evolve");
|
|
59
|
+
const defaultEnabled = config.enabledByDefault ?? true;
|
|
60
|
+
const record = (entry) => {
|
|
61
|
+
appendReviewRecord(engine.baseDir, entry, config.reviewsRetain, (cause) => logger.warn(`failed to record auto-review: ${cause instanceof Error ? cause.message : String(cause)}`));
|
|
62
|
+
};
|
|
63
|
+
const schedulerFor = (agent, state) => {
|
|
64
|
+
const existing = schedulers.get(agent.id);
|
|
65
|
+
if (existing)
|
|
66
|
+
return existing;
|
|
67
|
+
const scheduler = createReviewScheduler(async ({ snapshot, signal }) => {
|
|
68
|
+
if (!isGateEnabled(engine.baseDir, defaultEnabled))
|
|
69
|
+
return "aborted";
|
|
70
|
+
if (!snapshot.eligible) {
|
|
71
|
+
record({
|
|
72
|
+
sessionId: snapshot.sessionId,
|
|
73
|
+
reason: snapshot.reason,
|
|
74
|
+
turnsSinceLastReview: state.turns - state.lastReviewAt,
|
|
75
|
+
outcome: "skipped",
|
|
76
|
+
rationale: `snapshot ${snapshot.cursor} skipped: ${snapshot.skipReason ?? "not eligible"}`,
|
|
77
|
+
});
|
|
78
|
+
return "no-op";
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await runMemoryExtractionPhase(ctx, engine, agent, config, state, snapshot, record, signal);
|
|
82
|
+
return "success";
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
86
|
+
logger.warn(`memory extraction failed for ${agent.id}: ${message}`);
|
|
87
|
+
record({
|
|
88
|
+
sessionId: agent.id,
|
|
89
|
+
reason: snapshot.reason,
|
|
90
|
+
turnsSinceLastReview: state.turns - state.lastReviewAt,
|
|
91
|
+
outcome: "failed",
|
|
92
|
+
rationale: `extraction error: ${message}`,
|
|
93
|
+
});
|
|
94
|
+
return "error";
|
|
95
|
+
}
|
|
96
|
+
}, (snapshot) => snapshot.cursor);
|
|
97
|
+
schedulers.set(agent.id, scheduler);
|
|
98
|
+
return scheduler;
|
|
99
|
+
};
|
|
100
|
+
const captureAndSchedule = (agent, state, reason) => {
|
|
101
|
+
if (disposedSessions.has(agent.id) || !isGateEnabled(engine.baseDir, defaultEnabled))
|
|
102
|
+
return;
|
|
103
|
+
const previousCapture = captureTails.get(agent.id) ?? Promise.resolve();
|
|
104
|
+
const captureTask = previousCapture.then(async () => {
|
|
105
|
+
if (disposedSessions.has(agent.id) || !isGateEnabled(engine.baseDir, defaultEnabled))
|
|
106
|
+
return;
|
|
107
|
+
const cursor = schedulerFor(agent, state).getCursor();
|
|
108
|
+
const snapshot = await captureTurnSnapshot(ctx, agent, {
|
|
109
|
+
turn: state.completedTurn,
|
|
110
|
+
reason,
|
|
111
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
112
|
+
maxChars: config.maxInputChars,
|
|
113
|
+
...(config.memoryMinUserWords !== undefined ? { minUserWords: config.memoryMinUserWords } : {}),
|
|
114
|
+
});
|
|
115
|
+
if (!disposedSessions.has(agent.id))
|
|
116
|
+
schedulerFor(agent, state).schedule(snapshot);
|
|
117
|
+
}).catch((cause) => {
|
|
118
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
119
|
+
logger.warn(`memory snapshot failed for ${agent.id}: ${message}`);
|
|
120
|
+
record({
|
|
121
|
+
sessionId: agent.id,
|
|
122
|
+
reason,
|
|
123
|
+
turnsSinceLastReview: state.turns - state.lastReviewAt,
|
|
124
|
+
outcome: "failed",
|
|
125
|
+
rationale: `snapshot error: ${message}`,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
captureTails.set(agent.id, captureTask);
|
|
129
|
+
};
|
|
130
|
+
// DSH emits this awaited boundary when a turn is about to close. It only
|
|
131
|
+
// advances the mechanical counters — extraction is moment-driven, so a
|
|
132
|
+
// successful turn never costs an LLM call by itself.
|
|
133
|
+
ctx.on("agent/turn-stopping", (payload) => {
|
|
134
|
+
const agent = payload.agent;
|
|
135
|
+
if (!agent) {
|
|
136
|
+
logger.warn(`memory extraction listener: agent/turn-stopping payload missing agent; skipping count`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const state = stateFor(perSession, agent.id);
|
|
140
|
+
state.turns += 1;
|
|
141
|
+
if (typeof payload.turn === "number")
|
|
142
|
+
state.completedTurn = Math.max(state.completedTurn, payload.turn);
|
|
143
|
+
});
|
|
144
|
+
// The idle transition is the safe point at which to read the durable
|
|
145
|
+
// surface. It probes the goal phase (mechanical, zero LLM) and fires the
|
|
146
|
+
// goal-blocked moment when the streak reaches the threshold.
|
|
147
|
+
ctx.on("agent/status", (payload) => {
|
|
148
|
+
const agent = payload.agent;
|
|
149
|
+
if (!agent || payload.status !== "idle")
|
|
150
|
+
return;
|
|
151
|
+
if (config.goalBlockedWrapupTurns <= 0)
|
|
152
|
+
return;
|
|
153
|
+
const state = stateFor(perSession, agent.id);
|
|
154
|
+
const goal = goalServiceOf(ctx)?.get(agent);
|
|
155
|
+
if (goal?.phase !== "blocked") {
|
|
156
|
+
state.goalBlockStreak = 0;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
state.goalBlockStreak += 1;
|
|
160
|
+
if (state.goalBlockStreak < config.goalBlockedWrapupTurns)
|
|
161
|
+
return;
|
|
162
|
+
state.goalBlockStreak = 0; // one moment per streak; declines re-arm after the next full streak
|
|
163
|
+
logger.info(`goal-blocked moment [${agent.id}]: ${config.goalBlockedWrapupTurns} consecutive blocked rounds → memory extraction`);
|
|
164
|
+
captureAndSchedule(agent, state, "goal_blocked");
|
|
165
|
+
});
|
|
166
|
+
// Boot-time diagnostic: proves the listener was registered even when its
|
|
167
|
+
// runtime default is off, distinguishing "not registered" from "off".
|
|
168
|
+
writeArmedMarker(engine.baseDir, `automatic evolution listener registered (mode=memory-only; default=${defaultEnabled ? "on" : "off"}; moments=compaction/goal-blocked/session-close-drain/manual-wrapup)`, (cause) => logger.warn(`failed to write armed marker: ${cause instanceof Error ? cause.message : String(cause)}`));
|
|
169
|
+
ctx.on("agent/disposed", (payload) => {
|
|
170
|
+
const agent = payload.agent;
|
|
171
|
+
if (!agent)
|
|
172
|
+
return;
|
|
173
|
+
disposedSessions.add(agent.id);
|
|
174
|
+
const scheduler = schedulers.get(agent.id);
|
|
175
|
+
schedulers.delete(agent.id);
|
|
176
|
+
captureTails.delete(agent.id);
|
|
177
|
+
perSession.delete(agent.id);
|
|
178
|
+
if (scheduler) {
|
|
179
|
+
// Bounded session-close drain: an in-flight extraction gets drainMs
|
|
180
|
+
// to settle so its result is not lost on session close; afterwards
|
|
181
|
+
// the owner signal aborts. Fire-and-forget — disposal must never
|
|
182
|
+
// hang the host, and the helper never rejects.
|
|
183
|
+
void drainSchedulerOnDispose(scheduler, resolveSessionCloseDrainMs(config.sessionCloseDrainMs));
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
ctx.on("session/event", (session, event) => {
|
|
187
|
+
// Compaction moment: extract before the pre-compaction trajectory is
|
|
188
|
+
// summarized away. Cold sessions (no live agent) are ignored.
|
|
189
|
+
if (event.type !== "compaction/start")
|
|
190
|
+
return;
|
|
191
|
+
const agents = ctx.agents;
|
|
192
|
+
const agent = agents?.get(session.id);
|
|
193
|
+
if (!agent)
|
|
194
|
+
return;
|
|
195
|
+
const state = stateFor(perSession, agent.id);
|
|
196
|
+
captureAndSchedule(agent, state, "compact");
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=listener.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { CommandInvocation } from "@deepseek-ai/dsh-commands";
|
|
3
|
+
import { type HarnessScope } from "./types.js";
|
|
4
|
+
import type { EvolutionEngine } from "./service.js";
|
|
5
|
+
import { type CommandTextResult } from "./command-util.js";
|
|
6
|
+
type ScopeArg = {
|
|
7
|
+
scope: HarnessScope;
|
|
8
|
+
rest: string[];
|
|
9
|
+
};
|
|
10
|
+
/** `/evolve archive|unarchive <id> [global|project]` — hide or restore an entry. */
|
|
11
|
+
export declare function executeArchiveCommand(_ctx: Context, engine: EvolutionEngine, invocation: CommandInvocation, sub: "archive" | "unarchive", parsed: ScopeArg, usage: string): Promise<CommandTextResult>;
|
|
12
|
+
/** `/evolve consolidate [apply] [merge]` — deterministic global-store hygiene. */
|
|
13
|
+
export declare function executeConsolidateCommand(engine: EvolutionEngine, rest: string[]): CommandTextResult;
|
|
14
|
+
/** `/evolve failures` — failure-signature aggregation (D1 observation). */
|
|
15
|
+
export declare function executeFailuresCommand(engine: EvolutionEngine): CommandTextResult;
|
|
16
|
+
/** `/evolve log [tail N] [session <sessionId>]` — plugin file log inspection. */
|
|
17
|
+
export declare function executeLogCommand(engine: EvolutionEngine, rest: string[], usage: string): CommandTextResult;
|
|
18
|
+
/** `/evolve export [global|project] <path>` — backup a store to a JSON file. */
|
|
19
|
+
export declare function executeExportCommand(engine: EvolutionEngine, invocation: CommandInvocation, parsed: ScopeArg, usage: string): CommandTextResult;
|
|
20
|
+
/** `/evolve import [global|project] <path>` — restore a store from an export file. */
|
|
21
|
+
export declare function executeImportCommand(engine: EvolutionEngine, invocation: CommandInvocation, parsed: ScopeArg, path: string | undefined): CommandTextResult;
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=maintenance-commands.d.ts.map
|