@unblocklabs/unblock-memory 0.7.0 → 0.7.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.
Files changed (41) hide show
  1. package/dist/src/analysis.js +11 -12
  2. package/dist/src/contracts.d.ts +1 -1
  3. package/dist/src/extraction-chunk.d.ts +1 -0
  4. package/dist/src/extraction-chunk.js +1 -0
  5. package/dist/src/extraction-model.js +5 -4
  6. package/dist/src/extraction-runtime.js +2 -7
  7. package/dist/src/extraction-source.js +6 -2
  8. package/dist/src/extraction-store.d.ts +3 -0
  9. package/dist/src/extraction-store.js +14 -10
  10. package/dist/src/extraction-worker.js +31 -15
  11. package/dist/src/manager.d.ts +1 -1
  12. package/dist/src/manager.js +63 -79
  13. package/dist/src/memory-whisperer.js +6 -10
  14. package/dist/src/people-primer.js +6 -8
  15. package/dist/src/people-store.js +2 -0
  16. package/dist/src/people-tools.js +2 -6
  17. package/dist/src/plugin.js +6 -14
  18. package/dist/src/query-contract.js +18 -10
  19. package/dist/src/response-audit.js +3 -3
  20. package/dist/src/response-judge.js +1 -2
  21. package/dist/src/response-store.js +2 -1
  22. package/dist/src/retrieval-telemetry.d.ts +1 -1
  23. package/dist/src/review-tools.d.ts +24 -0
  24. package/dist/src/review-tools.js +22 -26
  25. package/dist/src/session-projector.js +8 -4
  26. package/dist/src/skill-whisperer.js +44 -15
  27. package/dist/src/training-candidates.d.ts +12 -13
  28. package/dist/src/training-candidates.js +11 -81
  29. package/dist/src/training-gate.js +2 -2
  30. package/dist/src/training-queries.js +5 -2
  31. package/dist/src/training-retrieval.d.ts +15 -2
  32. package/dist/src/training-retrieval.js +63 -39
  33. package/dist/src/typesafe-client.d.ts +5 -0
  34. package/dist/src/typesafe-client.js +4 -0
  35. package/dist/src/typesafe-review.js +4 -6
  36. package/dist/src/typesafe.d.ts +1 -9
  37. package/dist/src/typesafe.js +4 -10
  38. package/docs/configuration.md +3 -2
  39. package/docs/retrieval.md +15 -1
  40. package/openclaw.plugin.json +2 -2
  41. package/package.json +2 -2
@@ -1,13 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import { Value } from "typebox/value";
4
- import { requestTypeSafe } from "./typesafe-client.js";
4
+ import { requestTypeSafe, TYPESAFE_NOUL_SCHEMA } from "./typesafe-client.js";
5
5
  import { TYPESAFE_REVIEW_MODEL } from "./typesafe-review.js";
6
6
  import { abortable } from "./abortable.js";
7
7
  const VERSION = "people-primer-background-v4";
8
8
  const MAX_EXCERPT_CHARS = 6000;
9
- const noul = Type.Object({ type: Type.Literal("noul"), noul: Type.Number({ minimum: 0, maximum: 1 }) });
10
- const answerSchema = Type.Object({ answers: Type.Record(Type.String(), noul) });
9
+ const answerSchema = Type.Object({ answers: Type.Record(Type.String(), TYPESAFE_NOUL_SCHEMA) });
11
10
  function questionsFor(name, agent) {
12
11
  return [
13
12
  { id: "role", question: `Who is ${name}? What is their explicitly stated role and organization?` },
@@ -115,15 +114,14 @@ export async function primePersonDossier(params) {
115
114
  requests++;
116
115
  payload = await requestTypeSafe({ apiKey: params.apiKey, timeoutMs: config.timeoutMs, signal }, state, questions);
117
116
  signal.throwIfAborted();
118
- if (!valid(payload) || !Value.Check(answerSchema, payload))
117
+ if (!valid(payload))
119
118
  throw new Error("Invalid primer judgments");
120
119
  // Keep only validated numerical answers, never provider extras or echoes.
121
120
  const answers = payload.answers;
122
- payload = { answers: Object.fromEntries(expectedKeys.map(k => [k, { type: "noul", noul: answers[k].noul }])) };
123
- store.cachePrimerJudgment(person.id, key, payload);
121
+ store.cachePrimerJudgment(person.id, key, {
122
+ answers: Object.fromEntries(expectedKeys.map(k => [k, { type: "noul", noul: answers[k].noul }])),
123
+ });
124
124
  }
125
- if (!Value.Check(answerSchema, payload))
126
- throw new Error("Invalid primer cache");
127
125
  graded.push({ ...candidate, aboutPerson: payload.answers.aboutPerson.noul,
128
126
  explicitBackground: payload.answers.explicitBackground.noul, enduring: payload.answers.enduring.noul,
129
127
  recognition: payload.answers.recognition.noul,
@@ -153,6 +153,8 @@ export class PeopleStore {
153
153
  this.#maxBlurbChars = options.maxBlurbChars;
154
154
  try {
155
155
  this.#migrate();
156
+ this.#db.exec(`CREATE INDEX IF NOT EXISTS person_identities_person
157
+ ON person_identities(person_id, provider, account_scope, external_id)`);
156
158
  }
157
159
  catch (error) {
158
160
  this.#db.close();
@@ -112,10 +112,6 @@ const syncParameters = Type.Object({
112
112
  accountId: Type.String({ pattern: "\\S", maxLength: 200 }),
113
113
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })),
114
114
  }, { additionalProperties: false });
115
- function context(ctx) {
116
- const cfg = ctx.getRuntimeConfig?.() ?? ctx.runtimeConfig ?? ctx.config;
117
- return cfg && ctx.agentId ? { agentId: ctx.agentId } : undefined;
118
- }
119
115
  function personView(stores, agentId, selector, maxChars) {
120
116
  const store = stores.get(agentId);
121
117
  const person = "personId" in selector
@@ -138,7 +134,7 @@ function personView(stores, agentId, selector, maxChars) {
138
134
  };
139
135
  }
140
136
  function createInspectTool(stores, config, ctx) {
141
- const active = context(ctx);
137
+ const active = getContext(ctx);
142
138
  if (!active)
143
139
  return null;
144
140
  return {
@@ -265,7 +261,7 @@ function createUpdateTool(stores, config, runtime, ctx) {
265
261
  };
266
262
  }
267
263
  function createSyncTool(stores, reader, ctx) {
268
- const active = context(ctx);
264
+ const active = getContext(ctx);
269
265
  if (!active)
270
266
  return null;
271
267
  return {
@@ -2,7 +2,6 @@ import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { jsonResult } from "openclaw/plugin-sdk/agent-runtime";
4
4
  import { resolveConfig } from "./config.js";
5
- import { resolveTypeSafeApiKey } from "./typesafe-client.js";
6
5
  import { registerPeopleHooks } from "./people-hooks.js";
7
6
  import { PeopleStores } from "./people-store.js";
8
7
  import { registerPeopleTools } from "./people-tools.js";
@@ -13,7 +12,7 @@ import { registerMemoryWhisperer } from "./memory-whisperer.js";
13
12
  import { registerWhispererPrompt } from "./whisperer-prompt.js";
14
13
  import { getContext } from "./tool-context.js";
15
14
  import { WhispererDiagnostics } from "./diagnostics.js";
16
- import { registerReviewTools } from "./review-tools.js";
15
+ import { registerReviewTools, reviewRequestContext } from "./review-tools.js";
17
16
  import { registerResponseAudit } from "./response-runtime.js";
18
17
  import { registerInsideOut } from "./inside-out-runtime.js";
19
18
  import { registerMemoryTraining } from "./training-runtime.js";
@@ -263,20 +262,13 @@ function createAuditQualityTool(runtime, ctx, config) {
263
262
  const options = Value.Parse(auditQualityParameters, params);
264
263
  if (!config.qualityAudit.enabled || !config.typesafe.enabled)
265
264
  return jsonResult({ status: "disabled" });
266
- const deadline = AbortSignal.timeout(30_000);
267
- const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
268
265
  try {
269
- combined.throwIfAborted();
270
- const apiKey = await resolveTypeSafeApiKey(config.typesafe);
271
- if (!apiKey)
272
- return jsonResult({ status: "unavailable", reason: "TypeSafe API key not configured" });
273
- combined.throwIfAborted();
274
- const { manager } = await runtime.getMemorySearchManager(active);
275
- if (!manager)
276
- return jsonResult({ status: "unavailable", reason: "Memory manager unavailable" });
277
- return jsonResult(await manager.auditQuality({
266
+ const context = await reviewRequestContext(runtime, active, config.typesafe, signal);
267
+ if (context.status !== "ready")
268
+ return jsonResult(context);
269
+ return jsonResult(await context.manager.auditQuality({
278
270
  ...options, corpora: config.qualityAudit.corpora, minNoise: config.qualityAudit.minNoise,
279
- apiKey, timeoutMs: config.typesafe.timeoutMs, signal: combined,
271
+ apiKey: context.apiKey, timeoutMs: context.timeoutMs, signal: context.signal,
280
272
  }));
281
273
  }
282
274
  catch {
@@ -44,15 +44,23 @@ export function prepareQueryConversation(history, currentRequest) {
44
44
  conversation.history.unshift(message);
45
45
  bytes += added;
46
46
  }
47
- let contextLimited = conversation.history.length < history.length;
48
- for (;;) {
49
- const serialized = serializeQueryConversation(conversation);
50
- if (Buffer.byteLength(serialized) <= contract.conversationBytes && queryTokenIds(serialized).length <= contract.conversationTokens) {
51
- return { conversation, contextLimited };
52
- }
53
- if (!conversation.history.length)
54
- throw new QueryInputBudgetError();
55
- conversation.history.shift();
56
- contextLimited = true;
47
+ const fits = (start) => queryTokenIds(serializeQueryConversation({
48
+ history: conversation.history.slice(start), currentRequest,
49
+ })).length <= contract.conversationTokens;
50
+ if (fits(0))
51
+ return { conversation, contextLimited: conversation.history.length < history.length };
52
+ if (!fits(conversation.history.length))
53
+ throw new QueryInputBudgetError();
54
+ // Search whole-message suffixes with the exact serialized tokenizer contract;
55
+ // never approximate the budget by adding independently tokenized messages.
56
+ let tooLarge = 0, fitting = conversation.history.length;
57
+ while (fitting - tooLarge > 1) {
58
+ const middle = Math.floor((tooLarge + fitting) / 2);
59
+ if (fits(middle))
60
+ fitting = middle;
61
+ else
62
+ tooLarge = middle;
57
63
  }
64
+ conversation.history = conversation.history.slice(fitting);
65
+ return { conversation, contextLimited: true };
58
66
  }
@@ -142,11 +142,11 @@ export async function auditResponses(options) {
142
142
  }
143
143
  }
144
144
  signal.throwIfAborted();
145
- const fresh = reader.read(session, config.responseAudit);
146
- if (!fresh?.episodes.some(candidate => candidate.id === e.id && candidate.inputHash === e.inputHash)) {
145
+ const fresh = reader.read(session, config.responseAudit, snapshot.revision);
146
+ if (!fresh || !("unchanged" in fresh) && !fresh.episodes.some(candidate => candidate.id === e.id && candidate.inputHash === e.inputHash)) {
147
147
  coverage.stale++;
148
148
  store.stale(cohort, e);
149
- if (fresh !== undefined) {
149
+ if (fresh !== undefined && (!fresh || !("unchanged" in fresh))) {
150
150
  store.observe(cohort, session, fresh?.episodes ?? []);
151
151
  store.checkpointForget(cohort, session);
152
152
  }
@@ -1,11 +1,10 @@
1
1
  import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
- import { requestTypeSafe } from "./typesafe-client.js";
3
+ import { requestTypeSafe, TYPESAFE_NOUL_SCHEMA as noul } from "./typesafe-client.js";
4
4
  import { TYPESAFE_REVIEW_MODEL } from "./typesafe-review.js";
5
5
  export const RESPONSE_RUBRIC_VERSION = `${TYPESAFE_REVIEW_MODEL}:response-v10`;
6
6
  export const RESPONSE_STAGE_VERSIONS = { quality: "quality-v9", feedback: "feedback-v9", sentiment: "sentiment-v10", retrospective: "retrospective-v9", memory: "memory-v2-isolated" };
7
7
  const probability = Type.Number({ minimum: 0, maximum: 1 });
8
- const noul = Type.Object({ type: Type.Literal("noul"), noul: probability });
9
8
  const score = Type.Object({ type: Type.Literal("score"), score: Type.Number({ minimum: 0, maximum: 3 }),
10
9
  confidence: probability, probabilities: Type.Object({ "0": probability, "1": probability, "2": probability, "3": probability }) });
11
10
  function choiceSchema(criteria) {
@@ -22,7 +22,8 @@ export class ResponseAuditStore {
22
22
  attempts INTEGER NOT NULL DEFAULT 0, attempted_at INTEGER, assessed_at INTEGER, result TEXT,
23
23
  PRIMARY KEY(cohort,id));
24
24
  CREATE TABLE IF NOT EXISTS response_scans (cohort TEXT PRIMARY KEY, observed_at INTEGER, coverage TEXT);
25
- CREATE INDEX IF NOT EXISTS response_results_time ON response_results(cohort,episode_at);`);
25
+ CREATE INDEX IF NOT EXISTS response_results_time ON response_results(cohort,episode_at);
26
+ CREATE INDEX IF NOT EXISTS response_results_session ON response_results(cohort,session_id,episode_at);`);
26
27
  this.#db.exec(`CREATE TABLE IF NOT EXISTS response_checkpoints (
27
28
  cohort TEXT NOT NULL, session_id TEXT NOT NULL, revision TEXT NOT NULL, coverage TEXT NOT NULL,
28
29
  PRIMARY KEY(cohort,session_id));
@@ -1,4 +1,4 @@
1
- type RetrievalOperation = "vector" | "lexical" | "memoryWhisperer";
1
+ type RetrievalOperation = "vector" | "memoryWhisperer";
2
2
  type RetrievalOutcome = "ok" | "empty" | "failed" | "cancelled" | "timed_out" | "skipped";
3
3
  declare const fields: readonly ["elapsedMs", "retrievalMs", "judgeMs", "gateMs", "generationMs", "candidates", "eligible", "results", "contextChars"];
4
4
  type Field = typeof fields[number];
@@ -2,4 +2,28 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
2
  import type { UnblockMemoryConfig } from "./config.js";
3
3
  import type { QmdMemoryRuntime } from "./runtime.js";
4
4
  import type { WhispererDiagnostics } from "./diagnostics.js";
5
+ import { getContext } from "./tool-context.js";
6
+ /** Bound initialization as well as inference, without cancelling shared managers. */
7
+ export declare function reviewRequestContext(runtime: QmdMemoryRuntime, active: NonNullable<ReturnType<typeof getContext>>, typesafe: UnblockMemoryConfig["typesafe"], signal?: AbortSignal): Promise<{
8
+ readonly status: "unavailable";
9
+ readonly reason: "TypeSafe API key not configured";
10
+ readonly manager?: undefined;
11
+ readonly apiKey?: undefined;
12
+ readonly signal?: undefined;
13
+ readonly timeoutMs?: undefined;
14
+ } | {
15
+ readonly status: "unavailable";
16
+ readonly reason: "Memory manager unavailable";
17
+ readonly manager?: undefined;
18
+ readonly apiKey?: undefined;
19
+ readonly signal?: undefined;
20
+ readonly timeoutMs?: undefined;
21
+ } | {
22
+ readonly status: "ready";
23
+ readonly manager: import("./manager.js").QmdMemoryManager;
24
+ readonly apiKey: string;
25
+ readonly signal: AbortSignal;
26
+ readonly timeoutMs: number;
27
+ reason?: undefined;
28
+ }>;
5
29
  export declare function registerReviewTools(api: OpenClawPluginApi, runtime: QmdMemoryRuntime, config: UnblockMemoryConfig, diagnostics: WhispererDiagnostics): void;
@@ -13,6 +13,18 @@ const claimParameters = Type.Object({
13
13
  }, { additionalProperties: false });
14
14
  const clusterParameters = Type.Object({ clusterId: Type.String({ pattern: "^[0-9a-f]{10}$" }) }, { additionalProperties: false });
15
15
  const noParameters = Type.Object({}, { additionalProperties: false });
16
+ /** Bound initialization as well as inference, without cancelling shared managers. */
17
+ export async function reviewRequestContext(runtime, active, typesafe, signal) {
18
+ const deadline = AbortSignal.timeout(30_000);
19
+ const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
20
+ const apiKey = await abortable(resolveTypeSafeApiKey(typesafe), combined);
21
+ if (!apiKey)
22
+ return { status: "unavailable", reason: "TypeSafe API key not configured" };
23
+ const { manager } = await abortable(runtime.getMemorySearchManager(active), combined);
24
+ if (!manager)
25
+ return { status: "unavailable", reason: "Memory manager unavailable" };
26
+ return { status: "ready", manager, apiKey, signal: combined, timeoutMs: typesafe.timeoutMs };
27
+ }
16
28
  export function registerReviewTools(api, runtime, config, diagnostics) {
17
29
  api.registerTool(ctx => {
18
30
  const active = getContext(ctx);
@@ -59,20 +71,12 @@ export function registerReviewTools(api, runtime, config, diagnostics) {
59
71
  const parsed = Value.Parse(claimParameters, params);
60
72
  if (!config.evidenceReview.enabled || !config.typesafe.enabled)
61
73
  return jsonResult({ status: "disabled" });
62
- const deadline = AbortSignal.timeout(30_000);
63
- const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
64
74
  try {
65
- combined.throwIfAborted();
66
- const apiKey = await abortable(resolveTypeSafeApiKey(config.typesafe), combined);
67
- combined.throwIfAborted();
68
- if (!apiKey)
69
- return jsonResult({ status: "unavailable", reason: "TypeSafe API key not configured" });
70
- const { manager } = await abortable(runtime.getMemorySearchManager(active), combined);
71
- combined.throwIfAborted();
72
- if (!manager)
73
- return jsonResult({ status: "unavailable" });
74
- return jsonResult(await manager.reviewClaim({ ...parsed, corpora: config.evidenceReview.corpora,
75
- apiKey, timeoutMs: config.typesafe.timeoutMs, signal: combined }));
75
+ const context = await reviewRequestContext(runtime, active, config.typesafe, signal);
76
+ if (context.status !== "ready")
77
+ return jsonResult(context);
78
+ return jsonResult(await context.manager.reviewClaim({ ...parsed, corpora: config.evidenceReview.corpora,
79
+ apiKey: context.apiKey, timeoutMs: context.timeoutMs, signal: context.signal }));
76
80
  }
77
81
  catch {
78
82
  return jsonResult({ status: "unavailable", needsReview: true, reason: "Claim review failed or was cancelled; no claim verified" });
@@ -92,20 +96,12 @@ export function registerReviewTools(api, runtime, config, diagnostics) {
92
96
  const parsed = Value.Parse(clusterParameters, params);
93
97
  if (!config.qualityAudit.enabled || !config.typesafe.enabled)
94
98
  return jsonResult({ status: "disabled" });
95
- const deadline = AbortSignal.timeout(30_000);
96
- const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
97
99
  try {
98
- combined.throwIfAborted();
99
- const apiKey = await abortable(resolveTypeSafeApiKey(config.typesafe), combined);
100
- combined.throwIfAborted();
101
- if (!apiKey)
102
- return jsonResult({ status: "unavailable", reason: "TypeSafe API key not configured" });
103
- const { manager } = await abortable(runtime.getMemorySearchManager(active), combined);
104
- combined.throwIfAborted();
105
- if (!manager)
106
- return jsonResult({ status: "unavailable" });
107
- return jsonResult(await manager.reviewCluster({ ...parsed, corpora: config.qualityAudit.corpora,
108
- apiKey, timeoutMs: config.typesafe.timeoutMs, signal: combined }));
100
+ const context = await reviewRequestContext(runtime, active, config.typesafe, signal);
101
+ if (context.status !== "ready")
102
+ return jsonResult(context);
103
+ return jsonResult(await context.manager.reviewCluster({ ...parsed, corpora: config.qualityAudit.corpora,
104
+ apiKey: context.apiKey, timeoutMs: context.timeoutMs, signal: context.signal }));
109
105
  }
110
106
  catch {
111
107
  return jsonResult({ status: "unavailable", reason: "Cluster review failed or was cancelled; no cluster judgment made" });
@@ -109,8 +109,8 @@ export function projectMessage(row, input) {
109
109
  timestamp: timestamp(eventRecord.timestamp) ?? row.createdAt ?? timestamp(message.timestamp) ?? input.startedAt,
110
110
  };
111
111
  }
112
- function formatTimestamp(value, timezone) {
113
- const parts = new Intl.DateTimeFormat("en-CA", {
112
+ function timestampFormatter(timezone) {
113
+ return new Intl.DateTimeFormat("en-CA", {
114
114
  timeZone: timezone,
115
115
  year: "numeric",
116
116
  month: "2-digit",
@@ -120,7 +120,10 @@ function formatTimestamp(value, timezone) {
120
120
  second: "2-digit",
121
121
  hourCycle: "h23",
122
122
  timeZoneName: "short",
123
- }).formatToParts(value);
123
+ });
124
+ }
125
+ function formatTimestamp(value, formatter) {
126
+ const parts = formatter.formatToParts(value);
124
127
  const part = (type) => parts.find((entry) => entry.type === type)?.value ?? "";
125
128
  return `${part("year")}-${part("month")}-${part("day")} ` +
126
129
  `${part("hour")}:${part("minute")}:${part("second")} ${part("timeZoneName")}`.trim();
@@ -169,11 +172,12 @@ export function projectSessionDocument(input) {
169
172
  }
170
173
  let content = "# Transcript\n\n";
171
174
  const spans = [];
175
+ const formatter = timestampFormatter(input.timezone);
172
176
  for (const message of messages.filter(message => !hidden.has(message))) {
173
177
  if (spans.length)
174
178
  content += "\n\n";
175
179
  const start = content.length;
176
- const timestamp = formatTimestamp(message.timestamp, input.timezone);
180
+ const timestamp = formatTimestamp(message.timestamp, formatter);
177
181
  content += `## ${message.role === "user" ? "User" : "Assistant"} — ${message.speaker} — ${timestamp}\n\n`;
178
182
  const bodyStart = content.length;
179
183
  content += message.text;
@@ -2,9 +2,11 @@ import { basename } from "node:path";
2
2
  import { selectTypeSafeSkill } from "./typesafe.js";
3
3
  import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
4
4
  import { buildSkillWhispererQuery, messageText } from "./whisperer-context.js";
5
+ import { abortable } from "./abortable.js";
5
6
  const CANDIDATE_LIMIT = 10;
6
7
  const MAX_QUERY_CHARS = 12_000;
7
8
  const TYPESAFE_CANDIDATE_LIMIT = 3;
9
+ const TOTAL_TIMEOUT_MS = 3000;
8
10
  function isRecord(value) {
9
11
  return value !== null && typeof value === "object" && !Array.isArray(value);
10
12
  }
@@ -56,16 +58,23 @@ export function registerSkillWhisperer(api, runtime, config, typesafe, diagnosti
56
58
  const state = stateFor(scope);
57
59
  if (state.lastRunId === runId)
58
60
  return state.result;
61
+ state.controller?.abort();
62
+ const controller = new AbortController();
63
+ state.controller = controller;
64
+ const { signal } = controller;
65
+ const timer = setTimeout(() => controller.abort(new DOMException("Skill deadline", "TimeoutError")), TOTAL_TIMEOUT_MS);
59
66
  state.lastRunId = runId;
60
67
  state.turn += 1;
61
68
  // Reuse even an in-flight result when the host rebuilds this run's prompt.
62
69
  const run = async () => {
63
70
  try {
64
71
  const runtimeParams = active(agentId);
65
- const apiKey = await resolveTypeSafeApiKey(typesafe);
72
+ const apiKey = await abortable(resolveTypeSafeApiKey(typesafe), signal);
73
+ signal.throwIfAborted();
66
74
  if (!apiKey)
67
75
  diagnostics?.record(agentId, "skill", typesafe.enabled ? "missing_key" : "typesafe_disabled");
68
- const candidates = await runtime.searchSkills(runtimeParams, buildSkillWhispererQuery(event.prompt, event.messages, config.historyMessages), apiKey ? -1 : config.minScore, CANDIDATE_LIMIT);
76
+ const candidates = await abortable(runtime.searchSkills(runtimeParams, buildSkillWhispererQuery(event.prompt, event.messages, config.historyMessages), apiKey ? -1 : config.minScore, CANDIDATE_LIMIT), signal);
77
+ signal.throwIfAborted();
69
78
  const resolvedCandidates = candidates.flatMap((candidate) => {
70
79
  const canonicalPath = runtime.resolveSkillPath(runtimeParams, candidate.path);
71
80
  return canonicalPath ? [{ candidate, canonicalPath }] : [];
@@ -75,19 +84,29 @@ export function registerSkillWhisperer(api, runtime, config, typesafe, diagnosti
75
84
  diagnostics?.record(agentId, "skill", "no_candidates");
76
85
  return;
77
86
  }
87
+ const cooling = (path) => {
88
+ const previous = state.skills.get(path);
89
+ return state.turn - Math.max(previous?.suggested ?? -Infinity, previous?.opened ?? -Infinity) <= config.cooldownTurns;
90
+ };
78
91
  if (apiKey) {
79
92
  const shortlist = resolvedCandidates.slice(0, TYPESAFE_CANDIDATE_LIMIT);
80
- const selectedIndex = await selectTypeSafeSkill({
81
- apiKey, timeoutMs: typesafe.timeoutMs,
93
+ if (shortlist.every(({ canonicalPath }) => cooling(canonicalPath))) {
94
+ diagnostics?.record(agentId, "skill", "cooldown");
95
+ return;
96
+ }
97
+ const selectedIndex = await abortable(selectTypeSafeSkill({
98
+ apiKey, timeoutMs: typesafe.timeoutMs, signal,
82
99
  ...typeSafeConversation(event.prompt, event.messages, config.historyMessages),
83
100
  candidates: shortlist.map(({ candidate }) => candidate),
84
101
  onCandidateFailure: (candidateIndex, error) => {
102
+ if (signal.aborted)
103
+ return;
85
104
  diagnostics?.record(agentId, "skill", "judge_candidate_failed");
86
105
  api.logger.warn("unblock-memory skill_whisperer " + JSON.stringify({ event: "candidate_failed",
87
106
  agentId, runId, candidateIndex, errorCode: error instanceof TypeSafeRequestError ? error.code : "unexpected",
88
107
  httpStatus: error instanceof TypeSafeRequestError ? error.status : undefined }));
89
108
  },
90
- });
109
+ }), signal);
91
110
  if (selectedIndex === undefined) {
92
111
  diagnostics?.record(agentId, "skill", "rejected");
93
112
  return;
@@ -108,9 +127,7 @@ export function registerSkillWhisperer(api, runtime, config, typesafe, diagnosti
108
127
  const { candidate: selected, canonicalPath } = resolved;
109
128
  if (apiKey && runtime.resolveSkillPath(runtimeParams, selected.path) !== canonicalPath)
110
129
  return;
111
- const previous = state.skills.get(canonicalPath);
112
- const lastSeen = Math.max(previous?.suggested ?? -Infinity, previous?.opened ?? -Infinity);
113
- if (state.turn - lastSeen <= config.cooldownTurns) {
130
+ if (cooling(canonicalPath)) {
114
131
  diagnostics?.record(agentId, "skill", "cooldown");
115
132
  return;
116
133
  }
@@ -123,10 +140,16 @@ export function registerSkillWhisperer(api, runtime, config, typesafe, diagnosti
123
140
  };
124
141
  }
125
142
  catch (error) {
126
- diagnostics?.record(agentId, "skill", error instanceof TypeSafeRequestError && error.code === "timeout" ? "timed_out" : "failed");
127
- api.logger.warn("unblock-memory skill whisperer failed; no hint emitted");
143
+ const timedOut = (error instanceof TypeSafeRequestError && error.code === "timeout") ||
144
+ (signal.aborted && signal.reason instanceof Error && signal.reason.name === "TimeoutError");
145
+ diagnostics?.record(agentId, "skill", timedOut ? "timed_out" : signal.aborted ? "cancelled" : "failed");
146
+ if (!signal.aborted)
147
+ api.logger.warn("unblock-memory skill whisperer failed; no hint emitted");
128
148
  return;
129
149
  }
150
+ finally {
151
+ clearTimeout(timer);
152
+ }
130
153
  };
131
154
  state.result = run();
132
155
  return state.result;
@@ -153,11 +176,17 @@ export function registerSkillWhisperer(api, runtime, config, typesafe, diagnosti
153
176
  }
154
177
  }, { matcher: ["read"] });
155
178
  api.on("session_end", (event, context) => {
156
- sessions.delete(event.sessionId);
157
- if (event.sessionKey)
158
- sessions.delete(event.sessionKey);
159
- if (context.sessionKey)
160
- sessions.delete(context.sessionKey);
179
+ for (const scope of [event.sessionId, event.sessionKey, context.sessionKey]) {
180
+ if (!scope)
181
+ continue;
182
+ sessions.get(scope)?.controller?.abort();
183
+ sessions.delete(scope);
184
+ }
185
+ });
186
+ api.on("gateway_stop", () => {
187
+ for (const state of sessions.values())
188
+ state.controller?.abort();
189
+ sessions.clear();
161
190
  });
162
191
  return beforePrompt;
163
192
  }
@@ -1,22 +1,21 @@
1
1
  import type { QMDStore } from "@unblocklabs/qmd";
2
2
  import type { QueryLane } from "./query-contract.js";
3
- type Candidate = {
4
- file: string;
5
- body: string;
6
- bestChunk: string;
7
- bestChunkPos: number;
8
- score: number;
9
- explain: {
10
- methods: string[];
11
- };
3
+ /** Runtime and training share the exact ten-candidate recipe, not hybrid ranking. */
4
+ export declare function trainingCandidates(qmd: QMDStore, query: string, collection: string | string[], lane: QueryLane, signal?: AbortSignal): Promise<{
12
5
  vector?: {
13
6
  score: number;
14
7
  rank: number;
15
- };
8
+ } | undefined;
16
9
  bm25?: {
17
10
  score: number;
18
11
  rank: number;
12
+ } | undefined;
13
+ file: string;
14
+ body: string;
15
+ bestChunk: string;
16
+ bestChunkPos: number;
17
+ score: number;
18
+ explain: {
19
+ methods: ("vector" | "bm25")[];
19
20
  };
20
- };
21
- export declare function trainingCandidates(qmd: QMDStore, query: string, collection: string | string[], lane: QueryLane, signal?: AbortSignal): Promise<Candidate[]>;
22
- export {};
21
+ }[]>;
@@ -1,83 +1,13 @@
1
- import { randomUUID } from "node:crypto";
2
- const stopWords = new Set("a an and are as at be by can did do does for from how i in is it of on or that the their this to was were what when where which who why will with you".split(" "));
3
- function queryTerms(query) {
4
- const words = [...new Set(query.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [])];
5
- const meaningful = words.filter(word => !stopWords.has(word));
6
- return (meaningful.length ? meaningful : words).slice(0, 64);
7
- }
8
- function lexicalChunk(chunks, body, highlighted, marker, intent) {
9
- const compactLength = (text) => text.replace(/\s/gu, "").length;
10
- const ranges = [];
11
- let offset = 0;
12
- for (const [i, part] of highlighted.split(marker).entries()) {
13
- const end = offset + compactLength(part);
14
- if (i % 2 === 1)
15
- ranges.push({ start: offset, end });
16
- offset = end;
17
- }
18
- const intentTerms = queryTerms(intent);
19
- let sourcePos = 0, compactPos = 0;
20
- return chunks.map(chunk => {
21
- compactPos += compactLength(body.slice(sourcePos, chunk.pos));
22
- sourcePos = chunk.pos;
23
- const end = compactPos + compactLength(chunk.text);
24
- const matches = ranges.reduce((sum, range) => sum + Math.max(0, Math.min(end, range.end) - Math.max(compactPos, range.start)) / Math.max(1, range.end - range.start), 0);
25
- const lower = chunk.text.toLowerCase();
26
- return { chunk, matches, intentMatches: intentTerms.filter(term => lower.includes(term)).length };
27
- }).sort((a, b) => b.matches - a.matches || b.intentMatches - a.intentMatches || a.chunk.pos - b.chunk.pos)[0]?.chunk;
28
- }
1
+ /** Runtime and training share the exact ten-candidate recipe, not hybrid ranking. */
29
2
  export async function trainingCandidates(qmd, query, collection, lane, signal) {
30
- signal?.throwIfAborted();
31
- if (!query.trim() || query.length > 12_000)
32
- throw new Error("Invalid training query");
33
- // QMD exposes its store but not these chunk helpers at the package root.
34
- // Resolve relative to its installed SDK, never a global QMD or modified copy.
35
- const chunksApi = await import(new URL("./store.js", import.meta.resolve("@unblocklabs/qmd")).href);
36
- const candidates = new Map();
37
- const add = (hit, method, rank, rawScore) => {
38
- if (!hit.bestChunk.trim() || hit.bestChunk.length > 12_000)
39
- return;
40
- const key = JSON.stringify([hit.file, hit.bestChunk.trim()]), existing = candidates.get(key);
41
- if (existing) {
42
- if (!existing.explain.methods.includes(method))
43
- existing.explain.methods.push(method);
44
- existing.score = Math.max(existing.score, 1 / (rank + 1));
45
- existing[method] ??= { score: rawScore, rank: rank + 1 };
46
- }
47
- else
48
- candidates.set(key, { ...hit, score: 1 / (rank + 1), explain: { methods: [method] },
49
- [method]: { score: rawScore, rank: rank + 1 } });
50
- };
51
- if (lane === "vec") {
52
- const vectors = await qmd.searchVector(query, { limit: 10, collection });
53
- signal?.throwIfAborted();
54
- for (const [rank, hit] of vectors.entries()) {
55
- const pos = hit.chunkPos, len = hit.chunkLen, body = hit.body ?? "";
56
- if (pos === undefined || len === undefined || pos < 0 || len <= 0 || pos + len > body.length)
57
- continue;
58
- add({ file: hit.filepath, body, bestChunk: body.slice(pos, pos + len), bestChunkPos: pos }, "vector", rank, hit.score);
59
- }
60
- return [...candidates.values()];
61
- }
62
- const expression = queryTerms(query).map(term => `"${chunksApi.normalizeCjkForFTS(term).trim()}"`).join(" OR ");
63
- if (expression) {
64
- const marker = `qmd-match-${randomUUID()}`;
65
- const rows = qmd.internal.db.prepare(`SELECT d.collection,d.path,d.hash,c.doc,
66
- bm25(documents_fts,1.5,4.0,1.0) AS rank, highlight(documents_fts,2,?,?) AS highlighted
67
- FROM documents_fts JOIN documents d ON d.id=documents_fts.rowid JOIN content c ON c.hash=d.hash
68
- WHERE documents_fts MATCH ? AND d.active=1 AND d.collection IN (SELECT value FROM json_each(?))
69
- ORDER BY rank,d.collection,d.path LIMIT 10`).all(marker, marker, expression, JSON.stringify(typeof collection === "string" ? [collection] : collection));
70
- for (const [rank, row] of rows.entries()) {
71
- signal?.throwIfAborted();
72
- const file = `qmd://${row.collection}/${row.path}`;
73
- const stored = chunksApi.getStoredChunkSpans(qmd.internal.db, row.hash)
74
- .filter(span => span.pos >= 0 && span.chunk_len > 0 && span.pos + span.chunk_len <= row.doc.length)
75
- .map(span => ({ pos: span.pos, text: row.doc.slice(span.pos, span.pos + span.chunk_len) }));
76
- const chunks = stored.length ? stored : await chunksApi.chunkDocumentAsync(row.doc, undefined, undefined, undefined, file);
77
- const selected = lexicalChunk(chunks, row.doc, row.highlighted, marker, query);
78
- if (selected)
79
- add({ file, body: row.doc, bestChunk: selected.text, bestChunkPos: selected.pos }, "bm25", rank, row.rank);
80
- }
81
- }
82
- return [...candidates.values()].sort((a, b) => b.score - a.score);
3
+ const hits = await qmd.discoverCandidates({ query, lane, collection, limit: 10, signal,
4
+ // Preserve keyword chunk tie-breaking against this lane's own query.
5
+ intent: query });
6
+ return hits.map(({ method, rank, score, file, body, bestChunk, bestChunkPos }) => {
7
+ const retrieval = {
8
+ [method]: { score, rank: rank + 1 },
9
+ };
10
+ return { file, body, bestChunk, bestChunkPos, score: 1 / (rank + 1),
11
+ explain: { methods: [method] }, ...retrieval };
12
+ });
83
13
  }
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
- import { requestTypeSafe, TYPESAFE_MODEL, TypeSafeRequestError } from "./typesafe-client.js";
3
+ import { requestTypeSafe, TYPESAFE_MODEL, TYPESAFE_NOUL_SCHEMA, TypeSafeRequestError } from "./typesafe-client.js";
4
4
  export const TRAINING_GATE_VERSION = "historical-recall-v2";
5
5
  export const TRAINING_GATE_MODEL = TYPESAFE_MODEL;
6
6
  export const TRAINING_GATE_THRESHOLD = 0.7;
@@ -27,7 +27,7 @@ export const TRAINING_GATE_QUESTIONS = { recall_needed: {
27
27
  } };
28
28
  const resultSchema = Type.Object({
29
29
  model: Type.Literal(TRAINING_GATE_MODEL),
30
- answers: Type.Object({ recall_needed: Type.Object({ type: Type.Literal("noul"), noul: Type.Number({ minimum: 0, maximum: 1 }) }) }),
30
+ answers: Type.Object({ recall_needed: TYPESAFE_NOUL_SCHEMA }),
31
31
  usage: Type.Object({ input_tokens: Type.Integer({ minimum: 0 }), output_tokens: Type.Integer({ minimum: 0 }) }),
32
32
  });
33
33
  export async function judgeTrainingInput(input, apiKey, signal) {