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
package/lib/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import { expandHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
|
19
19
|
import { createEvolutionEngine } from "./service.js";
|
|
20
20
|
import { registerEvolveTools } from "./tool.js";
|
|
21
21
|
import { registerEvolveCommand } from "./command.js";
|
|
22
|
-
import { registerAutoReview } from "./
|
|
22
|
+
import { registerAutoReview } from "./listener.js";
|
|
23
23
|
import { syncSkillsFromResult } from "./skill.js";
|
|
24
24
|
import { entriesSectionText } from "./inject.js";
|
|
25
25
|
import { createFrozenMemorySection, DEFAULT_MEMORY_INDEX_MAX_CHARS, DEFAULT_MEMORY_SECTION_ORDER, MEMORY_SECTION_NAME, memoryIndexSectionText, } from "./memory-index.js";
|
|
@@ -45,9 +45,7 @@ export const Config = z.object({
|
|
|
45
45
|
* Absent → 15s; 0 restores the legacy immediate abort on dispose.
|
|
46
46
|
*/
|
|
47
47
|
sessionCloseDrainMs: z.natural().default(15000),
|
|
48
|
-
/**
|
|
49
|
-
reviewIntervalTurns: z.natural().default(6),
|
|
50
|
-
/** Trajectory slice handed to the gate, in characters. */
|
|
48
|
+
/** Trajectory slice handed to the extractor, in characters. */
|
|
51
49
|
maxReviewInputChars: z.natural().default(40000),
|
|
52
50
|
/** Output budget for the cheap gate call. */
|
|
53
51
|
reviewBudgetTokens: z.natural().default(4096),
|
|
@@ -90,29 +88,23 @@ export const Config = z.object({
|
|
|
90
88
|
*/
|
|
91
89
|
autoCase: z.boolean().default(true),
|
|
92
90
|
/**
|
|
93
|
-
*
|
|
91
|
+
* Optional model override for the memory extractor (cheaper model).
|
|
94
92
|
* Format: "provider/model" or just "model" (same provider as the agent).
|
|
95
|
-
* When absent, the
|
|
93
|
+
* When absent, the extractor uses the agent's own provider/model.
|
|
96
94
|
*/
|
|
97
95
|
reviewModel: z.string(),
|
|
98
96
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* disabled by default even when the automatic pipeline is explicitly enabled.
|
|
97
|
+
* Goal-blocked moment: after this many CONSECUTIVE idle probes that
|
|
98
|
+
* observe the session goal in phase "blocked", run one memory extraction
|
|
99
|
+
* so the stuck encounter is distilled. 0 disables.
|
|
103
100
|
*/
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Minimum turns between local-fate assessments on the turn-interval path
|
|
107
|
-
* (compaction is unconditional). Absent → follows reviewIntervalTurns.
|
|
108
|
-
*/
|
|
109
|
-
fateIntervalTurns: z.natural(),
|
|
101
|
+
goalBlockedWrapupTurns: z.natural().min(0).default(3),
|
|
110
102
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
103
|
+
* Promotion policy (2026-08-22): regex sources whose match in a
|
|
104
|
+
* candidate's title/content marks it project-scoped — such entries are
|
|
105
|
+
* never promoted to the cross-session global store. Replaces the built-in
|
|
106
|
+
* defaults when set.
|
|
114
107
|
*/
|
|
115
|
-
goalBlockedWrapupTurns: z.natural().min(0).default(3),
|
|
116
108
|
/**
|
|
117
109
|
* Promotion policy (2026-08-22): regex sources whose match in a
|
|
118
110
|
* candidate's title/content marks it project-scoped — such entries are
|
|
@@ -209,7 +201,10 @@ export function apply(ctx, config) {
|
|
|
209
201
|
text: (context) => frozenMemories.textFor(context.agent, (agent) => memoryIndexSectionText(engine, agent, { maxChars: memoryIndexMaxChars, guide: memoryIndexGuide })),
|
|
210
202
|
});
|
|
211
203
|
}
|
|
212
|
-
const gate = {
|
|
204
|
+
const gate = {
|
|
205
|
+
requireGlobalApproval: config.requireGlobalApproval ?? true,
|
|
206
|
+
...(config.recordLanguage !== undefined ? { recordLanguage: config.recordLanguage } : {}),
|
|
207
|
+
};
|
|
213
208
|
const promotionPolicy = resolvePromotionPolicy({
|
|
214
209
|
blockPatterns: config.promotionBlockPatterns,
|
|
215
210
|
minPromoteChars: config.promotionMinChars,
|
|
@@ -222,6 +217,7 @@ export function apply(ctx, config) {
|
|
|
222
217
|
autoCase: config.autoCase ?? true,
|
|
223
218
|
promotionPolicy,
|
|
224
219
|
autoReview: config.autoReview ?? false,
|
|
220
|
+
...(config.recordLanguage !== undefined ? { recordLanguage: config.recordLanguage } : {}),
|
|
225
221
|
});
|
|
226
222
|
// Plugin-owned file logging: every cordis log message lands in
|
|
227
223
|
// <baseDir>/evolve/plugin.log regardless of how dsh web was launched —
|
|
@@ -236,26 +232,20 @@ export function apply(ctx, config) {
|
|
|
236
232
|
void restoreMounted(ctx, baseDir).catch((cause) => {
|
|
237
233
|
ctx.logger("continual-evolve").warn(`mount restore failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
238
234
|
});
|
|
239
|
-
// The
|
|
240
|
-
// without profile edits, so `autoReview` is only the initial default
|
|
241
|
-
// no `evolve/runtime.json` exists yet — `/evolve pause|resume` own
|
|
242
|
-
// switch afterwards.
|
|
243
|
-
//
|
|
235
|
+
// The memory extraction listener is always registered: an install must
|
|
236
|
+
// work without profile edits, so `autoReview` is only the initial default
|
|
237
|
+
// when no `evolve/runtime.json` exists yet — `/evolve pause|resume` own
|
|
238
|
+
// the switch afterwards. Extraction is moment-driven: compaction,
|
|
239
|
+
// goal-blocked streaks, the session-close drain, and manual
|
|
240
|
+
// `/evolve wrapup` — successful turns never cost an LLM call.
|
|
244
241
|
registerAutoReview(ctx, engine, {
|
|
245
|
-
intervalTurns: config.reviewIntervalTurns ?? 6,
|
|
246
242
|
enabledByDefault: config.autoReview ?? false,
|
|
247
|
-
memoryOnly: true,
|
|
248
243
|
memoryMinUserWords: config.memoryMinUserWords ?? 3,
|
|
249
244
|
sessionCloseDrainMs: config.sessionCloseDrainMs ?? 15000,
|
|
250
245
|
maxInputChars: config.maxReviewInputChars ?? 40000,
|
|
251
246
|
budgetTokens: config.reviewBudgetTokens ?? 4096,
|
|
252
247
|
notifyOnAutoReview: config.notifyOnAutoReview ?? true,
|
|
253
|
-
localFate: config.localFate ?? false,
|
|
254
|
-
fateIntervalTurns: config.fateIntervalTurns ?? config.reviewIntervalTurns ?? 6,
|
|
255
248
|
goalBlockedWrapupTurns: config.goalBlockedWrapupTurns ?? 3,
|
|
256
|
-
promotionPolicy,
|
|
257
|
-
autoCase: config.autoCase ?? true,
|
|
258
|
-
rubricKey,
|
|
259
249
|
...(config.historyRetain?.reviews !== undefined ? { reviewsRetain: config.historyRetain.reviews } : {}),
|
|
260
250
|
...(config.reviewModel ? { reviewModel: config.reviewModel } : {}),
|
|
261
251
|
requireGlobalApproval: config.requireGlobalApproval ?? true,
|
|
@@ -263,7 +253,7 @@ export function apply(ctx, config) {
|
|
|
263
253
|
...(config.plannerPrefixMaxChars !== undefined ? { prefixMaxChars: config.plannerPrefixMaxChars } : {}),
|
|
264
254
|
...(config.recordLanguage !== undefined ? { recordLanguage: config.recordLanguage } : {}),
|
|
265
255
|
});
|
|
266
|
-
ctx.logger("continual-evolve").info("continual-evolve
|
|
256
|
+
ctx.logger("continual-evolve").info("continual-evolve moment-driven memory extraction listener registered (compaction / goal-blocked / session-close drain; manual wrapup via /evolve wrapup)");
|
|
267
257
|
ctx.logger("continual-evolve").info(`continual-evolve mounted (baseDir=${baseDir})`);
|
|
268
258
|
}
|
|
269
259
|
//# sourceMappingURL=index.js.map
|
package/lib/inject.d.ts
CHANGED
|
@@ -16,140 +16,34 @@
|
|
|
16
16
|
* sort), keeping the injected cost bounded no matter how the store grows;
|
|
17
17
|
* - full text stays one `evolve_list` call away: the injected block is a
|
|
18
18
|
* summary index, not a duplicate of the store.
|
|
19
|
+
*
|
|
20
|
+
* Split (2026-10-03 refactor): ranking lives in `injection-rank.ts`, the
|
|
21
|
+
* entry directory in `injection-directory.ts`, shared caps in
|
|
22
|
+
* `injection-caps.ts`; this module owns section rendering and the
|
|
23
|
+
* three-store merge. Moved symbols are re-exported here so the public
|
|
24
|
+
* surface stays on one module.
|
|
19
25
|
*/
|
|
20
26
|
import type { HarnessEntry, HarnessState } from "./types.js";
|
|
21
27
|
import type { EvolutionEngine } from "./service.js";
|
|
22
|
-
import {
|
|
23
|
-
/**
|
|
24
|
-
export { tokenize };
|
|
25
|
-
|
|
26
|
-
export
|
|
27
|
-
|
|
28
|
-
export
|
|
28
|
+
import { type AgentLike } from "./injection-rank.js";
|
|
29
|
+
/** Compat surface: ranking + directory + caps moved out 2026-10-03. */
|
|
30
|
+
export { rankEntries, recencyScore, recentUserText, sessionEventsOf, tokenize } from "./injection-rank.js";
|
|
31
|
+
export type { AgentLike, UserMessageEventLike } from "./injection-rank.js";
|
|
32
|
+
export { RECENCY_HALF_LIFE_MS, MAX_QUERY_MESSAGES, MAX_QUERY_CHARS } from "./injection-rank.js";
|
|
33
|
+
export { directoryLine, formatEntriesDirectory, formatEntriesDirectoryCapped, formatEntriesDirectoryRanked, rankedDirectoryEntries, DEFAULT_DIRECTORY_LINES, } from "./injection-directory.js";
|
|
34
|
+
export { MAX_INJECTED_ENTRIES_PER_KIND, MAX_INJECTED_CONTENT_LENGTH } from "./injection-caps.js";
|
|
29
35
|
/** How many `parentSession` hops a child walks to inherit entries. */
|
|
30
36
|
export declare const MAX_PARENT_CHAIN_DEPTH = 8;
|
|
31
|
-
/** Recency half-life for the injection ranking: an entry this old scores 0. */
|
|
32
|
-
export declare const RECENCY_HALF_LIFE_MS: number;
|
|
33
|
-
/** At most this many recent user messages feed the relevance query. */
|
|
34
|
-
export declare const MAX_QUERY_MESSAGES = 3;
|
|
35
|
-
/** Query text handed to the relevance scorer is capped at this many chars. */
|
|
36
|
-
export declare const MAX_QUERY_CHARS = 400;
|
|
37
|
-
/** A user-message event's durable shape, loosened for duck typing. */
|
|
38
|
-
export interface UserMessageEventLike {
|
|
39
|
-
type?: string;
|
|
40
|
-
data?: {
|
|
41
|
-
content?: unknown;
|
|
42
|
-
source?: {
|
|
43
|
-
kind?: string;
|
|
44
|
-
};
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
/** The minimal agent shape the section provider needs (duck-typed). */
|
|
48
|
-
export interface AgentLike {
|
|
49
|
-
id: string;
|
|
50
|
-
session?: {
|
|
51
|
-
header?: {
|
|
52
|
-
parentSession?: string;
|
|
53
|
-
};
|
|
54
|
-
/**
|
|
55
|
-
* Live append-only session log as the public `session.events` getter.
|
|
56
|
-
* Typed loosely (`unknown[]`) so the real `SessionEvent[]` union from
|
|
57
|
-
* dsh-session is assignable; rows are narrowed to
|
|
58
|
-
* {@link UserMessageEventLike} at read time.
|
|
59
|
-
*/
|
|
60
|
-
events?: readonly unknown[];
|
|
61
|
-
/**
|
|
62
|
-
* Full-log snapshot reader that answers the same rows in the same order
|
|
63
|
-
* where the `events` getter is absent. Called with no arguments for the
|
|
64
|
-
* whole log, which is the read {@link sessionEventsOf} needs.
|
|
65
|
-
*/
|
|
66
|
-
snapshotEvents?: () => readonly unknown[];
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* The session's event log through whichever reader the running harness
|
|
71
|
-
* generation exposes. Returns [] when neither is present, so every caller
|
|
72
|
-
* degrades to its empty-log path rather than throwing.
|
|
73
|
-
*/
|
|
74
|
-
export declare function sessionEventsOf(agent: AgentLike | undefined): readonly unknown[];
|
|
75
37
|
/** The section-provider context shape we consume (subset of AssembleContext). */
|
|
76
38
|
export interface InjectContext {
|
|
77
39
|
agent?: AgentLike;
|
|
78
40
|
}
|
|
79
|
-
/**
|
|
80
|
-
* Normalized recency in [0, 1]: 1 when the entry was just updated, decaying
|
|
81
|
-
* linearly to 0 after {@link RECENCY_HALF_LIFE_MS}. Unparseable timestamps
|
|
82
|
-
* score 0 (never preferred over a timestamped entry).
|
|
83
|
-
*/
|
|
84
|
-
export declare function recencyScore(entry: HarnessEntry, now: number): number;
|
|
85
|
-
/**
|
|
86
|
-
* Rank entries for injection, best first. With no query the ranking is
|
|
87
|
-
* negative-valence first (contradicted entries last), then pure recency
|
|
88
|
-
* (newest first). With a query, entries are scored once against a per-call
|
|
89
|
-
* BM25 index (CJK bigrams; field-weighted title ×2 — see search.ts): any
|
|
90
|
-
* entry with a positive score (≥1 matched token) outranks every hit-less
|
|
91
|
-
* entry (score exactly 0), scores decide the order among relevant entries,
|
|
92
|
-
* the negative-valence counter breaks remaining ties (contradicted entries
|
|
93
|
-
* sink), recency breaks remaining ties, and the stable dictionary order is
|
|
94
|
-
* the final tiebreak, so the result is deterministic. The input is never
|
|
95
|
-
* mutated.
|
|
96
|
-
*/
|
|
97
|
-
export declare function rankEntries(entries: readonly HarnessEntry[], query?: string, now?: number): HarnessEntry[];
|
|
98
|
-
/**
|
|
99
|
-
* Compose the relevance query from the assembling agent's most recent direct
|
|
100
|
-
* user messages (event rows whose `type` is `user/message` and whose source
|
|
101
|
-
* is a human `user`, so injected plugin context and tool results never leak
|
|
102
|
-
* into the query). Returns "" when nothing qualifies — the ranking then
|
|
103
|
-
* falls back to pure recency.
|
|
104
|
-
*/
|
|
105
|
-
export declare function recentUserText(agent: AgentLike | undefined, opts?: {
|
|
106
|
-
maxMessages?: number;
|
|
107
|
-
maxChars?: number;
|
|
108
|
-
}): string;
|
|
109
41
|
/** True when the state carries at least one entry of any kind. */
|
|
110
42
|
export declare function hasAnyEntries(state: HarnessState): boolean;
|
|
111
43
|
/** The additive prompt-notes block (empty when there are no visible prompt entries). */
|
|
112
44
|
export declare function formatPromptEntriesSection(entries: readonly HarnessEntry[], query?: string): string;
|
|
113
45
|
/** The reusable delegation-specs block (empty when there are no visible subagent entries). */
|
|
114
46
|
export declare function formatSubagentSpecsSection(entries: readonly HarnessEntry[], query?: string): string;
|
|
115
|
-
/**
|
|
116
|
-
* Gap B3: a lightweight directory of ALL non-archived entries across all
|
|
117
|
-
* kinds — one line per entry, no content. Memory lines carry their recall
|
|
118
|
-
* type (`- [memory:feedback:id] title`) so the model can judge relevance
|
|
119
|
-
* from the index alone. This gives the model a zero-cost overview of what
|
|
120
|
-
* exists so it can ask for full text via `evolve_list` or `/evolve list`.
|
|
121
|
-
* The directory is appended after the curated top-N injection sections and
|
|
122
|
-
* adds minimal tokens.
|
|
123
|
-
*
|
|
124
|
-
* 2026-08-22 throttle: the directory is CAPPED at {@link DEFAULT_DIRECTORY_LINES}
|
|
125
|
-
* lines with the remainder folded into a single counter line — an uncapped
|
|
126
|
-
* directory across a polluted global store was measured at ~2K chars of
|
|
127
|
-
* every build in every project.
|
|
128
|
-
*
|
|
129
|
-
* 2026-09-23 relevance order: the directory is sorted by
|
|
130
|
-
* {@link rankEntries} against the assembly's relevance query, so the lines
|
|
131
|
-
* folded below the cap are the least relevant — not the dictionary tail.
|
|
132
|
-
*/
|
|
133
|
-
export declare const DEFAULT_DIRECTORY_LINES = 15;
|
|
134
|
-
/** One directory index line: memory lines carry their recall-type hook. */
|
|
135
|
-
export declare function directoryLine(entry: HarnessEntry): string;
|
|
136
|
-
export declare function formatEntriesDirectory(...kindEntries: readonly HarnessEntry[][]): string;
|
|
137
|
-
/** {@link formatEntriesDirectory} with an explicit cap (configurable). */
|
|
138
|
-
export declare function formatEntriesDirectoryCapped(maxLines: number, ...kindEntries: readonly HarnessEntry[][]): string;
|
|
139
|
-
/**
|
|
140
|
-
* {@link formatEntriesDirectoryCapped} with a relevance query: entries are
|
|
141
|
-
* ordered by {@link rankEntries} (relevance first, then recency) so the cap
|
|
142
|
-
* folds the least relevant entries — never the dictionary tail. An empty
|
|
143
|
-
* query degrades to the valence/recency/stable order, still deterministic.
|
|
144
|
-
*/
|
|
145
|
-
export declare function formatEntriesDirectoryRanked(maxLines: number, query: string | undefined, ...kindEntries: readonly HarnessEntry[][]): string;
|
|
146
|
-
/**
|
|
147
|
-
* The directory entries actually shown under the cap, in display order —
|
|
148
|
-
* the single source for both the rendered text and usage accounting (so a
|
|
149
|
-
* memory's injection IS its directory line even with the `:type` hook).
|
|
150
|
-
* Returns [] when the directory is suppressed or empty.
|
|
151
|
-
*/
|
|
152
|
-
export declare function rankedDirectoryEntries(maxLines: number, query: string | undefined, ...kindEntries: readonly HarnessEntry[][]): HarnessEntry[];
|
|
153
47
|
/**
|
|
154
48
|
* Walk the parent-session chain from `agent` upward and return the nearest
|
|
155
49
|
* session whose local store is non-empty, if any. Children inherit their
|
package/lib/inject.js
CHANGED
|
@@ -1,176 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isArchived } from "./types.js";
|
|
2
2
|
import { mergeHarnessStates } from "./state.js";
|
|
3
3
|
import { projectKeyOf } from "./project.js";
|
|
4
4
|
import { entryLine } from "./render.js";
|
|
5
5
|
import { recordInjection } from "./usage.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
export
|
|
6
|
+
import { recentUserText, rankEntries, } from "./injection-rank.js";
|
|
7
|
+
import { formatEntriesDirectoryRanked, rankedDirectoryEntries } from "./injection-directory.js";
|
|
8
|
+
import { MAX_INJECTED_ENTRIES_PER_KIND, MAX_INJECTED_CONTENT_LENGTH } from "./injection-caps.js";
|
|
9
|
+
import { DEFAULT_DIRECTORY_LINES } from "./injection-directory.js";
|
|
10
|
+
/** Compat surface: ranking + directory + caps moved out 2026-10-03. */
|
|
11
|
+
export { rankEntries, recencyScore, recentUserText, sessionEventsOf, tokenize } from "./injection-rank.js";
|
|
12
|
+
export { RECENCY_HALF_LIFE_MS, MAX_QUERY_MESSAGES, MAX_QUERY_CHARS } from "./injection-rank.js";
|
|
13
|
+
export { directoryLine, formatEntriesDirectory, formatEntriesDirectoryCapped, formatEntriesDirectoryRanked, rankedDirectoryEntries, DEFAULT_DIRECTORY_LINES, } from "./injection-directory.js";
|
|
14
|
+
export { MAX_INJECTED_ENTRIES_PER_KIND, MAX_INJECTED_CONTENT_LENGTH } from "./injection-caps.js";
|
|
13
15
|
/** How many `parentSession` hops a child walks to inherit entries. */
|
|
14
16
|
export const MAX_PARENT_CHAIN_DEPTH = 8;
|
|
15
|
-
/** Recency half-life for the injection ranking: an entry this old scores 0. */
|
|
16
|
-
export const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
17
|
-
/** At most this many recent user messages feed the relevance query. */
|
|
18
|
-
export const MAX_QUERY_MESSAGES = 3;
|
|
19
|
-
/** Query text handed to the relevance scorer is capped at this many chars. */
|
|
20
|
-
export const MAX_QUERY_CHARS = 400;
|
|
21
|
-
/**
|
|
22
|
-
* The session's event log through whichever reader the running harness
|
|
23
|
-
* generation exposes. Returns [] when neither is present, so every caller
|
|
24
|
-
* degrades to its empty-log path rather than throwing.
|
|
25
|
-
*/
|
|
26
|
-
export function sessionEventsOf(agent) {
|
|
27
|
-
const session = agent?.session;
|
|
28
|
-
if (session === undefined) {
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
if (Array.isArray(session.events)) {
|
|
32
|
-
return session.events;
|
|
33
|
-
}
|
|
34
|
-
const snapshot = session.snapshotEvents?.();
|
|
35
|
-
return Array.isArray(snapshot) ? snapshot : [];
|
|
36
|
-
}
|
|
37
|
-
/** Stable dictionary-order tiebreak used when two entries score equally. */
|
|
38
|
-
function stableCompare(a, b) {
|
|
39
|
-
return [a.path, a.title, a.id].join("\0").localeCompare([b.path, b.title, b.id].join("\0"));
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Normalized recency in [0, 1]: 1 when the entry was just updated, decaying
|
|
43
|
-
* linearly to 0 after {@link RECENCY_HALF_LIFE_MS}. Unparseable timestamps
|
|
44
|
-
* score 0 (never preferred over a timestamped entry).
|
|
45
|
-
*/
|
|
46
|
-
export function recencyScore(entry, now) {
|
|
47
|
-
const updatedAt = Date.parse(entry.updated_at);
|
|
48
|
-
if (Number.isNaN(updatedAt)) {
|
|
49
|
-
return 0;
|
|
50
|
-
}
|
|
51
|
-
const age = now - updatedAt;
|
|
52
|
-
if (age <= 0) {
|
|
53
|
-
return 1;
|
|
54
|
-
}
|
|
55
|
-
return Math.max(0, 1 - age / RECENCY_HALF_LIFE_MS);
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Negative-valence counter (P1 效价反馈): entries contradicted by later
|
|
59
|
-
* assessments sink below clean ones at equal relevance/recency — the
|
|
60
|
-
* behavioral analog of a confidence penalty (pi-continuous-learning's
|
|
61
|
-
* contradicted −0.15), without inventing a new score axis.
|
|
62
|
-
*/
|
|
63
|
-
function negativeValence(entry) {
|
|
64
|
-
const value = entry.metadata[VALENCE_NEGATIVE_KEY];
|
|
65
|
-
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Rank entries for injection, best first. With no query the ranking is
|
|
69
|
-
* negative-valence first (contradicted entries last), then pure recency
|
|
70
|
-
* (newest first). With a query, entries are scored once against a per-call
|
|
71
|
-
* BM25 index (CJK bigrams; field-weighted title ×2 — see search.ts): any
|
|
72
|
-
* entry with a positive score (≥1 matched token) outranks every hit-less
|
|
73
|
-
* entry (score exactly 0), scores decide the order among relevant entries,
|
|
74
|
-
* the negative-valence counter breaks remaining ties (contradicted entries
|
|
75
|
-
* sink), recency breaks remaining ties, and the stable dictionary order is
|
|
76
|
-
* the final tiebreak, so the result is deterministic. The input is never
|
|
77
|
-
* mutated.
|
|
78
|
-
*/
|
|
79
|
-
export function rankEntries(entries, query, now = Date.now()) {
|
|
80
|
-
const q = (query ?? "").trim();
|
|
81
|
-
if (q.length === 0) {
|
|
82
|
-
return [...entries].sort((a, b) => {
|
|
83
|
-
const valenceDelta = negativeValence(a) - negativeValence(b);
|
|
84
|
-
if (valenceDelta !== 0) {
|
|
85
|
-
return valenceDelta;
|
|
86
|
-
}
|
|
87
|
-
const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
|
|
88
|
-
if (recencyDelta !== 0) {
|
|
89
|
-
return recencyDelta;
|
|
90
|
-
}
|
|
91
|
-
return stableCompare(a, b);
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
// Precompute scores once: the old comparator re-tokenized both sides on
|
|
95
|
-
// every comparison (O(n log n) tokenizations); one index + one score per
|
|
96
|
-
// entry turns the pass into table lookups.
|
|
97
|
-
const index = buildRelevanceIndex(entries);
|
|
98
|
-
const scores = new Map(entries.map((entry) => [entry, relevanceScore(index, entry, q)]));
|
|
99
|
-
return [...entries].sort((a, b) => {
|
|
100
|
-
const relevanceDelta = (scores.get(b) ?? 0) - (scores.get(a) ?? 0);
|
|
101
|
-
if (relevanceDelta !== 0) {
|
|
102
|
-
return relevanceDelta;
|
|
103
|
-
}
|
|
104
|
-
const valenceDelta = negativeValence(a) - negativeValence(b);
|
|
105
|
-
if (valenceDelta !== 0) {
|
|
106
|
-
return valenceDelta;
|
|
107
|
-
}
|
|
108
|
-
const recencyDelta = recencyScore(b, now) - recencyScore(a, now);
|
|
109
|
-
if (recencyDelta !== 0) {
|
|
110
|
-
return recencyDelta;
|
|
111
|
-
}
|
|
112
|
-
return stableCompare(a, b);
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
function sortedEntries(entries, query) {
|
|
116
|
-
return rankEntries(entries, query);
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Extract the text of a message's content blocks without depending on the
|
|
120
|
-
* dsh-llm ContentBlock type: string blocks pass through, object blocks
|
|
121
|
-
* contribute their `text` field when present.
|
|
122
|
-
*/
|
|
123
|
-
function extractBlockText(content) {
|
|
124
|
-
if (typeof content === "string") {
|
|
125
|
-
return content;
|
|
126
|
-
}
|
|
127
|
-
if (!Array.isArray(content)) {
|
|
128
|
-
return "";
|
|
129
|
-
}
|
|
130
|
-
return content
|
|
131
|
-
.map((block) => {
|
|
132
|
-
if (typeof block === "string") {
|
|
133
|
-
return block;
|
|
134
|
-
}
|
|
135
|
-
if (block !== null && typeof block === "object" && "text" in block && typeof block.text === "string") {
|
|
136
|
-
return block.text;
|
|
137
|
-
}
|
|
138
|
-
return "";
|
|
139
|
-
})
|
|
140
|
-
.filter((text) => text.length > 0)
|
|
141
|
-
.join(" ");
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Compose the relevance query from the assembling agent's most recent direct
|
|
145
|
-
* user messages (event rows whose `type` is `user/message` and whose source
|
|
146
|
-
* is a human `user`, so injected plugin context and tool results never leak
|
|
147
|
-
* into the query). Returns "" when nothing qualifies — the ranking then
|
|
148
|
-
* falls back to pure recency.
|
|
149
|
-
*/
|
|
150
|
-
export function recentUserText(agent, opts) {
|
|
151
|
-
const events = sessionEventsOf(agent);
|
|
152
|
-
if (events.length === 0) {
|
|
153
|
-
return "";
|
|
154
|
-
}
|
|
155
|
-
const maxMessages = opts?.maxMessages ?? MAX_QUERY_MESSAGES;
|
|
156
|
-
const maxChars = opts?.maxChars ?? MAX_QUERY_CHARS;
|
|
157
|
-
const parts = [];
|
|
158
|
-
for (let i = events.length - 1; i >= 0 && parts.length < maxMessages; i -= 1) {
|
|
159
|
-
const event = events[i];
|
|
160
|
-
if (event?.type !== "user/message") {
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
const source = event.data?.source;
|
|
164
|
-
if (source && source.kind !== "user") {
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
const text = extractBlockText(event.data?.content).trim();
|
|
168
|
-
if (text.length > 0) {
|
|
169
|
-
parts.unshift(text);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return parts.join(" ").slice(0, maxChars);
|
|
173
|
-
}
|
|
174
17
|
/** True when the state carries at least one entry of any kind. */
|
|
175
18
|
export function hasAnyEntries(state) {
|
|
176
19
|
return Object.values(state.entries).some((byKind) => Object.keys(byKind).length > 0);
|
|
@@ -185,7 +28,7 @@ export function formatPromptEntriesSection(entries, query) {
|
|
|
185
28
|
"# Continual Harness — Prompt Notes",
|
|
186
29
|
"Supplemental prompt notes (the base system prompt is immutable). Use evolve_list for the full text of any note.",
|
|
187
30
|
];
|
|
188
|
-
for (const entry of
|
|
31
|
+
for (const entry of rankEntries(visible, query).slice(0, MAX_INJECTED_ENTRIES_PER_KIND)) {
|
|
189
32
|
lines.push(entryLine(entry, MAX_INJECTED_CONTENT_LENGTH));
|
|
190
33
|
}
|
|
191
34
|
const overflow = visible.length - Math.min(visible.length, MAX_INJECTED_ENTRIES_PER_KIND);
|
|
@@ -204,7 +47,7 @@ export function formatSubagentSpecsSection(entries, query) {
|
|
|
204
47
|
"# Continual Harness — Delegation Specs",
|
|
205
48
|
"Reusable subagent specs: when you delegate work that matches a spec, assemble the child prompt from its content. Children inherit these specs through their parent chain.",
|
|
206
49
|
];
|
|
207
|
-
for (const entry of
|
|
50
|
+
for (const entry of rankEntries(visible, query).slice(0, MAX_INJECTED_ENTRIES_PER_KIND)) {
|
|
208
51
|
lines.push(entryLine(entry, MAX_INJECTED_CONTENT_LENGTH));
|
|
209
52
|
}
|
|
210
53
|
const overflow = visible.length - Math.min(visible.length, MAX_INJECTED_ENTRIES_PER_KIND);
|
|
@@ -213,97 +56,6 @@ export function formatSubagentSpecsSection(entries, query) {
|
|
|
213
56
|
}
|
|
214
57
|
return lines.join("\n");
|
|
215
58
|
}
|
|
216
|
-
/**
|
|
217
|
-
* Gap B3: a lightweight directory of ALL non-archived entries across all
|
|
218
|
-
* kinds — one line per entry, no content. Memory lines carry their recall
|
|
219
|
-
* type (`- [memory:feedback:id] title`) so the model can judge relevance
|
|
220
|
-
* from the index alone. This gives the model a zero-cost overview of what
|
|
221
|
-
* exists so it can ask for full text via `evolve_list` or `/evolve list`.
|
|
222
|
-
* The directory is appended after the curated top-N injection sections and
|
|
223
|
-
* adds minimal tokens.
|
|
224
|
-
*
|
|
225
|
-
* 2026-08-22 throttle: the directory is CAPPED at {@link DEFAULT_DIRECTORY_LINES}
|
|
226
|
-
* lines with the remainder folded into a single counter line — an uncapped
|
|
227
|
-
* directory across a polluted global store was measured at ~2K chars of
|
|
228
|
-
* every build in every project.
|
|
229
|
-
*
|
|
230
|
-
* 2026-09-23 relevance order: the directory is sorted by
|
|
231
|
-
* {@link rankEntries} against the assembly's relevance query, so the lines
|
|
232
|
-
* folded below the cap are the least relevant — not the dictionary tail.
|
|
233
|
-
*/
|
|
234
|
-
export const DEFAULT_DIRECTORY_LINES = 15;
|
|
235
|
-
/** One directory index line: memory lines carry their recall-type hook. */
|
|
236
|
-
export function directoryLine(entry) {
|
|
237
|
-
if (entry.kind === "memory") {
|
|
238
|
-
const type = entry.metadata[MEMORY_TYPE_KEY];
|
|
239
|
-
const hook = isMemoryType(type) ? `:${type}` : "";
|
|
240
|
-
return `- [memory${hook}:${entry.id}] ${entry.title}`;
|
|
241
|
-
}
|
|
242
|
-
return `- [${entry.kind}:${entry.id}] ${entry.title}`;
|
|
243
|
-
}
|
|
244
|
-
/** Positional contract for the directory variadics: the first two arrays
|
|
245
|
-
* must be the content-section kinds (prompt, subagent) — the redundancy
|
|
246
|
-
* check counts exactly those as already visible. Callers pass
|
|
247
|
-
* (prompt, subagent, memory, skill); any other order miscounts. */
|
|
248
|
-
const CONTENT_SECTION_KINDS = 2;
|
|
249
|
-
export function formatEntriesDirectory(...kindEntries) {
|
|
250
|
-
return formatEntriesDirectoryCapped(DEFAULT_DIRECTORY_LINES, ...kindEntries);
|
|
251
|
-
}
|
|
252
|
-
/** {@link formatEntriesDirectory} with an explicit cap (configurable). */
|
|
253
|
-
export function formatEntriesDirectoryCapped(maxLines, ...kindEntries) {
|
|
254
|
-
return formatEntriesDirectoryRanked(maxLines, undefined, ...kindEntries);
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* {@link formatEntriesDirectoryCapped} with a relevance query: entries are
|
|
258
|
-
* ordered by {@link rankEntries} (relevance first, then recency) so the cap
|
|
259
|
-
* folds the least relevant entries — never the dictionary tail. An empty
|
|
260
|
-
* query degrades to the valence/recency/stable order, still deterministic.
|
|
261
|
-
*/
|
|
262
|
-
export function formatEntriesDirectoryRanked(maxLines, query, ...kindEntries) {
|
|
263
|
-
const total = kindEntries.flat().filter((e) => !isArchived(e)).length;
|
|
264
|
-
if (total === 0) {
|
|
265
|
-
return "";
|
|
266
|
-
}
|
|
267
|
-
// Suppressed (every entry already content-visible) renders as "" — the
|
|
268
|
-
// prompt renderer then drops the section entirely.
|
|
269
|
-
const shown = rankedDirectoryEntries(maxLines, query, ...kindEntries);
|
|
270
|
-
if (shown.length === 0) {
|
|
271
|
-
return "";
|
|
272
|
-
}
|
|
273
|
-
const lines = ["# Continual Harness — Entry Directory", "All entries (use evolve_list for full text of any entry):"];
|
|
274
|
-
for (const entry of shown) {
|
|
275
|
-
lines.push(directoryLine(entry));
|
|
276
|
-
}
|
|
277
|
-
const hidden = total - shown.length;
|
|
278
|
-
if (hidden > 0) {
|
|
279
|
-
lines.push(`- …and ${hidden} more entries (evolve_list for the full index)`);
|
|
280
|
-
}
|
|
281
|
-
return lines.join("\n");
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* The directory entries actually shown under the cap, in display order —
|
|
285
|
-
* the single source for both the rendered text and usage accounting (so a
|
|
286
|
-
* memory's injection IS its directory line even with the `:type` hook).
|
|
287
|
-
* Returns [] when the directory is suppressed or empty.
|
|
288
|
-
*/
|
|
289
|
-
export function rankedDirectoryEntries(maxLines, query, ...kindEntries) {
|
|
290
|
-
const allEntries = kindEntries.flat().filter((e) => !isArchived(e));
|
|
291
|
-
if (allEntries.length === 0) {
|
|
292
|
-
return [];
|
|
293
|
-
}
|
|
294
|
-
// Skip the directory only when EVERY entry is already content-visible.
|
|
295
|
-
// Only the first two arrays (prompt, subagent) have curated sections —
|
|
296
|
-
// memories and skills have NO content injection, so they are invisible
|
|
297
|
-
// unless the directory lists them (pre-2026-08-22 the redundancy check
|
|
298
|
-
// wrongly counted them as "already shown", hiding small stores entirely).
|
|
299
|
-
const contentVisible = kindEntries
|
|
300
|
-
.slice(0, CONTENT_SECTION_KINDS)
|
|
301
|
-
.reduce((sum, entries) => sum + Math.min(entries.filter((e) => !isArchived(e)).length, MAX_INJECTED_ENTRIES_PER_KIND), 0);
|
|
302
|
-
if (allEntries.length <= contentVisible) {
|
|
303
|
-
return [];
|
|
304
|
-
}
|
|
305
|
-
return rankEntries(allEntries, query).slice(0, Math.max(maxLines, 1));
|
|
306
|
-
}
|
|
307
59
|
/**
|
|
308
60
|
* Walk the parent-session chain from `agent` upward and return the nearest
|
|
309
61
|
* session whose local store is non-empty, if any. Children inherit their
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entry directory: a lightweight one-line-per-entry index of ALL
|
|
3
|
+
* non-archived entries across kinds, appended after the curated top-N
|
|
4
|
+
* injection sections so the model has a zero-cost overview of what exists
|
|
5
|
+
* and can ask for full text via `evolve_list`.
|
|
6
|
+
*/
|
|
7
|
+
import type { HarnessEntry } from "./types.js";
|
|
8
|
+
/** Directory lines injected per build before folding into a counter. */
|
|
9
|
+
export declare const DEFAULT_DIRECTORY_LINES = 15;
|
|
10
|
+
/** One directory index line: memory lines carry their recall-type hook. */
|
|
11
|
+
export declare function directoryLine(entry: HarnessEntry): string;
|
|
12
|
+
export declare function formatEntriesDirectory(...kindEntries: readonly HarnessEntry[][]): string;
|
|
13
|
+
/** {@link formatEntriesDirectory} with an explicit cap (configurable). */
|
|
14
|
+
export declare function formatEntriesDirectoryCapped(maxLines: number, ...kindEntries: readonly HarnessEntry[][]): string;
|
|
15
|
+
/**
|
|
16
|
+
* {@link formatEntriesDirectoryCapped} with a relevance query: entries are
|
|
17
|
+
* ordered by {@link rankEntries} (relevance first, then recency) so the cap
|
|
18
|
+
* folds the least relevant entries — never the dictionary tail. An empty
|
|
19
|
+
* query degrades to the valence/recency/stable order, still deterministic.
|
|
20
|
+
*/
|
|
21
|
+
export declare function formatEntriesDirectoryRanked(maxLines: number, query: string | undefined, ...kindEntries: readonly HarnessEntry[][]): string;
|
|
22
|
+
/**
|
|
23
|
+
* The directory entries actually shown under the cap, in display order —
|
|
24
|
+
* the single source for both the rendered text and usage accounting (so a
|
|
25
|
+
* memory's injection IS its directory line even with the `:type` hook).
|
|
26
|
+
* Returns [] when the directory is suppressed or empty.
|
|
27
|
+
*/
|
|
28
|
+
export declare function rankedDirectoryEntries(maxLines: number, query: string | undefined, ...kindEntries: readonly HarnessEntry[][]): HarnessEntry[];
|
|
29
|
+
//# sourceMappingURL=injection-directory.d.ts.map
|