@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,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the learned-edge lane (`learned-edges.ts`).
|
|
3
|
+
*
|
|
4
|
+
* `computeLearnedEdgeGraph` takes the db handle directly (no module mocks):
|
|
5
|
+
* each test seeds an in-memory SQLite db with `memory_v3_selections` rows
|
|
6
|
+
* (via the real migration) and asserts the NPMI association graph. Rows
|
|
7
|
+
* sharing a `(conversation_id, created_at)` form one selector call.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Database } from "bun:sqlite";
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
14
|
+
|
|
15
|
+
import { migrateAddMemoryV3Selections } from "../../../memory/migrations/268-add-memory-v3-selections.js";
|
|
16
|
+
import * as schema from "../../../memory/schema.js";
|
|
17
|
+
import {
|
|
18
|
+
computeLearnedEdgeGraph,
|
|
19
|
+
type LearnedEdgesOptions,
|
|
20
|
+
} from "./learned-edges.js";
|
|
21
|
+
|
|
22
|
+
const HALF_LIFE_MS = 1_000_000;
|
|
23
|
+
const NOW = 100_000;
|
|
24
|
+
|
|
25
|
+
let sqlite: Database;
|
|
26
|
+
let db: ReturnType<typeof drizzle<typeof schema>>;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
sqlite = new Database(":memory:");
|
|
30
|
+
db = drizzle(sqlite, { schema });
|
|
31
|
+
migrateAddMemoryV3Selections(db);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
let nextTurn = 0;
|
|
35
|
+
/** Insert one selector call: every slug shares the same (conv, created_at). */
|
|
36
|
+
function seedCall(slugs: string[], createdAt = NOW, conv = "conv-1"): void {
|
|
37
|
+
const stmt = sqlite.query(
|
|
38
|
+
`INSERT INTO memory_v3_selections (conversation_id, turn, slug, source, pinned, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
39
|
+
);
|
|
40
|
+
const turn = nextTurn++;
|
|
41
|
+
for (const slug of slugs) {
|
|
42
|
+
stmt.run(conv, turn, slug, "needle", 0, createdAt);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function graphOf(overrides: Partial<LearnedEdgesOptions> = {}) {
|
|
47
|
+
return computeLearnedEdgeGraph(
|
|
48
|
+
{ db },
|
|
49
|
+
{
|
|
50
|
+
halfLifeMs: HALF_LIFE_MS,
|
|
51
|
+
minCount: 2,
|
|
52
|
+
npmiFloor: 0.2,
|
|
53
|
+
maxPerPage: 6,
|
|
54
|
+
now: NOW,
|
|
55
|
+
windowMs: NOW,
|
|
56
|
+
knownSlugs: new Set([
|
|
57
|
+
"page-a",
|
|
58
|
+
"page-b",
|
|
59
|
+
"page-c",
|
|
60
|
+
"page-d",
|
|
61
|
+
"skills/widget",
|
|
62
|
+
]),
|
|
63
|
+
...overrides,
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const peersOf = (
|
|
69
|
+
graph: ReturnType<typeof computeLearnedEdgeGraph>,
|
|
70
|
+
slug: string,
|
|
71
|
+
): string[] => [...(graph.adjacency.get(slug)?.keys() ?? [])];
|
|
72
|
+
|
|
73
|
+
describe("computeLearnedEdgeGraph", () => {
|
|
74
|
+
test("pages selected together (and rarely apart) form a symmetric edge", () => {
|
|
75
|
+
// a+b co-select twice; c selects alone twice (so a/b are not ubiquitous).
|
|
76
|
+
seedCall(["page-a", "page-b"], NOW, "conv-1");
|
|
77
|
+
seedCall(["page-a", "page-b"], NOW, "conv-2");
|
|
78
|
+
seedCall(["page-c"], NOW, "conv-3");
|
|
79
|
+
seedCall(["page-c"], NOW, "conv-4");
|
|
80
|
+
|
|
81
|
+
const graph = graphOf();
|
|
82
|
+
expect(peersOf(graph, "page-a")).toEqual(["page-b"]);
|
|
83
|
+
expect(peersOf(graph, "page-b")).toEqual(["page-a"]);
|
|
84
|
+
expect(graph.hubs.size).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a page selected in every call forms no edges (NPMI → 0)", () => {
|
|
88
|
+
// page-a rides every call; page-b/page-c each appear half the time.
|
|
89
|
+
seedCall(["page-a", "page-b"], NOW, "conv-1");
|
|
90
|
+
seedCall(["page-a", "page-b"], NOW, "conv-2");
|
|
91
|
+
seedCall(["page-a", "page-c"], NOW, "conv-3");
|
|
92
|
+
seedCall(["page-a", "page-c"], NOW, "conv-4");
|
|
93
|
+
|
|
94
|
+
const graph = graphOf();
|
|
95
|
+
// p(a) = 1 ⇒ p(a,b) = p(b) ⇒ npmi = 0, floored out — for every pairing.
|
|
96
|
+
expect(peersOf(graph, "page-a")).toEqual([]);
|
|
97
|
+
expect(peersOf(graph, "page-b")).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("pairs below the co-occurrence mass floor form no edge", () => {
|
|
101
|
+
seedCall(["page-a", "page-b"], NOW, "conv-1"); // mass ≈ 1 < minCount 2
|
|
102
|
+
seedCall(["page-c"], NOW, "conv-2");
|
|
103
|
+
seedCall(["page-d"], NOW, "conv-3");
|
|
104
|
+
|
|
105
|
+
const graph = graphOf();
|
|
106
|
+
expect(peersOf(graph, "page-a")).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("decay pushes old co-selections below the mass floor", () => {
|
|
110
|
+
// Two co-selections ten half-lives ago: decayed mass ≈ 0.002 < 2.
|
|
111
|
+
seedCall(["page-a", "page-b"], NOW - 10 * HALF_LIFE_MS, "conv-1");
|
|
112
|
+
seedCall(["page-a", "page-b"], NOW - 10 * HALF_LIFE_MS, "conv-2");
|
|
113
|
+
seedCall(["page-c"], NOW, "conv-3");
|
|
114
|
+
|
|
115
|
+
const graph = graphOf({ windowMs: 20 * HALF_LIFE_MS });
|
|
116
|
+
expect(peersOf(graph, "page-a")).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("maxPerPage keeps the strongest associations first", () => {
|
|
120
|
+
// page-a co-selects with b twice and with c once alongside others, making
|
|
121
|
+
// a↔b the stronger association; cap page-a to one edge.
|
|
122
|
+
seedCall(["page-a", "page-b"], NOW, "conv-1");
|
|
123
|
+
seedCall(["page-a", "page-b"], NOW, "conv-2");
|
|
124
|
+
seedCall(["page-a", "page-c", "page-d"], NOW, "conv-3");
|
|
125
|
+
seedCall(["page-a", "page-c", "page-d"], NOW, "conv-4");
|
|
126
|
+
seedCall(["page-d"], NOW, "conv-5");
|
|
127
|
+
seedCall(["page-d"], NOW, "conv-6");
|
|
128
|
+
|
|
129
|
+
const graph = graphOf({ maxPerPage: 1 });
|
|
130
|
+
expect(peersOf(graph, "page-a")).toEqual(["page-b"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("capability slugs participate like any page", () => {
|
|
134
|
+
seedCall(["skills/widget", "page-a"], NOW, "conv-1");
|
|
135
|
+
seedCall(["skills/widget", "page-a"], NOW, "conv-2");
|
|
136
|
+
seedCall(["page-b"], NOW, "conv-3");
|
|
137
|
+
seedCall(["page-b"], NOW, "conv-4");
|
|
138
|
+
|
|
139
|
+
const graph = graphOf();
|
|
140
|
+
expect(peersOf(graph, "skills/widget")).toEqual(["page-a"]);
|
|
141
|
+
expect(peersOf(graph, "page-a")).toEqual(["skills/widget"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("slugs outside knownSlugs never form edges", () => {
|
|
145
|
+
seedCall(["page-a", "deleted-page"], NOW, "conv-1");
|
|
146
|
+
seedCall(["page-a", "deleted-page"], NOW, "conv-2");
|
|
147
|
+
seedCall(["page-b"], NOW, "conv-3");
|
|
148
|
+
|
|
149
|
+
const graph = graphOf();
|
|
150
|
+
expect(peersOf(graph, "page-a")).toEqual([]);
|
|
151
|
+
expect(graph.adjacency.has("deleted-page")).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("maxPerPage = 0 disables the lane", () => {
|
|
155
|
+
seedCall(["page-a", "page-b"], NOW, "conv-1");
|
|
156
|
+
seedCall(["page-a", "page-b"], NOW, "conv-2");
|
|
157
|
+
|
|
158
|
+
const graph = graphOf({ maxPerPage: 0 });
|
|
159
|
+
expect(graph.adjacency.size).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("rows outside the scan window are ignored", () => {
|
|
163
|
+
seedCall(["page-a", "page-b"], NOW - 50_000, "conv-1");
|
|
164
|
+
seedCall(["page-a", "page-b"], NOW - 60_000, "conv-2");
|
|
165
|
+
|
|
166
|
+
const graph = graphOf({ windowMs: 10_000 });
|
|
167
|
+
expect(graph.adjacency.size).toBe(0);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Learned-edge lane: a co-selection association graph over
|
|
3
|
+
* `memory_v3_selections`, scored by NPMI (normalized pointwise mutual
|
|
4
|
+
* information).
|
|
5
|
+
*
|
|
6
|
+
* Two pages that keep getting SELECTED together are associated in how the
|
|
7
|
+
* corpus is actually used, whether or not any authored `[[link]]` connects
|
|
8
|
+
* them. That behavioral signal is exactly what the static edge lane cannot
|
|
9
|
+
* see, and it reaches the candidate class no other lane can: pages relevant
|
|
10
|
+
* by association, with no lexical/semantic overlap with the query and no
|
|
11
|
+
* curated link from any seed. Capability slugs (skills / CLI commands)
|
|
12
|
+
* participate like any page — a skill co-selected with a topic page is a
|
|
13
|
+
* real association worth following.
|
|
14
|
+
*
|
|
15
|
+
* Scoring: selections are grouped into selector calls
|
|
16
|
+
* (`conversation_id` + `created_at`); each call contributes a
|
|
17
|
+
* recency-decayed weight `2^(−age / halfLifeMs)` to every slug (unigram) and
|
|
18
|
+
* unordered slug-pair (co-occurrence) it selected. NPMI per pair:
|
|
19
|
+
*
|
|
20
|
+
* npmi(a,b) = ln(p(a,b) / (p(a)·p(b))) / −ln(p(a,b))
|
|
21
|
+
*
|
|
22
|
+
* NPMI is what makes the lane safe around the stable-prefix pages: a page
|
|
23
|
+
* selected in (nearly) every call has p(a) ≈ 1, so p(a,b) ≈ p(b) and the
|
|
24
|
+
* numerator collapses to ~0 — ubiquitous pages form no edges, mathematically,
|
|
25
|
+
* rather than by special-casing. The `minCount` mass floor suppresses
|
|
26
|
+
* rare-pair noise (NPMI explodes on coincidences), and `maxPerPage` bounds
|
|
27
|
+
* each page's out-degree, which is the lane's real noise control.
|
|
28
|
+
*
|
|
29
|
+
* The result is emitted as an {@link EdgeGraph} so expansion REUSES
|
|
30
|
+
* `edgeExpand` unchanged. Neighbors are inserted in NPMI-descending order —
|
|
31
|
+
* `edgeExpand` takes neighbors in insertion order, so its `perSeed` budget
|
|
32
|
+
* consumes the strongest associations first. The graph carries no hubs
|
|
33
|
+
* (the per-page cap is the degree control) and no curated descriptions
|
|
34
|
+
* (the orchestrator falls back to a section descriptor).
|
|
35
|
+
*
|
|
36
|
+
* Like the other computed lanes, the graph is built at lane init and rebuilt
|
|
37
|
+
* on lane invalidation (the consolidation cadence). The selections table is
|
|
38
|
+
* the persistence; nothing else is stored.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
type DrizzleDb,
|
|
43
|
+
getSqliteFrom,
|
|
44
|
+
} from "../../../memory/db-connection.js";
|
|
45
|
+
import type { EdgeGraph } from "./edge.js";
|
|
46
|
+
import type { Slug } from "./types.js";
|
|
47
|
+
|
|
48
|
+
export interface LearnedEdgesDeps {
|
|
49
|
+
/** Handle to the database containing `memory_v3_selections`. */
|
|
50
|
+
db: DrizzleDb;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LearnedEdgesOptions {
|
|
54
|
+
/** Decay half-life in milliseconds: a selector call this old contributes
|
|
55
|
+
* half the weight of one made now. */
|
|
56
|
+
halfLifeMs: number;
|
|
57
|
+
/** Minimum decayed co-occurrence mass for a pair to form an edge — the
|
|
58
|
+
* rare-pair noise floor (NPMI explodes on coincidences). */
|
|
59
|
+
minCount: number;
|
|
60
|
+
/** Minimum NPMI for a pair to form an edge. */
|
|
61
|
+
npmiFloor: number;
|
|
62
|
+
/** Maximum out-edges kept per page (strongest-NPMI first); `0` disables
|
|
63
|
+
* the lane (empty graph). */
|
|
64
|
+
maxPerPage: number;
|
|
65
|
+
/** Reference timestamp (ms epoch) ages are measured against. */
|
|
66
|
+
now: number;
|
|
67
|
+
/** Only selections younger than this window are read. At a 30-day
|
|
68
|
+
* half-life, rows beyond ~90 days carry negligible weight anyway — the
|
|
69
|
+
* window bounds the scan, not the math. */
|
|
70
|
+
windowMs: number;
|
|
71
|
+
/** Slugs that may form edges — the live section-index membership, so
|
|
72
|
+
* selections of since-deleted pages can never surface a dangling
|
|
73
|
+
* candidate. */
|
|
74
|
+
knownSlugs: Set<string>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface SelectionRow {
|
|
78
|
+
conversation_id: string;
|
|
79
|
+
slug: string;
|
|
80
|
+
created_at: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the learned-edge {@link EdgeGraph} from the selection log.
|
|
85
|
+
*
|
|
86
|
+
* Deterministic for fixed inputs: per-page neighbors order by NPMI desc, then
|
|
87
|
+
* peer slug asc. Symmetric by construction — an edge contributes to BOTH
|
|
88
|
+
* endpoints' neighbor lists (each independently subject to `maxPerPage`).
|
|
89
|
+
*/
|
|
90
|
+
export function computeLearnedEdgeGraph(
|
|
91
|
+
deps: LearnedEdgesDeps,
|
|
92
|
+
opts: LearnedEdgesOptions,
|
|
93
|
+
): EdgeGraph {
|
|
94
|
+
const { halfLifeMs, minCount, npmiFloor, maxPerPage, now, windowMs } = opts;
|
|
95
|
+
const empty: EdgeGraph = {
|
|
96
|
+
adjacency: new Map(),
|
|
97
|
+
hubs: new Set(),
|
|
98
|
+
slugs: opts.knownSlugs,
|
|
99
|
+
};
|
|
100
|
+
if (maxPerPage <= 0) return empty;
|
|
101
|
+
|
|
102
|
+
const rows = getSqliteFrom(deps.db)
|
|
103
|
+
.query(
|
|
104
|
+
/*sql*/ `
|
|
105
|
+
SELECT conversation_id, slug, created_at FROM memory_v3_selections
|
|
106
|
+
WHERE created_at >= ?
|
|
107
|
+
`,
|
|
108
|
+
)
|
|
109
|
+
.all(now - windowMs) as SelectionRow[];
|
|
110
|
+
if (rows.length === 0) return empty;
|
|
111
|
+
|
|
112
|
+
// Group into selector calls; one decayed weight per call.
|
|
113
|
+
const calls = new Map<string, { weight: number; slugs: Set<Slug> }>();
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
if (!opts.knownSlugs.has(row.slug)) continue;
|
|
116
|
+
const key = `${row.conversation_id}|${row.created_at}`;
|
|
117
|
+
let call = calls.get(key);
|
|
118
|
+
if (!call) {
|
|
119
|
+
const age = Math.max(0, now - row.created_at);
|
|
120
|
+
call = { weight: 2 ** (-age / halfLifeMs), slugs: new Set() };
|
|
121
|
+
calls.set(key, call);
|
|
122
|
+
}
|
|
123
|
+
call.slugs.add(row.slug);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Decayed unigram and pair masses. Pair keys order endpoints
|
|
127
|
+
// lexicographically so (a,b) and (b,a) accumulate together.
|
|
128
|
+
const uni = new Map<Slug, number>();
|
|
129
|
+
const pair = new Map<string, number>();
|
|
130
|
+
let total = 0;
|
|
131
|
+
for (const { weight, slugs } of calls.values()) {
|
|
132
|
+
total += weight;
|
|
133
|
+
const arr = [...slugs];
|
|
134
|
+
for (const slug of arr) uni.set(slug, (uni.get(slug) ?? 0) + weight);
|
|
135
|
+
for (let i = 0; i < arr.length; i++) {
|
|
136
|
+
for (let j = i + 1; j < arr.length; j++) {
|
|
137
|
+
const key =
|
|
138
|
+
arr[i]! < arr[j]! ? `${arr[i]}\t${arr[j]}` : `${arr[j]}\t${arr[i]}`;
|
|
139
|
+
pair.set(key, (pair.get(key) ?? 0) + weight);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (total <= 0) return empty;
|
|
144
|
+
|
|
145
|
+
// Score pairs and collect each endpoint's qualifying neighbors.
|
|
146
|
+
const neighbors = new Map<Slug, Array<{ peer: Slug; npmi: number }>>();
|
|
147
|
+
const addNeighbor = (from: Slug, peer: Slug, npmi: number): void => {
|
|
148
|
+
let list = neighbors.get(from);
|
|
149
|
+
if (!list) {
|
|
150
|
+
list = [];
|
|
151
|
+
neighbors.set(from, list);
|
|
152
|
+
}
|
|
153
|
+
list.push({ peer, npmi });
|
|
154
|
+
};
|
|
155
|
+
for (const [key, mass] of pair) {
|
|
156
|
+
if (mass < minCount) continue;
|
|
157
|
+
const [a, b] = key.split("\t") as [Slug, Slug];
|
|
158
|
+
const pab = mass / total;
|
|
159
|
+
const pa = uni.get(a)! / total;
|
|
160
|
+
const pb = uni.get(b)! / total;
|
|
161
|
+
const npmi = Math.log(pab / (pa * pb)) / -Math.log(pab);
|
|
162
|
+
if (npmi <= npmiFloor) continue;
|
|
163
|
+
addNeighbor(a, b, npmi);
|
|
164
|
+
addNeighbor(b, a, npmi);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Emit adjacency with neighbors in NPMI-desc order (edgeExpand consumes
|
|
168
|
+
// insertion order), capped per page.
|
|
169
|
+
const adjacency = new Map<Slug, Map<Slug, string | undefined>>();
|
|
170
|
+
for (const [slug, list] of neighbors) {
|
|
171
|
+
list.sort((x, y) => y.npmi - x.npmi || (x.peer < y.peer ? -1 : 1));
|
|
172
|
+
const out = new Map<Slug, string | undefined>();
|
|
173
|
+
for (const { peer } of list.slice(0, maxPerPage)) out.set(peer, undefined);
|
|
174
|
+
adjacency.set(slug, out);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { adjacency, hubs: new Set(), slugs: opts.knownSlugs };
|
|
178
|
+
}
|
|
@@ -2,21 +2,29 @@
|
|
|
2
2
|
* Memory v3 — orchestrator composing the candidate lanes into one turn.
|
|
3
3
|
*
|
|
4
4
|
* Each turn runs:
|
|
5
|
-
* 1. Candidate generation over
|
|
5
|
+
* 1. Candidate generation over the deterministic finder lanes:
|
|
6
6
|
* - the section-grain BM25 needle (`SectionNeedle.query`, KB articles),
|
|
7
|
-
* - the dense lane (`denseLane`, KD articles),
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
7
|
+
* - the dense lane (`denseLane`, KD articles),
|
|
8
|
+
* - the reply-query pass — needle + dense re-run over the assistant's
|
|
9
|
+
* PREVIOUS message as separate queries at a smaller budget
|
|
10
|
+
* (`replyQueryK` per lane), surfacing the threads the assistant is
|
|
11
|
+
* actively developing that the user's message references without
|
|
12
|
+
* naming — and
|
|
13
|
+
* - link-graph edge expansion (`edgeExpand`) over the top
|
|
14
|
+
* user-message needle+dense article seeds, and
|
|
15
|
+
* - learned-edge expansion (`edgeExpand` over the co-selection NPMI
|
|
16
|
+
* graph from `learned-edges.ts`) over the same seeds — behavioral
|
|
17
|
+
* associations the authored link graph does not record.
|
|
10
18
|
* Each lane only ever ADDS candidates, so the pool is recall-safe by
|
|
11
19
|
* construction.
|
|
12
20
|
* 2. Build the candidate pool in CACHE ORDER: the stable prefix —
|
|
13
|
-
* `[...core (file order), ...hot (score order)
|
|
14
|
-
* init — followed by the finder
|
|
15
|
-
* surfacing order). The stable prefix
|
|
16
|
-
* turns while the lanes are unchanged
|
|
17
|
-
* is the recompute cadence), so the
|
|
18
|
-
* rides the provider KV cache (the
|
|
19
|
-
* `pool-select.ts`).
|
|
21
|
+
* `[...core (file order), ...hot (score order), ...fresh (recency
|
|
22
|
+
* order)]`, all computed at lane init — followed by the finder
|
|
23
|
+
* candidates (needle → dense → edge surfacing order). The stable prefix
|
|
24
|
+
* is identical across consecutive turns while the lanes are unchanged
|
|
25
|
+
* (lane invalidation at consolidation is the recompute cadence), so the
|
|
26
|
+
* selector input's leading segment rides the provider KV cache (the
|
|
27
|
+
* cache breakpoint itself lives in `pool-select.ts`).
|
|
20
28
|
*
|
|
21
29
|
* Stable-prefix candidates render as FULL CARDS (`renderCard` — head
|
|
22
30
|
* section + TOC), pre-rendered at lane init (`prefixCards`) so the
|
|
@@ -71,16 +79,23 @@ export interface OrchestrateDeps {
|
|
|
71
79
|
/** The frecency hot set in score order (computed at lane init with the core
|
|
72
80
|
* set excluded). Follows core in the stable prefix. */
|
|
73
81
|
hotSlugs: Slug[];
|
|
82
|
+
/** The modification-recency fresh set in recency order (computed at lane
|
|
83
|
+
* init with core and hot excluded). Follows hot in the stable prefix. */
|
|
84
|
+
freshSlugs: Slug[];
|
|
74
85
|
/** Pre-rendered FULL cards for the stable-prefix slugs, keyed by slug.
|
|
75
86
|
* Rendered ONCE at lane init so the selector's stable prefix is
|
|
76
|
-
* byte-identical across turns (the cache contract). Every core/hot
|
|
77
|
-
* MUST have an entry — a missing card is a lane-init bug and throws
|
|
87
|
+
* byte-identical across turns (the cache contract). Every core/hot/fresh
|
|
88
|
+
* slug MUST have an entry — a missing card is a lane-init bug and throws
|
|
78
89
|
* (silently degrading would violate the byte-stable-prefix contract). */
|
|
79
90
|
prefixCards: ReadonlyMap<Slug, string>;
|
|
80
91
|
/** Number of BM25 needle articles. Defaults to {@link DEFAULT_NEEDLE_K}. */
|
|
81
92
|
needleK?: number;
|
|
82
93
|
/** Number of dense-lane articles. Defaults to {@link DEFAULT_DENSE_K}. */
|
|
83
94
|
denseK?: number;
|
|
95
|
+
/** Per-lane article budget for the reply-query pass (needle + dense re-run
|
|
96
|
+
* over `turn.previousAssistantMessage` as separate queries). `0` or
|
|
97
|
+
* omitted disables the pass (canonical value: `memory.v3.replyQueryK`). */
|
|
98
|
+
replyQueryK?: number;
|
|
84
99
|
/** Number of top needle+dense seeds expanded. When omitted, the edge lane's
|
|
85
100
|
* own default applies (canonical value: `memory.v3.edge.seedCount`). */
|
|
86
101
|
edgeSeeds?: number;
|
|
@@ -90,6 +105,15 @@ export interface OrchestrateDeps {
|
|
|
90
105
|
/** Hard cap on total edge-lane surfaced articles. When omitted, the edge
|
|
91
106
|
* lane's own default applies (canonical value: `memory.v3.edge.cap`). */
|
|
92
107
|
edgeCap?: number;
|
|
108
|
+
/** The learned-edge graph (co-selection NPMI associations, built at lane
|
|
109
|
+
* init). Omitted or empty → no learned pass. */
|
|
110
|
+
learnedGraph?: EdgeGraph;
|
|
111
|
+
/** Learned neighbours surfaced per expanded seed (canonical value:
|
|
112
|
+
* `memory.v3.learnedEdges.perSeed`). */
|
|
113
|
+
learnedPerSeed?: number;
|
|
114
|
+
/** Hard cap on total learned-lane surfaced articles; `0` disables the pass
|
|
115
|
+
* (canonical value: `memory.v3.learnedEdges.cap`). */
|
|
116
|
+
learnedCap?: number;
|
|
93
117
|
}
|
|
94
118
|
|
|
95
119
|
/** A finder-lane candidate: the slug, the descriptor that justified it, and
|
|
@@ -101,16 +125,19 @@ export interface FinderCandidate {
|
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
/**
|
|
104
|
-
* The
|
|
105
|
-
* prefix (byte-identical across turns while lanes are unchanged);
|
|
106
|
-
* the dynamic tail and MAY repeat a stable-prefix slug (a finder
|
|
107
|
-
*
|
|
128
|
+
* The candidate lanes in cache order. `core`, `hot`, and `fresh` are the
|
|
129
|
+
* stable prefix (byte-identical across turns while lanes are unchanged);
|
|
130
|
+
* `finder` is the dynamic tail and MAY repeat a stable-prefix slug (a finder
|
|
131
|
+
* hit on a stable-prefix page is kept so its current relevance stays visible
|
|
132
|
+
* downstream).
|
|
108
133
|
*/
|
|
109
134
|
export interface OrchestrateLanes {
|
|
110
135
|
/** Curated core set, file order. */
|
|
111
136
|
core: Slug[];
|
|
112
137
|
/** Frecency hot set, score order (never overlaps core). */
|
|
113
138
|
hot: Slug[];
|
|
139
|
+
/** Modification-recency fresh set, recency order (never overlaps core/hot). */
|
|
140
|
+
fresh: Slug[];
|
|
114
141
|
/** Finder candidates in surfacing order, deduped among themselves only. */
|
|
115
142
|
finder: FinderCandidate[];
|
|
116
143
|
}
|
|
@@ -144,19 +171,34 @@ export async function orchestrate(
|
|
|
144
171
|
const denseK = deps.denseK ?? DEFAULT_DENSE_K;
|
|
145
172
|
const { sections } = deps.sectionIndex;
|
|
146
173
|
|
|
147
|
-
// The stable prefix: core (file order)
|
|
148
|
-
// computed with core excluded at lane init
|
|
149
|
-
//
|
|
174
|
+
// The stable prefix: core (file order), hot (score order), fresh (recency
|
|
175
|
+
// order). Hot is computed with core excluded at lane init, fresh with both
|
|
176
|
+
// excluded; the filters here are a cheap defensive dedup so a misbehaving
|
|
177
|
+
// lane can never double-list a slug.
|
|
150
178
|
const core = deps.coreSlugs;
|
|
151
179
|
const hot = deps.hotSlugs.filter((slug) => !core.includes(slug));
|
|
152
|
-
const
|
|
180
|
+
const coreHot = new Set<Slug>([...core, ...hot]);
|
|
181
|
+
const fresh = deps.freshSlugs.filter((slug) => !coreHot.has(slug));
|
|
182
|
+
const stablePrefix = new Set<Slug>([...coreHot, ...fresh]);
|
|
153
183
|
|
|
154
184
|
// Step 1: needle (sync BM25) and dense (async embed + Qdrant) lanes run in
|
|
155
185
|
// parallel. Both return distinct articles each tagged with their best-scoring
|
|
156
|
-
// section index/ordinal.
|
|
157
|
-
|
|
186
|
+
// section index/ordinal. The reply-query pass re-runs both lanes over the
|
|
187
|
+
// assistant's previous message as SEPARATE queries (concatenating the two
|
|
188
|
+
// speakers would average their retrieval intents into a vector that matches
|
|
189
|
+
// neither) at its own, smaller budget; it runs in the same parallel batch.
|
|
190
|
+
const replyK = deps.replyQueryK ?? 0;
|
|
191
|
+
const replyQuery =
|
|
192
|
+
replyK > 0 ? (turn.previousAssistantMessage ?? "").trim() : "";
|
|
193
|
+
const [needled, densed, replyNeedled, replyDensed] = await Promise.all([
|
|
158
194
|
Promise.resolve(deps.needle.query(turn.currentMessage, needleK)),
|
|
159
195
|
denseLane(deps.denseConfig, turn.currentMessage, denseK),
|
|
196
|
+
Promise.resolve(
|
|
197
|
+
replyQuery.length > 0 ? deps.needle.query(replyQuery, replyK) : [],
|
|
198
|
+
),
|
|
199
|
+
replyQuery.length > 0
|
|
200
|
+
? denseLane(deps.denseConfig, replyQuery, replyK)
|
|
201
|
+
: Promise.resolve([]),
|
|
160
202
|
]);
|
|
161
203
|
|
|
162
204
|
// `matchedSections` records the matched `Section` (when one is known) for
|
|
@@ -211,6 +253,24 @@ export async function orchestrate(
|
|
|
211
253
|
);
|
|
212
254
|
}
|
|
213
255
|
|
|
256
|
+
// Step 1b': reply-query hits — candidates the user-message lanes already
|
|
257
|
+
// surfaced keep their primary attribution (`addFinder`'s first-lane-wins
|
|
258
|
+
// dedup); only genuinely reply-surfaced articles tag `"reply"`. Matched
|
|
259
|
+
// sections are recorded the same way as the primary lanes', so injection
|
|
260
|
+
// and the spotlight render the reply-matched section.
|
|
261
|
+
for (const hit of replyNeedled) {
|
|
262
|
+
addFinder(hit.article, sections[hit.section], undefined, "reply");
|
|
263
|
+
}
|
|
264
|
+
for (const hit of replyDensed) {
|
|
265
|
+
if (!deps.sectionIndex.byArticle.has(hit.article)) continue;
|
|
266
|
+
addFinder(
|
|
267
|
+
hit.article,
|
|
268
|
+
sectionByOrdinal(deps.sectionIndex, hit.article, hit.section),
|
|
269
|
+
undefined,
|
|
270
|
+
"reply",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
214
274
|
// Step 1c: edge expansion over the top needle+dense article seeds. `alive`
|
|
215
275
|
// skips slugs already in the pool — finder-surfaced AND stable-prefix slugs
|
|
216
276
|
// (an edge hit carries no matched section, so re-surfacing a core/hot page
|
|
@@ -245,8 +305,36 @@ export async function orchestrate(
|
|
|
245
305
|
);
|
|
246
306
|
}
|
|
247
307
|
|
|
308
|
+
// Step 1d: learned-edge expansion over the SAME seeds, through the
|
|
309
|
+
// co-selection NPMI graph. Mirrors the static edge pass — no matched
|
|
310
|
+
// section (association, not lexical relevance, surfaced the page; injection
|
|
311
|
+
// falls back to the full page), `bestSection` text as the descriptor
|
|
312
|
+
// fallback — but tags `"learned"` so the lane's selection rate is
|
|
313
|
+
// measurable. Runs after the static lane: an association that duplicates an
|
|
314
|
+
// authored link keeps its `"edge"` attribution.
|
|
315
|
+
if (deps.learnedGraph && (deps.learnedCap ?? 0) > 0) {
|
|
316
|
+
const learned = edgeExpand(deps.learnedGraph, seeds, {
|
|
317
|
+
seedCount: deps.edgeSeeds,
|
|
318
|
+
perSeed: deps.learnedPerSeed,
|
|
319
|
+
cap: deps.learnedCap,
|
|
320
|
+
alive: (slug) => !finderSeen.has(slug) && !stablePrefix.has(slug),
|
|
321
|
+
});
|
|
322
|
+
for (const neighbor of learned) {
|
|
323
|
+
const best = deps.needle.bestSection(
|
|
324
|
+
neighbor.article,
|
|
325
|
+
turn.currentMessage,
|
|
326
|
+
);
|
|
327
|
+
addFinder(
|
|
328
|
+
neighbor.article,
|
|
329
|
+
undefined,
|
|
330
|
+
best >= 0 ? sections[best]?.text : undefined,
|
|
331
|
+
"learned",
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
248
336
|
// Step 2: assemble the selector pool in cache order — the stable prefix
|
|
249
|
-
// (core
|
|
337
|
+
// (core, hot, fresh) as FULL CARDS, then the finder tail. Cards are
|
|
250
338
|
// pre-rendered at lane init (`prefixCards`): query- AND
|
|
251
339
|
// conversation-state-INDEPENDENT by design, so the rendered prefix is
|
|
252
340
|
// byte-identical across turns while the lanes are unchanged. The tail is
|
|
@@ -254,7 +342,7 @@ export async function orchestrate(
|
|
|
254
342
|
// its own snippet line so its CURRENT relevance stays visible; `selectPool`
|
|
255
343
|
// dedupes selections by slug. Finder candidates with no match text fall
|
|
256
344
|
// back to the page's lead-section snippet.
|
|
257
|
-
const stable: StableCandidate[] = [...core, ...hot].map((slug) => {
|
|
345
|
+
const stable: StableCandidate[] = [...core, ...hot, ...fresh].map((slug) => {
|
|
258
346
|
const card = deps.prefixCards.get(slug);
|
|
259
347
|
if (card === undefined) {
|
|
260
348
|
// Lane init renders a card for every core/hot slug; a hole here means
|
|
@@ -269,6 +357,7 @@ export async function orchestrate(
|
|
|
269
357
|
});
|
|
270
358
|
const finderTail: PoolCandidate[] = finder.map((c) => ({
|
|
271
359
|
slug: c.slug,
|
|
360
|
+
lane: c.lane,
|
|
272
361
|
descriptor:
|
|
273
362
|
c.descriptor.trim().length > 0
|
|
274
363
|
? c.descriptor
|
|
@@ -283,7 +372,7 @@ export async function orchestrate(
|
|
|
283
372
|
return {
|
|
284
373
|
selections,
|
|
285
374
|
matchedSections,
|
|
286
|
-
lanes: { core, hot, finder },
|
|
375
|
+
lanes: { core, hot, fresh, finder },
|
|
287
376
|
};
|
|
288
377
|
}
|
|
289
378
|
|
|
@@ -62,10 +62,12 @@ const log = getLogger("memory-v3-pool-select");
|
|
|
62
62
|
|
|
63
63
|
/** A dynamic-tail (finder) candidate: the slug plus the descriptor that
|
|
64
64
|
* justifies it — a matched section for a needle/dense hit, or a curated link
|
|
65
|
-
* description for an edge page. Rendered as a one-line snippet
|
|
65
|
+
* description for an edge page. Rendered as a one-line snippet, prefixed
|
|
66
|
+
* with the surfacing lane when one is supplied. */
|
|
66
67
|
export interface PoolCandidate {
|
|
67
68
|
slug: Slug;
|
|
68
69
|
descriptor: string;
|
|
70
|
+
lane?: string;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
/** A stable-prefix candidate: the slug plus its pre-rendered FULL card
|
|
@@ -127,13 +129,13 @@ const SELECT_PAGES_TOOL: ToolDefinition = {
|
|
|
127
129
|
},
|
|
128
130
|
};
|
|
129
131
|
|
|
130
|
-
const SYSTEM_PROMPT = `You are given the candidate memory pages for an assistant's next reply, in two segments that share one numbering: full page cards first (the curated core and recently-
|
|
132
|
+
const SYSTEM_PROMPT = `You are given the candidate memory pages for an assistant's next reply, in two segments that share one numbering: full page cards first (the curated core, recently-recurring, and recently-modified pages, shown every turn), then this turn's search hits as one-line snippets. Cards carry a \`[lane: …]\` annotation — core is curated, hot recurs by selection frequency, fresh was recently modified (with its last-update time) — and search hits are tagged with the lane that surfaced them.
|
|
131
133
|
|
|
132
|
-
Select EVERY candidate whose content the upcoming reply would draw on. That includes facts the reply needs,
|
|
134
|
+
Select EVERY candidate whose content the upcoming reply would draw on. That includes facts the reply needs, current task and event state (open items, deadlines, schedules, recent activity), and equally register, established framing, calibration rules, and relationship/person/project texture — pages that shape HOW to reply, not only what to say. There is no limit on how many you may select; recall matters more than precision, so when a candidate could plausibly inform the reply, keep it. For a list or an "all of X" request, keep EVERY candidate that belongs to X rather than guessing a representative subset.
|
|
133
135
|
|
|
134
136
|
Pages you select persist in the conversation automatically, and re-selecting a page that is already in context is harmless (duplicates are removed downstream) — so never withhold a candidate because it might have been selected before. Judge each candidate only by whether the upcoming reply would draw on it.
|
|
135
137
|
|
|
136
|
-
A page can be relevant because of the current situation — the date or the live scratchpad — not only the message: keep a page the situation makes pertinent (e.g. a person whose anniversary is today).
|
|
138
|
+
A page can be relevant because of the current situation — the date or the live scratchpad — not only the message: keep a page the situation makes pertinent (e.g. a person whose anniversary is today). When the message asks about status, plans, schedule, or what's pending, treat pages carrying current task/event state — especially recently-updated (fresh) ones — as first-class candidates.
|
|
137
139
|
|
|
138
140
|
If the conversation is centrally ABOUT a page (rather than only peripherally relevant to it), mark that page as pinned. Call \`select_pages\` with the chosen IDs. Omit \`ids\` only as a recall-safe fallback when you cannot judge the pool (keeps every candidate); return \`[]\` when candidates are present but none are relevant.`;
|
|
139
141
|
|
|
@@ -153,17 +155,19 @@ function renderCardSegment(stable: StableCandidate[]): string {
|
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
/**
|
|
156
|
-
* Render the finder tail: one `[m+i] slug — snippet` line per
|
|
157
|
-
* numbered continuing after the `offset` stable-prefix cards.
|
|
158
|
-
*
|
|
158
|
+
* Render the finder tail: one `[m+i] (lane) slug — snippet` line per
|
|
159
|
+
* candidate, numbered continuing after the `offset` stable-prefix cards. The
|
|
160
|
+
* lane tag is omitted for a candidate without one; a candidate with an empty
|
|
161
|
+
* descriptor renders without the dash.
|
|
159
162
|
*/
|
|
160
163
|
function renderFinderSegment(finder: PoolCandidate[], offset: number): string {
|
|
161
164
|
const lines = finder.map((c, i) => {
|
|
162
165
|
const snippet = renderSnippet(c.descriptor);
|
|
163
166
|
const id = offset + i + 1;
|
|
167
|
+
const lane = c.lane !== undefined ? `(${c.lane}) ` : "";
|
|
164
168
|
return snippet.length > 0
|
|
165
|
-
? `[${id}] ${c.slug} — ${snippet}`
|
|
166
|
-
: `[${id}] ${c.slug}`;
|
|
169
|
+
? `[${id}] ${lane}${c.slug} — ${snippet}`
|
|
170
|
+
: `[${id}] ${lane}${c.slug}`;
|
|
167
171
|
});
|
|
168
172
|
return `<candidates>\n${lines.join("\n")}\n</candidates>`;
|
|
169
173
|
}
|