@unblocklabs/unblock-memory 0.7.1 → 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.
- package/dist/src/analysis.js +11 -12
- package/dist/src/contracts.d.ts +1 -1
- package/dist/src/extraction-chunk.d.ts +1 -0
- package/dist/src/extraction-chunk.js +1 -0
- package/dist/src/extraction-model.js +5 -4
- package/dist/src/extraction-runtime.js +2 -7
- package/dist/src/extraction-source.js +6 -2
- package/dist/src/extraction-store.d.ts +3 -0
- package/dist/src/extraction-store.js +14 -10
- package/dist/src/extraction-worker.js +31 -15
- package/dist/src/manager.d.ts +1 -1
- package/dist/src/manager.js +63 -79
- package/dist/src/memory-whisperer.js +6 -10
- package/dist/src/people-primer.js +6 -8
- package/dist/src/people-store.js +2 -0
- package/dist/src/people-tools.js +2 -6
- package/dist/src/plugin.js +6 -14
- package/dist/src/query-contract.js +18 -10
- package/dist/src/response-audit.js +3 -3
- package/dist/src/response-judge.js +1 -2
- package/dist/src/response-store.js +2 -1
- package/dist/src/retrieval-telemetry.d.ts +1 -1
- package/dist/src/review-tools.d.ts +24 -0
- package/dist/src/review-tools.js +22 -26
- package/dist/src/session-projector.js +8 -4
- package/dist/src/skill-whisperer.js +44 -15
- package/dist/src/training-candidates.d.ts +12 -13
- package/dist/src/training-candidates.js +11 -81
- package/dist/src/training-gate.js +2 -2
- package/dist/src/training-queries.js +5 -2
- package/dist/src/training-retrieval.d.ts +15 -2
- package/dist/src/training-retrieval.js +63 -39
- package/dist/src/typesafe-client.d.ts +5 -0
- package/dist/src/typesafe-client.js +4 -0
- package/dist/src/typesafe-review.js +4 -6
- package/dist/src/typesafe.d.ts +1 -9
- package/dist/src/typesafe.js +4 -10
- package/docs/configuration.md +3 -2
- package/docs/retrieval.md +15 -1
- package/openclaw.plugin.json +2 -2
- 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
|
|
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)
|
|
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
|
-
|
|
123
|
-
|
|
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,
|
package/dist/src/people-store.js
CHANGED
|
@@ -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();
|
package/dist/src/people-tools.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
264
|
+
const active = getContext(ctx);
|
|
269
265
|
if (!active)
|
|
270
266
|
return null;
|
|
271
267
|
return {
|
package/dist/src/plugin.js
CHANGED
|
@@ -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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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:
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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" | "
|
|
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;
|
package/dist/src/review-tools.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
113
|
-
|
|
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
|
-
})
|
|
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,
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
sessions.delete(
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
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) {
|