@vellumai/assistant 0.8.11 → 0.8.12-staging.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/ARCHITECTURE.md +15 -17
- package/README.md +0 -6
- package/bun.lock +6 -122
- package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
- package/node_modules/@vellumai/gateway-client/package.json +3 -1
- package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
- package/openapi.yaml +633 -4
- package/package.json +1 -3
- package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
- package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
- package/src/__tests__/anthropic-provider.test.ts +129 -0
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
- package/src/__tests__/btw-routes.test.ts +7 -34
- package/src/__tests__/checker.test.ts +6 -12
- package/src/__tests__/config-loader-backfill.test.ts +4 -2
- package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
- package/src/__tests__/config-watcher.test.ts +2 -2
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +2 -6
- package/src/__tests__/conversation-history-web-search.test.ts +8 -0
- package/src/__tests__/conversation-title-service.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/disk-pressure-tools.test.ts +1 -1
- package/src/__tests__/exploration-drift-hook.test.ts +692 -0
- package/src/__tests__/filing-service.test.ts +8 -3
- package/src/__tests__/guardian-action-store.test.ts +0 -167
- package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
- package/src/__tests__/heartbeat-service.test.ts +5 -2
- package/src/__tests__/identity-intro-cache.test.ts +12 -5
- package/src/__tests__/identity-routes.test.ts +16 -57
- package/src/__tests__/injector-chain.test.ts +8 -3
- package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
- package/src/__tests__/llm-catalog-parity.test.ts +16 -0
- package/src/__tests__/llm-usage-store.test.ts +11 -0
- package/src/__tests__/log-export-workspace.test.ts +468 -3
- package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/oauth-cli.test.ts +19 -8
- package/src/__tests__/openai-provider.test.ts +34 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
- package/src/__tests__/recurrence-engine.test.ts +45 -0
- package/src/__tests__/schedule-routes.test.ts +34 -0
- package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
- package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
- package/src/__tests__/secret-fixtures.ts +20 -0
- package/src/__tests__/skill-tool-factory.test.ts +49 -0
- package/src/__tests__/subagent-role-registry.test.ts +24 -1
- package/src/__tests__/subagent-tools.test.ts +1 -0
- package/src/__tests__/system-prompt.test.ts +109 -11
- package/src/__tests__/tool-approval-handler.test.ts +85 -0
- package/src/__tests__/tool-audit-listener.test.ts +86 -0
- package/src/__tests__/tool-error-hook.test.ts +1 -0
- package/src/__tests__/tool-result-spool.test.ts +337 -0
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
- package/src/__tests__/validate-input.test.ts +95 -1
- package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
- package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
- package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
- package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
- package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
- package/src/acp/__tests__/agent-process.test.ts +315 -2
- package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
- package/src/acp/agent-process.ts +163 -34
- package/src/acp/prepare-agent-env.ts +55 -15
- package/src/agent/loop.ts +81 -24
- package/src/api/events/usage-progress.ts +28 -0
- package/src/api/index.ts +6 -0
- package/src/background-wake/wake-intent-hooks.test.ts +2 -0
- package/src/bundler/app-bundler.ts +25 -42
- package/src/bundler/app-compiler.ts +8 -0
- package/src/calls/call-controller.ts +1 -1
- package/src/cli/commands/plugins.ts +248 -15
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
- package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
- package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
- package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
- package/src/cli/lib/inspect-plugin.ts +252 -0
- package/src/cli/lib/install-from-github.ts +214 -21
- package/src/cli/lib/list-installed-plugins.ts +17 -6
- package/src/cli/lib/plugin-artifact.ts +103 -0
- package/src/cli/lib/plugin-details.ts +18 -1
- package/src/cli/lib/plugin-fingerprint.ts +197 -0
- package/src/cli/lib/upgrade-plugin.ts +225 -0
- package/src/config/bundled-skills/subagent/SKILL.md +2 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
- package/src/config/call-site-defaults.ts +13 -2
- package/src/config/feature-flag-registry.json +8 -16
- package/src/config/loader.ts +52 -59
- package/src/config/schema.ts +0 -2
- package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
- package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
- package/src/config/schemas/llm.ts +10 -0
- package/src/config/schemas/memory-v2.ts +13 -0
- package/src/config/schemas/memory-v3.ts +92 -0
- package/src/config/seed-inference-profiles.ts +4 -8
- package/src/context/post-turn-tool-result-truncation.ts +32 -18
- package/src/context/tool-result-spool.ts +104 -0
- package/src/credential-execution/feature-gates.ts +0 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
- package/src/daemon/conversation-error.ts +6 -15
- package/src/daemon/conversation.ts +9 -0
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +1 -20
- package/src/daemon/message-types/conversations.ts +2 -15
- package/src/daemon/trust-context.ts +1 -1
- package/src/events/tool-audit-listener.ts +40 -9
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
- package/src/home/__tests__/home-content-refresh.test.ts +114 -0
- package/src/home/__tests__/suggested-prompts.test.ts +86 -5
- package/src/home/home-content-refresh.ts +43 -31
- package/src/home/home-greeting-cache.ts +8 -1
- package/src/home/home-greeting.ts +13 -9
- package/src/home/suggested-prompts.ts +77 -24
- package/src/ipc/routes/trust-rules.test.ts +66 -72
- package/src/media/image-credentials.ts +2 -2
- package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
- package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
- package/src/memory/conversation-attention-store.ts +1 -0
- package/src/memory/conversation-bootstrap.ts +18 -9
- package/src/memory/conversation-crud.ts +12 -2
- package/src/memory/conversation-title-service.ts +53 -9
- package/src/memory/delivery-channels.ts +0 -69
- package/src/memory/graph/extraction-job.ts +0 -15
- package/src/memory/guardian-action-store.ts +1 -376
- package/src/memory/llm-usage-store.ts +5 -1
- package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
- package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
- package/src/memory/v2/__tests__/injection.test.ts +70 -0
- package/src/memory/v2/__tests__/static-context.test.ts +12 -0
- package/src/memory/v2/consolidation-job.ts +93 -9
- package/src/memory/v2/injection.ts +53 -0
- package/src/memory/v2/prompts/consolidation.ts +1 -0
- package/src/memory/v2/static-context.ts +13 -1
- package/src/memory/v2/sweep-job.ts +1 -1
- package/src/memory/v2/types.ts +5 -0
- package/src/plugin-api/types.ts +7 -0
- package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
- package/src/plugins/defaults/exploration-drift/package.json +15 -0
- package/src/plugins/defaults/index.ts +25 -0
- package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
- package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
- package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
- package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
- package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
- package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
- package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
- package/src/prompts/cache-boundary.ts +17 -0
- package/src/prompts/sections.ts +50 -17
- package/src/prompts/system-prompt.ts +12 -4
- package/src/prompts/templates/system-sections.ts +22 -0
- package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
- package/src/providers/anthropic/client.ts +74 -28
- package/src/providers/gemini/client.ts +5 -1
- package/src/providers/minimax/client.ts +9 -0
- package/src/providers/model-catalog.ts +28 -0
- package/src/providers/model-intents.ts +3 -3
- package/src/providers/openai/chat-completions-provider.ts +4 -2
- package/src/providers/openai/responses-provider.ts +7 -2
- package/src/providers/retry.ts +8 -0
- package/src/providers/types.ts +11 -0
- package/src/providers/unparseable-tool-args.ts +56 -0
- package/src/runtime/AGENTS.md +6 -0
- package/src/runtime/__tests__/agent-wake.test.ts +2 -2
- package/src/runtime/agent-wake.ts +5 -5
- package/src/runtime/background-job-runner.ts +2 -2
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
- package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
- package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
- package/src/runtime/migrations/vbundle-importer.ts +9 -4
- package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
- package/src/runtime/pre-first-message-gate.ts +1 -1
- package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
- package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
- package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
- package/src/runtime/routes/btw-routes.ts +0 -14
- package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
- package/src/runtime/routes/conversation-list-routes.ts +77 -5
- package/src/runtime/routes/conversation-management-routes.ts +54 -0
- package/src/runtime/routes/conversation-query-routes.ts +79 -4
- package/src/runtime/routes/gateway-log-routes.ts +14 -64
- package/src/runtime/routes/home-feed-routes.ts +10 -0
- package/src/runtime/routes/identity-intro-cache.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +76 -20
- package/src/runtime/routes/inbound-message-handler.ts +0 -36
- package/src/runtime/routes/log-export-routes.ts +143 -96
- package/src/runtime/routes/plugins-routes.ts +380 -0
- package/src/runtime/routes/redact-staged-export.ts +259 -0
- package/src/runtime/routes/schedule-routes.ts +19 -2
- package/src/runtime/routes/trust-rules-routes.ts +14 -67
- package/src/schedule/recurrence-engine.ts +34 -0
- package/src/schedule/scheduler.ts +1 -0
- package/src/security/redact-json.ts +61 -0
- package/src/skills/validate-input.ts +41 -1
- package/src/subagent/types.ts +26 -1
- package/src/telemetry/types.ts +15 -1
- package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
- package/src/telemetry/usage-telemetry-reporter.ts +1 -0
- package/src/tools/apps/executors.ts +1 -1
- package/src/tools/skills/skill-tool-factory.ts +19 -8
- package/src/tools/tool-approval-handler.ts +31 -0
- package/src/usage/types.ts +8 -1
- package/src/util/platform.ts +16 -0
- package/src/watcher/engine.ts +1 -0
- package/src/workspace/adaptive-thinking-repair.ts +113 -0
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
- package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
- package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
- package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
- package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
- package/src/workspace/migrations/registry.ts +8 -0
- package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
- package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
- package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
- package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
- package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
- package/src/__tests__/update-bulletin-job.test.ts +0 -292
- package/src/config/schemas/updates.ts +0 -14
- package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
- package/src/memory/conversation-starters-cadence.ts +0 -78
- package/src/prompts/update-bulletin-job.ts +0 -180
- package/src/runtime/guardian-action-followup-executor.ts +0 -306
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { computeFreshSet } from "../fresh-set.js";
|
|
4
|
+
|
|
5
|
+
const entry = (slug: string, modifiedAt: number) => ({ slug, modifiedAt });
|
|
6
|
+
|
|
7
|
+
describe("computeFreshSet", () => {
|
|
8
|
+
test("ranks by modification time, newest first", () => {
|
|
9
|
+
const slugs = computeFreshSet(
|
|
10
|
+
[entry("old", 1000), entry("newest", 3000), entry("mid", 2000)],
|
|
11
|
+
{ k: 3, excludeSlugs: new Set() },
|
|
12
|
+
);
|
|
13
|
+
expect(slugs).toEqual(["newest", "mid", "old"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("cuts to k after exclusions", () => {
|
|
17
|
+
const slugs = computeFreshSet(
|
|
18
|
+
[entry("a", 4000), entry("b", 3000), entry("c", 2000), entry("d", 1000)],
|
|
19
|
+
{ k: 2, excludeSlugs: new Set(["a"]) },
|
|
20
|
+
);
|
|
21
|
+
// "a" is excluded BEFORE the cut, so the two slots go to the next-newest.
|
|
22
|
+
expect(slugs).toEqual(["b", "c"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("skips synthetic entries (modifiedAt 0)", () => {
|
|
26
|
+
const slugs = computeFreshSet(
|
|
27
|
+
[entry("skills/oura", 0), entry("real-page", 500)],
|
|
28
|
+
{ k: 5, excludeSlugs: new Set() },
|
|
29
|
+
);
|
|
30
|
+
expect(slugs).toEqual(["real-page"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("k = 0 disables the lane", () => {
|
|
34
|
+
expect(
|
|
35
|
+
computeFreshSet([entry("a", 1000)], { k: 0, excludeSlugs: new Set() }),
|
|
36
|
+
).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("breaks modification-time ties by slug, ascending", () => {
|
|
40
|
+
const slugs = computeFreshSet(
|
|
41
|
+
[entry("zebra", 1000), entry("apple", 1000), entry("mango", 1000)],
|
|
42
|
+
{ k: 3, excludeSlugs: new Set() },
|
|
43
|
+
);
|
|
44
|
+
expect(slugs).toEqual(["apple", "mango", "zebra"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("does not mutate the input array", () => {
|
|
48
|
+
const entries = [entry("b", 1000), entry("a", 2000)];
|
|
49
|
+
computeFreshSet(entries, { k: 2, excludeSlugs: new Set() });
|
|
50
|
+
expect(entries.map((e) => e.slug)).toEqual(["b", "a"]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -189,7 +189,7 @@ function candidateSlugs(messages: Message[]): Slug[] {
|
|
|
189
189
|
);
|
|
190
190
|
if (finder) {
|
|
191
191
|
for (const line of finder[1].split("\n")) {
|
|
192
|
-
const m = /^\[(\d+)\] (\S+)(?: — |$)/.exec(line);
|
|
192
|
+
const m = /^\[(\d+)\] (?:\([^)]*\) )?(\S+)(?: — |$)/.exec(line);
|
|
193
193
|
if (m) entries.push({ id: Number(m[1]), slug: m[2]! });
|
|
194
194
|
}
|
|
195
195
|
}
|
|
@@ -235,6 +235,7 @@ async function runTurn(
|
|
|
235
235
|
edgeGraph: deps.lanes.edgeGraph,
|
|
236
236
|
coreSlugs: [],
|
|
237
237
|
hotSlugs: [],
|
|
238
|
+
freshSlugs: [],
|
|
238
239
|
prefixCards: new Map(),
|
|
239
240
|
});
|
|
240
241
|
const active = getActiveSlugs(conversationId);
|
|
@@ -127,8 +127,9 @@ function depsOf(
|
|
|
127
127
|
): OrchestrateDeps {
|
|
128
128
|
const coreSlugs = overrides.coreSlugs ?? [];
|
|
129
129
|
const hotSlugs = overrides.hotSlugs ?? [];
|
|
130
|
+
const freshSlugs = overrides.freshSlugs ?? [];
|
|
130
131
|
const prefixCards = new Map<Slug, string>(
|
|
131
|
-
[...coreSlugs, ...hotSlugs].map((slug) => [
|
|
132
|
+
[...coreSlugs, ...hotSlugs, ...freshSlugs].map((slug) => [
|
|
132
133
|
slug,
|
|
133
134
|
renderCard(slug, RAW[slug] ?? ""),
|
|
134
135
|
]),
|
|
@@ -140,6 +141,7 @@ function depsOf(
|
|
|
140
141
|
edgeGraph: lanes.edgeGraph,
|
|
141
142
|
coreSlugs,
|
|
142
143
|
hotSlugs,
|
|
144
|
+
freshSlugs,
|
|
143
145
|
prefixCards,
|
|
144
146
|
...overrides,
|
|
145
147
|
};
|
|
@@ -148,12 +150,14 @@ function depsOf(
|
|
|
148
150
|
function makeTurn(
|
|
149
151
|
turnNumber: number,
|
|
150
152
|
currentMessage: string,
|
|
153
|
+
previousAssistantMessage?: string,
|
|
151
154
|
): MemoryRoutingTurn {
|
|
152
155
|
return {
|
|
153
156
|
conversationId: "conv-xyz",
|
|
154
157
|
turnNumber,
|
|
155
158
|
currentMessage,
|
|
156
159
|
recentContext: "prior context",
|
|
160
|
+
previousAssistantMessage,
|
|
157
161
|
};
|
|
158
162
|
}
|
|
159
163
|
|
|
@@ -199,7 +203,7 @@ function parsePool(messages: Message[]): {
|
|
|
199
203
|
);
|
|
200
204
|
if (finder) {
|
|
201
205
|
for (const line of finder[1].split("\n")) {
|
|
202
|
-
const m = /^\[(\d+)\] (\S+)(?: — |$)/.exec(line);
|
|
206
|
+
const m = /^\[(\d+)\] (?:\([^)]*\) )?(\S+)(?: — |$)/.exec(line);
|
|
203
207
|
if (m) entries.push({ id: Number(m[1]), slug: m[2]!, line });
|
|
204
208
|
}
|
|
205
209
|
}
|
|
@@ -371,6 +375,133 @@ describe("orchestrate — cache-ordered pool (core + hot + finders)", () => {
|
|
|
371
375
|
]);
|
|
372
376
|
});
|
|
373
377
|
|
|
378
|
+
test("fresh follows hot in the pool and dedups against core/hot", async () => {
|
|
379
|
+
const lanes = await buildLanes();
|
|
380
|
+
denseHits = [];
|
|
381
|
+
providerStub = selectProvider([]);
|
|
382
|
+
|
|
383
|
+
const result = await orchestrate(
|
|
384
|
+
makeTurn(1, "apple"),
|
|
385
|
+
depsOf(lanes, {
|
|
386
|
+
coreSlugs: ["topic-c"],
|
|
387
|
+
hotSlugs: ["topic-d"],
|
|
388
|
+
// topic-c (core) and topic-d (hot) are defensively dropped; only
|
|
389
|
+
// topic-b earns a fresh slot.
|
|
390
|
+
freshSlugs: ["topic-c", "topic-d", "topic-b"],
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
expect(lastPool).toEqual(["topic-c", "topic-d", "topic-b", "topic-a"]);
|
|
395
|
+
expect(result.lanes.fresh).toEqual(["topic-b"]);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('reply-query hits join the finder tail tagged "reply", after primary lanes and before edge', async () => {
|
|
399
|
+
const lanes = await buildLanes();
|
|
400
|
+
denseHits = [];
|
|
401
|
+
providerStub = selectProvider([]);
|
|
402
|
+
|
|
403
|
+
// Primary query matches topic-a; the previous reply matches topic-b. The
|
|
404
|
+
// edge lane expands topic-a's curated link to topic-d.
|
|
405
|
+
const result = await orchestrate(
|
|
406
|
+
makeTurn(1, "apple", "cherry date"),
|
|
407
|
+
depsOf(lanes, { replyQueryK: 5 }),
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
expect(result.lanes.finder.map((c) => [c.slug, c.lane])).toEqual([
|
|
411
|
+
["topic-a", "needle"],
|
|
412
|
+
["topic-b", "reply"],
|
|
413
|
+
["topic-d", "edge"],
|
|
414
|
+
]);
|
|
415
|
+
// The reply-matched section is recorded for injection/spotlight.
|
|
416
|
+
expect(result.matchedSections.has("topic-b")).toBe(true);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("a slug both queries surface keeps its primary-lane attribution", async () => {
|
|
420
|
+
const lanes = await buildLanes();
|
|
421
|
+
denseHits = [];
|
|
422
|
+
providerStub = selectProvider([]);
|
|
423
|
+
|
|
424
|
+
const result = await orchestrate(
|
|
425
|
+
makeTurn(1, "apple", "apple banana"),
|
|
426
|
+
depsOf(lanes, { replyQueryK: 5 }),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const topicA = result.lanes.finder.filter((c) => c.slug === "topic-a");
|
|
430
|
+
expect(topicA).toHaveLength(1);
|
|
431
|
+
expect(topicA[0]!.lane).toBe("needle");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("no previous assistant message → no reply-lane candidates", async () => {
|
|
435
|
+
const lanes = await buildLanes();
|
|
436
|
+
denseHits = [];
|
|
437
|
+
providerStub = selectProvider([]);
|
|
438
|
+
|
|
439
|
+
const result = await orchestrate(
|
|
440
|
+
makeTurn(1, "apple"),
|
|
441
|
+
depsOf(lanes, { replyQueryK: 5 }),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
expect(result.lanes.finder.some((c) => c.lane === "reply")).toBe(false);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("replyQueryK = 0 disables the pass even with a previous reply", async () => {
|
|
448
|
+
const lanes = await buildLanes();
|
|
449
|
+
denseHits = [];
|
|
450
|
+
providerStub = selectProvider([]);
|
|
451
|
+
|
|
452
|
+
const result = await orchestrate(
|
|
453
|
+
makeTurn(1, "apple", "cherry date"),
|
|
454
|
+
depsOf(lanes, { replyQueryK: 0 }),
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
expect(result.lanes.finder.some((c) => c.lane === "reply")).toBe(false);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('learned-edge expansion surfaces association neighbours tagged "learned", after the static edge lane', async () => {
|
|
461
|
+
const lanes = await buildLanes();
|
|
462
|
+
denseHits = [];
|
|
463
|
+
providerStub = selectProvider([]);
|
|
464
|
+
|
|
465
|
+
// Needle("apple") surfaces topic-a; static links expand a → d; the
|
|
466
|
+
// learned graph associates a → c (no authored link exists).
|
|
467
|
+
const learnedGraph = {
|
|
468
|
+
adjacency: new Map([["topic-a", new Map([["topic-c", undefined]])]]),
|
|
469
|
+
hubs: new Set<Slug>(),
|
|
470
|
+
slugs: new Set(SLUGS),
|
|
471
|
+
};
|
|
472
|
+
const result = await orchestrate(
|
|
473
|
+
makeTurn(1, "apple"),
|
|
474
|
+
depsOf(lanes, { learnedGraph, learnedPerSeed: 3, learnedCap: 20 }),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
expect(result.lanes.finder.map((c) => [c.slug, c.lane])).toEqual([
|
|
478
|
+
["topic-a", "needle"],
|
|
479
|
+
["topic-d", "edge"],
|
|
480
|
+
["topic-c", "learned"],
|
|
481
|
+
]);
|
|
482
|
+
// Association, not lexical relevance, surfaced topic-c — no matched
|
|
483
|
+
// section is recorded (injection falls back to the full page).
|
|
484
|
+
expect(result.matchedSections.has("topic-c")).toBe(false);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("learnedCap = 0 disables the learned pass", async () => {
|
|
488
|
+
const lanes = await buildLanes();
|
|
489
|
+
denseHits = [];
|
|
490
|
+
providerStub = selectProvider([]);
|
|
491
|
+
|
|
492
|
+
const learnedGraph = {
|
|
493
|
+
adjacency: new Map([["topic-a", new Map([["topic-c", undefined]])]]),
|
|
494
|
+
hubs: new Set<Slug>(),
|
|
495
|
+
slugs: new Set(SLUGS),
|
|
496
|
+
};
|
|
497
|
+
const result = await orchestrate(
|
|
498
|
+
makeTurn(1, "apple"),
|
|
499
|
+
depsOf(lanes, { learnedGraph, learnedCap: 0 }),
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
expect(result.lanes.finder.some((c) => c.lane === "learned")).toBe(false);
|
|
503
|
+
});
|
|
504
|
+
|
|
374
505
|
test("the rendered stable prefix is byte-identical across turns with different queries", async () => {
|
|
375
506
|
const lanes = await buildLanes();
|
|
376
507
|
const deps = depsOf(lanes, {
|
|
@@ -456,9 +587,9 @@ describe("orchestrate — cache-ordered pool (core + hot + finders)", () => {
|
|
|
456
587
|
expect(lastPool.filter((s) => s === "topic-a")).toHaveLength(2);
|
|
457
588
|
expect(lastPool[0]).toBe("topic-a");
|
|
458
589
|
// The finder line shows the matched-section snippet.
|
|
459
|
-
expect(
|
|
460
|
-
|
|
461
|
-
);
|
|
590
|
+
expect(
|
|
591
|
+
lastPoolLines.find((l) => /^\[2\] (?:\([^)]*\) )?topic-a — /.test(l)),
|
|
592
|
+
).toContain("apple");
|
|
462
593
|
// Selecting both ids still yields ONE selection (slug dedup), the finder
|
|
463
594
|
// lane records the hit, and the matched section survives downstream.
|
|
464
595
|
expect(result.selections).toEqual([{ slug: "topic-a", pinned: false }]);
|
|
@@ -316,6 +316,23 @@ describe("selectPool — request shape", () => {
|
|
|
316
316
|
expect(tail.text).not.toContain("<candidate_cards>");
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
+
test("finder lines render the surfacing lane tag when one is supplied", async () => {
|
|
320
|
+
providerStub = makeProvider(toolUseResponse({ ids: [] }));
|
|
321
|
+
const pool = makePool();
|
|
322
|
+
pool.finder = [
|
|
323
|
+
{ slug: "topic-x", descriptor: "section: about topic x", lane: "needle" },
|
|
324
|
+
{ slug: "page-a", descriptor: "", lane: "learned" },
|
|
325
|
+
];
|
|
326
|
+
await selectPool(pool, makeTurn("rollout?"));
|
|
327
|
+
|
|
328
|
+
const [, tail] = sentBlocks();
|
|
329
|
+
expect(tail.text).toContain(
|
|
330
|
+
"[3] (needle) topic-x — section: about topic x",
|
|
331
|
+
);
|
|
332
|
+
// Empty descriptor: lane tag still renders, dash omitted.
|
|
333
|
+
expect(tail.text).toContain("[4] (learned) page-a");
|
|
334
|
+
});
|
|
335
|
+
|
|
319
336
|
test("the rendered prefix is byte-identical across turns; only the tail varies", async () => {
|
|
320
337
|
providerStub = makeProvider(toolUseResponse({ ids: [1] }));
|
|
321
338
|
await selectPool(makePool(), makeTurn("first question"));
|
|
@@ -202,9 +202,12 @@ describe("summarizeSelections", () => {
|
|
|
202
202
|
expect(summary.bySource).toEqual({
|
|
203
203
|
core: 0,
|
|
204
204
|
hot: 0,
|
|
205
|
+
fresh: 0,
|
|
205
206
|
needle: 2,
|
|
206
207
|
dense: 0,
|
|
207
208
|
edge: 2,
|
|
209
|
+
reply: 0,
|
|
210
|
+
learned: 0,
|
|
208
211
|
});
|
|
209
212
|
expect(summary.turns).toBe(2);
|
|
210
213
|
// page-1 and page-2 — distinct across the two turns.
|
|
@@ -216,9 +219,12 @@ describe("summarizeSelections", () => {
|
|
|
216
219
|
bySource: {
|
|
217
220
|
core: 0,
|
|
218
221
|
hot: 0,
|
|
222
|
+
fresh: 0,
|
|
219
223
|
needle: 0,
|
|
220
224
|
dense: 0,
|
|
221
225
|
edge: 0,
|
|
226
|
+
reply: 0,
|
|
227
|
+
learned: 0,
|
|
222
228
|
},
|
|
223
229
|
turns: 0,
|
|
224
230
|
distinctSlugs: 0,
|
|
@@ -237,7 +237,7 @@ function candidateSlugs(messages: Message[]): Slug[] {
|
|
|
237
237
|
);
|
|
238
238
|
if (finder) {
|
|
239
239
|
for (const line of finder[1].split("\n")) {
|
|
240
|
-
const m = /^\[(\d+)\] (\S+)(?: — |$)/.exec(line);
|
|
240
|
+
const m = /^\[(\d+)\] (?:\([^)]*\) )?(\S+)(?: — |$)/.exec(line);
|
|
241
241
|
if (m) entries.push({ id: Number(m[1]), slug: m[2]! });
|
|
242
242
|
}
|
|
243
243
|
}
|
|
@@ -310,6 +310,7 @@ async function runTurn(
|
|
|
310
310
|
edgeGraph: deps.lanes.edgeGraph,
|
|
311
311
|
coreSlugs: deps.core ?? [],
|
|
312
312
|
hotSlugs: deps.hot ?? [],
|
|
313
|
+
freshSlugs: [],
|
|
313
314
|
// Mirrors lane init: every stable-prefix slug gets a pre-rendered card.
|
|
314
315
|
prefixCards: new Map(
|
|
315
316
|
stableSlugs.map((slug) => [slug, renderCard(slug, RAW[slug] ?? "")]),
|
|
@@ -476,9 +477,12 @@ describe("memory-v3 shadow integration — selection-log readout", () => {
|
|
|
476
477
|
bySource: {
|
|
477
478
|
core: 0,
|
|
478
479
|
hot: 0,
|
|
480
|
+
fresh: 0,
|
|
479
481
|
needle: 0,
|
|
480
482
|
dense: 0,
|
|
481
483
|
edge: 0,
|
|
484
|
+
reply: 0,
|
|
485
|
+
learned: 0,
|
|
482
486
|
},
|
|
483
487
|
turns: 0,
|
|
484
488
|
distinctSlugs: 0,
|
|
@@ -34,6 +34,7 @@ import type { HotSetEntry, HotSetOptions } from "../hot-set.js";
|
|
|
34
34
|
import type { OrchestrateResult } from "../orchestrate.js";
|
|
35
35
|
import {
|
|
36
36
|
MEMORY_V3_COMMIT_META_KEY,
|
|
37
|
+
type MemoryRoutingTurn,
|
|
37
38
|
type SectionIndex,
|
|
38
39
|
type SelectionSource,
|
|
39
40
|
} from "../types.js";
|
|
@@ -88,8 +89,9 @@ const CAPABILITY_SLUG = "skills/example";
|
|
|
88
89
|
const CAPABILITY_CONTENT = "use the kumquat skill to do the thing";
|
|
89
90
|
|
|
90
91
|
// The orchestrate result the spy returns. `lanes` records where each pooled
|
|
91
|
-
// slug lived: page-core in the core lane, page-hot in the hot lane,
|
|
92
|
-
// finder entries page-1 → "needle", page-2 → "dense",
|
|
92
|
+
// slug lived: page-core in the core lane, page-hot in the hot lane, page-fresh
|
|
93
|
+
// in the fresh lane, and the finder entries page-1 → "needle", page-2 → "dense",
|
|
94
|
+
// page-3 → "edge";
|
|
93
95
|
// `attributeSelections` reads it directly. `matchedSections` carries the
|
|
94
96
|
// matched section for the slugs that had one (page-1/page-2) — consumed by the
|
|
95
97
|
// live injector's progressive disclosure, independent of source attribution.
|
|
@@ -98,9 +100,12 @@ const orchestrateSpy = mock(
|
|
|
98
100
|
selections: [
|
|
99
101
|
{ slug: "page-core", pinned: false },
|
|
100
102
|
{ slug: "page-hot", pinned: false },
|
|
103
|
+
{ slug: "page-fresh", pinned: false },
|
|
101
104
|
{ slug: "page-1", pinned: true },
|
|
102
105
|
{ slug: "page-2", pinned: false },
|
|
103
106
|
{ slug: "page-3", pinned: false },
|
|
107
|
+
{ slug: "page-4", pinned: false },
|
|
108
|
+
{ slug: "page-5", pinned: false },
|
|
104
109
|
],
|
|
105
110
|
matchedSections: new Map([
|
|
106
111
|
["page-1", { article: "page-1", title: "", text: "x", ordinal: 0 }],
|
|
@@ -109,10 +114,13 @@ const orchestrateSpy = mock(
|
|
|
109
114
|
lanes: {
|
|
110
115
|
core: ["page-core"],
|
|
111
116
|
hot: ["page-hot"],
|
|
117
|
+
fresh: ["page-fresh"],
|
|
112
118
|
finder: [
|
|
113
119
|
{ slug: "page-1", descriptor: "", lane: "needle" },
|
|
114
120
|
{ slug: "page-2", descriptor: "", lane: "dense" },
|
|
115
121
|
{ slug: "page-3", descriptor: "", lane: "edge" },
|
|
122
|
+
{ slug: "page-4", descriptor: "", lane: "reply" },
|
|
123
|
+
{ slug: "page-5", descriptor: "", lane: "learned" },
|
|
116
124
|
],
|
|
117
125
|
},
|
|
118
126
|
}),
|
|
@@ -180,9 +188,19 @@ mock.module("../../../../config/loader.js", () => ({
|
|
|
180
188
|
enabled: memoryEnabled,
|
|
181
189
|
v3: {
|
|
182
190
|
hotSet: { k: 40, halfLifeDays: 14 },
|
|
191
|
+
freshSet: { k: 50 },
|
|
183
192
|
spotlight: { n: 6, windowTurns: 2 },
|
|
184
193
|
needleK: 100,
|
|
185
194
|
denseK: 100,
|
|
195
|
+
replyQueryK: 12,
|
|
196
|
+
learnedEdges: {
|
|
197
|
+
halfLifeDays: 30,
|
|
198
|
+
minCount: 3,
|
|
199
|
+
npmiFloor: 0.2,
|
|
200
|
+
maxPerPage: 6,
|
|
201
|
+
perSeed: 3,
|
|
202
|
+
cap: 20,
|
|
203
|
+
},
|
|
186
204
|
edge: { hubDegree: 30, seedCount: 18, perSeed: 6, cap: 45 },
|
|
187
205
|
},
|
|
188
206
|
qdrant: { vectorSize: 8, onDisk: false },
|
|
@@ -463,12 +481,57 @@ describe("memory-v3 shadow plugin", () => {
|
|
|
463
481
|
{ slug: "page-2", source: "dense", pinned: 0 },
|
|
464
482
|
// page-3 was surfaced by the edge lane → "edge".
|
|
465
483
|
{ slug: "page-3", source: "edge", pinned: 0 },
|
|
466
|
-
// page-
|
|
484
|
+
// page-4 was first surfaced by the reply-query pass → "reply".
|
|
485
|
+
{ slug: "page-4", source: "reply", pinned: 0 },
|
|
486
|
+
// page-5 was first surfaced by the learned-edge pass → "learned".
|
|
487
|
+
{ slug: "page-5", source: "learned", pinned: 0 },
|
|
488
|
+
// page-core / page-hot / page-fresh sit in the stable prefix →
|
|
489
|
+
// "core" / "hot" / "fresh".
|
|
467
490
|
{ slug: "page-core", source: "core", pinned: 0 },
|
|
491
|
+
{ slug: "page-fresh", source: "fresh", pinned: 0 },
|
|
468
492
|
{ slug: "page-hot", source: "hot", pinned: 0 },
|
|
469
493
|
]);
|
|
470
494
|
});
|
|
471
495
|
|
|
496
|
+
test("the turn carries the tail of the previous assistant reply for the reply-query pass", async () => {
|
|
497
|
+
shadowEnabled = true;
|
|
498
|
+
messages = [
|
|
499
|
+
{
|
|
500
|
+
role: "user",
|
|
501
|
+
content: JSON.stringify([{ type: "text", text: "first question" }]),
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
role: "assistant",
|
|
505
|
+
content: JSON.stringify([
|
|
506
|
+
{ type: "text", text: "the thread continues from my last reply" },
|
|
507
|
+
]),
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
role: "user",
|
|
511
|
+
content: JSON.stringify([{ type: "text", text: "and now this" }]),
|
|
512
|
+
},
|
|
513
|
+
];
|
|
514
|
+
await runShadowObservation("conv-1", 1);
|
|
515
|
+
|
|
516
|
+
const turn = (
|
|
517
|
+
orchestrateSpy.mock.calls as unknown as unknown[][]
|
|
518
|
+
)[0]![0] as MemoryRoutingTurn;
|
|
519
|
+
expect(turn.currentMessage).toBe("and now this");
|
|
520
|
+
expect(turn.previousAssistantMessage).toBe(
|
|
521
|
+
"the thread continues from my last reply",
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("a conversation-opening turn has no previous assistant message", async () => {
|
|
526
|
+
shadowEnabled = true;
|
|
527
|
+
await runShadowObservation("conv-1", 0);
|
|
528
|
+
|
|
529
|
+
const turn = (
|
|
530
|
+
orchestrateSpy.mock.calls as unknown as unknown[][]
|
|
531
|
+
)[0]![0] as MemoryRoutingTurn;
|
|
532
|
+
expect(turn.previousAssistantMessage).toBeUndefined();
|
|
533
|
+
});
|
|
534
|
+
|
|
472
535
|
test("a selection of a core page a finder also hit attributes to core (pool position wins)", () => {
|
|
473
536
|
const rows = attributeSelections({
|
|
474
537
|
selections: [{ slug: "page-core", pinned: false }],
|
|
@@ -476,6 +539,7 @@ describe("memory-v3 shadow plugin", () => {
|
|
|
476
539
|
lanes: {
|
|
477
540
|
core: ["page-core"],
|
|
478
541
|
hot: [],
|
|
542
|
+
fresh: [],
|
|
479
543
|
// The needle also hit the core page this turn — the row still logs
|
|
480
544
|
// "core" because that is where the candidate lived in the pool.
|
|
481
545
|
finder: [{ slug: "page-core", descriptor: "", lane: "needle" }],
|
|
@@ -598,7 +662,7 @@ describe("memory-v3 shadow plugin", () => {
|
|
|
598
662
|
orchestrateSpy.mockImplementationOnce(async () => ({
|
|
599
663
|
selections: [],
|
|
600
664
|
matchedSections: new Map(),
|
|
601
|
-
lanes: { core: [], hot: [], finder: [] },
|
|
665
|
+
lanes: { core: [], hot: [], fresh: [], finder: [] },
|
|
602
666
|
}));
|
|
603
667
|
const block = await produce("conv-1", 0);
|
|
604
668
|
expect(block).toBeNull();
|
|
@@ -72,22 +72,61 @@ function renderTocLine(
|
|
|
72
72
|
return `[sections: ${headings.map((h) => `§${h}`).join(" · ")}]`;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Max characters of a `current:` line carried onto the card. A `current:` is
|
|
76
|
+
* one line by contract; the cap keeps a runaway value from bloating every
|
|
77
|
+
* render of the card. */
|
|
78
|
+
const CURRENT_MAX_CHARS = 280;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render a page's `current:` frontmatter (one-line live state) as a card
|
|
82
|
+
* annotation, or `null` when the page has none. Whitespace-collapsed and
|
|
83
|
+
* capped at {@link CURRENT_MAX_CHARS}.
|
|
84
|
+
*/
|
|
85
|
+
function renderCurrentLine(fields: Record<string, unknown>): string | null {
|
|
86
|
+
const current = fields.current;
|
|
87
|
+
if (typeof current !== "string") return null;
|
|
88
|
+
const collapsed = current.replace(/\s+/g, " ").trim();
|
|
89
|
+
if (collapsed.length === 0) return null;
|
|
90
|
+
const capped =
|
|
91
|
+
collapsed.length > CURRENT_MAX_CHARS
|
|
92
|
+
? `${collapsed.slice(0, CURRENT_MAX_CHARS).trimEnd()}…`
|
|
93
|
+
: collapsed;
|
|
94
|
+
return `[current: ${capped}]`;
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
/**
|
|
76
|
-
* Render a page's compact card: header marker,
|
|
77
|
-
* corpus's rare long leads inject whole; a length cap
|
|
78
|
-
* non-feature), and one-line TOC.
|
|
98
|
+
* Render a page's compact card: header marker, optional annotation line, head
|
|
99
|
+
* section (uncapped — the corpus's rare long leads inject whole; a length cap
|
|
100
|
+
* is a deliberate non-feature), and one-line TOC.
|
|
79
101
|
*
|
|
80
102
|
* ```
|
|
81
103
|
* # memory/concepts/<slug>.md
|
|
104
|
+
* [lane: fresh · updated 2026-06-10 14:23]
|
|
82
105
|
* <head section verbatim>
|
|
83
106
|
*
|
|
84
107
|
* [sections: §… · §…]
|
|
85
108
|
* ```
|
|
86
109
|
*
|
|
110
|
+
* The annotation renders directly under the header: selector-visible metadata
|
|
111
|
+
* must sit on the always-rendered card surface (section-grain rendering can
|
|
112
|
+
* hide anything that lives only inside page content). Annotations must derive
|
|
113
|
+
* only from lane-init state (lane membership, page mtime) so the card stays
|
|
114
|
+
* byte-identical across turns between lane recomputes.
|
|
115
|
+
*
|
|
116
|
+
* A page's `current:` frontmatter (one-line live state — open items,
|
|
117
|
+
* deadlines, what's pending) renders the same way, first: it exists so
|
|
118
|
+
* state-shaped questions can select the page, which only works if the
|
|
119
|
+
* selector can see it on every card render. It changes only when the page is
|
|
120
|
+
* edited (consolidation), so it shares the lane annotation's cache story.
|
|
121
|
+
*
|
|
87
122
|
* The TOC line is omitted when the page has no `## ` sections and no usable
|
|
88
|
-
* `links:`. Deterministic for a given (slug, rawPageText).
|
|
123
|
+
* `links:`. Deterministic for a given (slug, rawPageText, annotation).
|
|
89
124
|
*/
|
|
90
|
-
export function renderCard(
|
|
125
|
+
export function renderCard(
|
|
126
|
+
slug: Slug,
|
|
127
|
+
rawPageText: string,
|
|
128
|
+
annotation?: string,
|
|
129
|
+
): string {
|
|
91
130
|
const parsed = parseFrontmatterFields(rawPageText);
|
|
92
131
|
// `parseFrontmatterFields` returns null both for "no frontmatter block" and
|
|
93
132
|
// "block present but YAML failed to parse" — strip the block either way so a
|
|
@@ -101,6 +140,11 @@ export function renderCard(slug: Slug, rawPageText: string): string {
|
|
|
101
140
|
const head = (firstHeading ? body.slice(0, firstHeading.index) : body).trim();
|
|
102
141
|
|
|
103
142
|
let card = injectedConceptHeader(slug);
|
|
143
|
+
const current = renderCurrentLine(fields);
|
|
144
|
+
if (current !== null) card += `\n${current}`;
|
|
145
|
+
if (annotation !== undefined && annotation.length > 0) {
|
|
146
|
+
card += `\n${annotation}`;
|
|
147
|
+
}
|
|
104
148
|
if (head.length > 0) card += `\n${head}`;
|
|
105
149
|
|
|
106
150
|
const toc = renderTocLine(body, fields);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recency fresh-set lane.
|
|
3
|
+
*
|
|
4
|
+
* Returns the top-K page slugs by most-recent on-disk modification
|
|
5
|
+
* (`PageIndexEntry.modifiedAt`). The fresh set keeps *just-written* pages in
|
|
6
|
+
* the candidate pool during the window before the other lanes can reach them:
|
|
7
|
+
* a page consolidated minutes ago has no selection history (so frecency can't
|
|
8
|
+
* rank it into the hot set) and a "what happened today?"-shaped message gives
|
|
9
|
+
* the finder lanes nothing lexical to match it on. Recency is exactly the
|
|
10
|
+
* signal those lanes are missing, so it gets its own lane.
|
|
11
|
+
*
|
|
12
|
+
* Like core and hot, the fresh set is a stable-prefix lane: it is computed at
|
|
13
|
+
* lane init and recomputed only on lane invalidation (the consolidation
|
|
14
|
+
* cadence), so its membership — and therefore the selector's cache-stable
|
|
15
|
+
* prefix — never changes mid-window. Page mtimes move on every consolidation
|
|
16
|
+
* write, which is the same event that invalidates the lanes, so recompute
|
|
17
|
+
* lag is bounded by the consolidation interval.
|
|
18
|
+
*
|
|
19
|
+
* Slugs in `excludeSlugs` (core + hot members) are dropped before the K cut —
|
|
20
|
+
* those pages already sit in the stable prefix, so re-listing them would only
|
|
21
|
+
* spend fresh slots on duplicates. Synthetic capability entries carry
|
|
22
|
+
* `modifiedAt: 0` and are skipped: they have no on-disk write to be fresh by.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Slug } from "./types.js";
|
|
26
|
+
|
|
27
|
+
/** The slice of a page-index entry the fresh lane ranks on. */
|
|
28
|
+
export interface FreshSetEntry {
|
|
29
|
+
slug: Slug;
|
|
30
|
+
/** File mtime in epoch ms; `0` for synthetic entries (skills, CLI commands). */
|
|
31
|
+
modifiedAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FreshSetOptions {
|
|
35
|
+
/** Maximum number of slugs returned; `0` disables the lane. */
|
|
36
|
+
k: number;
|
|
37
|
+
/** Slugs excluded from the result (core + hot — fresh never duplicates the
|
|
38
|
+
* rest of the stable prefix). */
|
|
39
|
+
excludeSlugs: Set<string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Compute the top-`k` fresh slugs by file modification time, newest first.
|
|
44
|
+
*
|
|
45
|
+
* Deterministic for fixed inputs: ties break `modifiedAt` desc, then slug asc.
|
|
46
|
+
*/
|
|
47
|
+
export function computeFreshSet(
|
|
48
|
+
entries: readonly FreshSetEntry[],
|
|
49
|
+
opts: FreshSetOptions,
|
|
50
|
+
): Slug[] {
|
|
51
|
+
const { k, excludeSlugs } = opts;
|
|
52
|
+
if (k <= 0) return [];
|
|
53
|
+
|
|
54
|
+
return entries
|
|
55
|
+
.filter((entry) => entry.modifiedAt > 0 && !excludeSlugs.has(entry.slug))
|
|
56
|
+
.sort((a, b) => b.modifiedAt - a.modifiedAt || (a.slug < b.slug ? -1 : 1))
|
|
57
|
+
.slice(0, k)
|
|
58
|
+
.map((entry) => entry.slug);
|
|
59
|
+
}
|
|
@@ -301,14 +301,16 @@ export const memoryV3Injector: Injector = {
|
|
|
301
301
|
// `produce()` would let a never-attached turn (non-user tail) claim
|
|
302
302
|
// cards in the store, suppressing them until compaction. The valve is
|
|
303
303
|
// scheduled after `recordInjected` so the resident accounting includes
|
|
304
|
-
// this turn's cards;
|
|
305
|
-
// stable prefix must never be pruned out from
|
|
304
|
+
// this turn's cards; stable-prefix lane members (core/hot/fresh) are
|
|
305
|
+
// exempt — the selector's stable prefix must never be pruned out from
|
|
306
|
+
// under it.
|
|
306
307
|
const commit = (): void => {
|
|
307
308
|
recordInjected(ctx.conversationId, entries);
|
|
308
309
|
schedulePruneValve(ctx.conversationId, {
|
|
309
310
|
exemptSlugs: new Set<Slug>([
|
|
310
311
|
...result.lanes.core,
|
|
311
312
|
...result.lanes.hot,
|
|
313
|
+
...result.lanes.fresh,
|
|
312
314
|
]),
|
|
313
315
|
});
|
|
314
316
|
};
|