@gmickel/gno 1.40.0 → 1.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/assets/skill/SKILL.md +22 -1
- package/assets/skill/cli-reference.md +48 -0
- package/assets/skill/mcp-reference.md +31 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.40.0.zip → gno-browser-clipper-v1.42.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.42.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +3 -2
- package/spec/cli.md +146 -7
- package/spec/db/schema.sql +17 -0
- package/spec/mcp.md +350 -6
- package/spec/output-schemas/memory-recall.schema.json +159 -0
- package/spec/output-schemas/memory-remember.schema.json +164 -0
- package/spec/output-schemas/status.schema.json +269 -54
- package/src/cli/commands/daemon.ts +1 -0
- package/src/cli/commands/mcp.ts +3 -1
- package/src/cli/commands/memory.ts +491 -0
- package/src/cli/commands/status.ts +23 -4
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +155 -0
- package/src/config/types.ts +10 -0
- package/src/core/audit-provenance.ts +91 -0
- package/src/core/audit-workspace.ts +17 -0
- package/src/core/connector-verifier.ts +2 -4
- package/src/core/memory-diagnostics.ts +144 -0
- package/src/core/memory-fence.ts +239 -0
- package/src/core/memory-recall.ts +269 -0
- package/src/core/memory-record.ts +435 -0
- package/src/core/memory-remember.ts +425 -0
- package/src/core/memory-types.ts +211 -0
- package/src/core/memory.ts +87 -0
- package/src/ingestion/sync.ts +17 -0
- package/src/mcp/AGENTS.md +7 -1
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/context.ts +37 -7
- package/src/mcp/http-egress.ts +2 -0
- package/src/mcp/http-modern.ts +214 -0
- package/src/mcp/http-security.ts +5 -0
- package/src/mcp/http-session.ts +4 -3
- package/src/mcp/http-transport.ts +81 -12
- package/src/mcp/resources/index.ts +3 -6
- package/src/mcp/server.ts +18 -16
- package/src/mcp/stdio-serving.ts +45 -0
- package/src/mcp/tool-descriptions-core.ts +56 -0
- package/src/mcp/tool-profile.ts +112 -0
- package/src/mcp/tools/index.ts +286 -126
- package/src/mcp/tools/memory-recall.ts +122 -0
- package/src/mcp/tools/memory-remember.ts +177 -0
- package/src/mcp/tools/memory-shared.ts +86 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +8 -0
- package/src/sdk/client.ts +94 -1
- package/src/sdk/index.ts +13 -0
- package/src/sdk/types.ts +28 -0
- package/src/serve/routes/api.ts +167 -0
- package/src/serve/routes/mcp.ts +1 -0
- package/src/serve/server.ts +27 -0
- package/src/store/migrations/027-memory-scopes.ts +37 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +127 -3
- package/src/store/types.ts +54 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.40.0.zip.sha256 +0 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory input validation, the context fence, receipts, and record
|
|
3
|
+
* materialization shared by remember and recall.
|
|
4
|
+
*
|
|
5
|
+
* Fencing limits (documented contract): the fence rejects (a) input whose
|
|
6
|
+
* normalized-text hash matches a span hash on a presented recall receipt and
|
|
7
|
+
* (b) input declaring `derivedFrom` gno:// origins. A paraphrase of recalled
|
|
8
|
+
* text that carries neither is indistinguishable from an original fact and
|
|
9
|
+
* cannot be fenced.
|
|
10
|
+
*
|
|
11
|
+
* @module src/core/memory-fence
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Collection } from "../config/types";
|
|
15
|
+
import type { DocumentRow, StorePort } from "../store/types";
|
|
16
|
+
import type {
|
|
17
|
+
MemoryDecision,
|
|
18
|
+
MemoryFact,
|
|
19
|
+
MemoryIdentity,
|
|
20
|
+
MemoryRecallReceipt,
|
|
21
|
+
MemoryServiceDeps,
|
|
22
|
+
RememberInput,
|
|
23
|
+
} from "./memory-types";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
invalidMemoryScopeReason,
|
|
27
|
+
MEMORY_MAX_FACT_BYTES,
|
|
28
|
+
MEMORY_MAX_SCOPES,
|
|
29
|
+
normalizeMemoryScopes,
|
|
30
|
+
validateMemoryRecord,
|
|
31
|
+
} from "./memory-record";
|
|
32
|
+
import {
|
|
33
|
+
MEMORY_DEFAULT_LOCK_WAIT_MS,
|
|
34
|
+
MEMORY_TOKEN_BYTES_ESTIMATE,
|
|
35
|
+
MemoryError,
|
|
36
|
+
} from "./memory-types";
|
|
37
|
+
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
// Service context helpers
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export function memoryNow(deps: MemoryServiceDeps): Date {
|
|
43
|
+
return deps.now?.() ?? new Date();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function memoryLockWaitMs(deps: MemoryServiceDeps): number {
|
|
47
|
+
return deps.lockWaitMs ?? MEMORY_DEFAULT_LOCK_WAIT_MS;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function utf8Bytes(text: string): number {
|
|
51
|
+
return new TextEncoder().encode(text).byteLength;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function estimateTokens(text: string): number {
|
|
55
|
+
return Math.ceil(utf8Bytes(text) / MEMORY_TOKEN_BYTES_ESTIMATE);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function compareCodeUnits(left: string, right: string): number {
|
|
59
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
// Validation helpers
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export function requireIdentity(input: MemoryIdentity): MemoryIdentity {
|
|
67
|
+
const caller = input.caller?.trim();
|
|
68
|
+
const session = input.session?.trim();
|
|
69
|
+
if (!caller || !session) {
|
|
70
|
+
throw new MemoryError(
|
|
71
|
+
"MEMORY_IDENTITY_REQUIRED",
|
|
72
|
+
"caller and session identity are required on every remember/recall call (receipts bind to them)."
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return { caller, session };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function requireScopes(scopes: unknown): string[] {
|
|
79
|
+
if (!Array.isArray(scopes) || scopes.some((s) => typeof s !== "string")) {
|
|
80
|
+
throw new MemoryError(
|
|
81
|
+
"MEMORY_SCOPES_REQUIRED",
|
|
82
|
+
"Explicit scopes are required; there is no implicit global scope."
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const normalized = normalizeMemoryScopes(scopes as string[]);
|
|
86
|
+
if (normalized.length === 0) {
|
|
87
|
+
throw new MemoryError(
|
|
88
|
+
"MEMORY_SCOPES_REQUIRED",
|
|
89
|
+
"Explicit scopes are required; there is no implicit global scope."
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (normalized.length > MEMORY_MAX_SCOPES) {
|
|
93
|
+
throw new MemoryError(
|
|
94
|
+
"MEMORY_SCOPES_INVALID",
|
|
95
|
+
`At most ${MEMORY_MAX_SCOPES} scopes per call.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
for (const scope of normalized) {
|
|
99
|
+
const reason = invalidMemoryScopeReason(scope);
|
|
100
|
+
if (reason) throw new MemoryError("MEMORY_SCOPES_INVALID", reason);
|
|
101
|
+
}
|
|
102
|
+
return normalized;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function requireManagedCollection(
|
|
106
|
+
collections: readonly Collection[],
|
|
107
|
+
name: string | undefined
|
|
108
|
+
): Collection {
|
|
109
|
+
const wanted = name?.trim().toLowerCase();
|
|
110
|
+
if (!wanted) {
|
|
111
|
+
throw new MemoryError(
|
|
112
|
+
"MEMORY_COLLECTION_REQUIRED",
|
|
113
|
+
"A memory collection is required."
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const collection = collections.find(
|
|
117
|
+
(candidate) => candidate.name.toLowerCase() === wanted
|
|
118
|
+
);
|
|
119
|
+
if (!collection) {
|
|
120
|
+
throw new MemoryError(
|
|
121
|
+
"MEMORY_COLLECTION_NOT_FOUND",
|
|
122
|
+
`Collection not found: ${wanted}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (collection.memoryManaged !== true) {
|
|
126
|
+
throw new MemoryError(
|
|
127
|
+
"MEMORY_COLLECTION_UNMANAGED",
|
|
128
|
+
`Collection "${collection.name}" is not memory-managed. Set memoryManaged: true on it in the config to allow remember/recall; other collections stay read-only for memory.`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return collection;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function requireFactText(text: unknown): string {
|
|
135
|
+
if (typeof text !== "string" || text.trim().length === 0) {
|
|
136
|
+
throw new MemoryError("MEMORY_TEXT_REQUIRED", "Fact text is required.");
|
|
137
|
+
}
|
|
138
|
+
if (text.includes("\0")) {
|
|
139
|
+
throw new MemoryError(
|
|
140
|
+
"MEMORY_TEXT_REQUIRED",
|
|
141
|
+
"Fact text must be text, not binary-like data."
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (utf8Bytes(text) > MEMORY_MAX_FACT_BYTES) {
|
|
145
|
+
throw new MemoryError(
|
|
146
|
+
"MEMORY_TEXT_TOO_LARGE",
|
|
147
|
+
`Fact text exceeds ${MEMORY_MAX_FACT_BYTES} bytes; remember stores single facts, not documents (use gno capture).`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return text.trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function requireDecision(decision: unknown): MemoryDecision | undefined {
|
|
154
|
+
if (decision === undefined || decision === null) return undefined;
|
|
155
|
+
if (decision === "add" || decision === "supersede") return decision;
|
|
156
|
+
throw new MemoryError(
|
|
157
|
+
"MEMORY_DECISION_INVALID",
|
|
158
|
+
'decision must be omitted, "add", or "supersede".'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
// Fence and receipts
|
|
164
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
export function applyFence(input: RememberInput, spanHash: string): void {
|
|
167
|
+
if (input.receipt?.spanHashes?.includes(spanHash)) {
|
|
168
|
+
throw new MemoryError(
|
|
169
|
+
"MEMORY_FENCED_REPLAY",
|
|
170
|
+
"Rejected: this text replays a span from the presented recall receipt. Recalled memories are context, not new facts."
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const derived = (input.derivedFrom ?? []).filter((origin) =>
|
|
174
|
+
origin.trim().startsWith("gno://")
|
|
175
|
+
);
|
|
176
|
+
if (derived.length > 0) {
|
|
177
|
+
throw new MemoryError(
|
|
178
|
+
"MEMORY_FENCED_DERIVED",
|
|
179
|
+
`Rejected: input declares GNO-derived origin (${derived.join(", ")}). Facts derived from GNO's own output are not stored.`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Content-free receipt: identity, memory ids, span hashes, and a digest. */
|
|
185
|
+
export function buildRecallReceipt(input: {
|
|
186
|
+
identity: MemoryIdentity;
|
|
187
|
+
issuedAt: string;
|
|
188
|
+
memoryIds: string[];
|
|
189
|
+
spanHashes: string[];
|
|
190
|
+
}): MemoryRecallReceipt {
|
|
191
|
+
const { identity, issuedAt, memoryIds, spanHashes } = input;
|
|
192
|
+
return {
|
|
193
|
+
caller: identity.caller,
|
|
194
|
+
session: identity.session,
|
|
195
|
+
issuedAt,
|
|
196
|
+
memoryIds,
|
|
197
|
+
spanHashes,
|
|
198
|
+
digest: new Bun.CryptoHasher("sha256")
|
|
199
|
+
.update(
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
caller: identity.caller,
|
|
202
|
+
session: identity.session,
|
|
203
|
+
issuedAt,
|
|
204
|
+
memoryIds,
|
|
205
|
+
spanHashes,
|
|
206
|
+
})
|
|
207
|
+
)
|
|
208
|
+
.digest("hex"),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
// Record materialization
|
|
214
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
export async function readFact(
|
|
217
|
+
store: StorePort,
|
|
218
|
+
doc: Pick<DocumentRow, "uri" | "docid" | "mirrorHash">
|
|
219
|
+
): Promise<MemoryFact | null> {
|
|
220
|
+
if (!doc.mirrorHash) return null;
|
|
221
|
+
const content = await store.getContent(doc.mirrorHash);
|
|
222
|
+
if (!content.ok || content.value === null) return null;
|
|
223
|
+
const validation = validateMemoryRecord(content.value);
|
|
224
|
+
if (!validation.ok) return null;
|
|
225
|
+
const { frontmatter, supersedes, text } = validation.record;
|
|
226
|
+
return {
|
|
227
|
+
uri: doc.uri,
|
|
228
|
+
docid: doc.docid,
|
|
229
|
+
recordId: frontmatter.recordId,
|
|
230
|
+
text,
|
|
231
|
+
scopes: frontmatter.scopes,
|
|
232
|
+
caller: frontmatter.caller,
|
|
233
|
+
session: frontmatter.session,
|
|
234
|
+
createdAt: frontmatter.createdAt,
|
|
235
|
+
contentHash: frontmatter.contentHash,
|
|
236
|
+
supersedes,
|
|
237
|
+
...(frontmatter.source ? { source: frontmatter.source } : {}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `recall()`: scoped hybrid retrieval, reciprocal-rank fusion, budgeting,
|
|
3
|
+
* and the content-free receipt.
|
|
4
|
+
*
|
|
5
|
+
* @module src/core/memory-recall
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SearchResult } from "../pipeline/types";
|
|
9
|
+
import type {
|
|
10
|
+
MemoryServiceDeps,
|
|
11
|
+
RecalledFact,
|
|
12
|
+
RecallInput,
|
|
13
|
+
RecallResult,
|
|
14
|
+
} from "./memory-types";
|
|
15
|
+
|
|
16
|
+
import { searchBm25 } from "../pipeline/search";
|
|
17
|
+
import { searchVectorWithEmbedding } from "../pipeline/vsearch";
|
|
18
|
+
import { selectContextEvidence } from "./context-budget";
|
|
19
|
+
import { mergeEgressLineages } from "./egress-provenance";
|
|
20
|
+
import {
|
|
21
|
+
buildRecallReceipt,
|
|
22
|
+
compareCodeUnits,
|
|
23
|
+
estimateTokens,
|
|
24
|
+
memoryNow,
|
|
25
|
+
readFact,
|
|
26
|
+
requireIdentity,
|
|
27
|
+
requireManagedCollection,
|
|
28
|
+
requireScopes,
|
|
29
|
+
utf8Bytes,
|
|
30
|
+
} from "./memory-fence";
|
|
31
|
+
import {
|
|
32
|
+
MEMORY_EMPTY_RECALL_HINT,
|
|
33
|
+
MEMORY_RECALL_MAX_FACTS,
|
|
34
|
+
MEMORY_RECALL_MAX_TOKENS,
|
|
35
|
+
MEMORY_RECALL_RETRIEVAL_LIMIT,
|
|
36
|
+
MEMORY_RRF_K,
|
|
37
|
+
MEMORY_TOKEN_BYTES_ESTIMATE,
|
|
38
|
+
MemoryError,
|
|
39
|
+
} from "./memory-types";
|
|
40
|
+
|
|
41
|
+
type RetrievalLeg = { source: "bm25" | "vector"; results: SearchResult[] };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Retrieval legs: BM25 always; vectors when an embedding port and a searchable
|
|
45
|
+
* vector index are present. The eligible set is one unbounded in-query
|
|
46
|
+
* scope+supersession filter; the vector leg only ever ranks inside it.
|
|
47
|
+
*/
|
|
48
|
+
async function retrieveLegs(
|
|
49
|
+
deps: MemoryServiceDeps,
|
|
50
|
+
input: { query: string; collection: string; scopes: string[] }
|
|
51
|
+
): Promise<{ legs: RetrievalLeg[]; retrieval: RecallResult["retrieval"] }> {
|
|
52
|
+
const { store, config } = deps;
|
|
53
|
+
const { query, collection, scopes } = input;
|
|
54
|
+
const legs: RetrievalLeg[] = [];
|
|
55
|
+
const bm25 = await searchBm25(store, query, {
|
|
56
|
+
collection,
|
|
57
|
+
limit: MEMORY_RECALL_RETRIEVAL_LIMIT,
|
|
58
|
+
memoryFilter: { scopes, excludeSuperseded: true },
|
|
59
|
+
});
|
|
60
|
+
if (!bm25.ok) {
|
|
61
|
+
if (bm25.error.code !== "INVALID_INPUT") {
|
|
62
|
+
throw new MemoryError("MEMORY_QUERY_FAILED", bm25.error.message);
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
legs.push({ source: "bm25", results: bm25.value.results });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const retrieval: RecallResult["retrieval"] = { mode: "lexical" };
|
|
69
|
+
const embedPort = deps.embedPort ?? null;
|
|
70
|
+
const vectorIndex = deps.vectorIndex ?? null;
|
|
71
|
+
if (!embedPort || !vectorIndex?.searchAvailable) {
|
|
72
|
+
retrieval.semanticUnavailable = embedPort
|
|
73
|
+
? "vector index unavailable"
|
|
74
|
+
: "no embedding model available";
|
|
75
|
+
return { legs, retrieval };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const eligible = await store.listMemoryEligibleDocuments({
|
|
79
|
+
collection,
|
|
80
|
+
scopes,
|
|
81
|
+
excludeSuperseded: true,
|
|
82
|
+
});
|
|
83
|
+
if (!eligible.ok) {
|
|
84
|
+
throw new MemoryError("MEMORY_QUERY_FAILED", eligible.error.message);
|
|
85
|
+
}
|
|
86
|
+
const allowedMirrorHashes = [
|
|
87
|
+
...new Set(eligible.value.map((row) => row.mirrorHash)),
|
|
88
|
+
];
|
|
89
|
+
if (allowedMirrorHashes.length === 0) {
|
|
90
|
+
retrieval.mode = "hybrid";
|
|
91
|
+
return { legs, retrieval };
|
|
92
|
+
}
|
|
93
|
+
const embedded = await embedPort.embed(query);
|
|
94
|
+
if (!embedded.ok) {
|
|
95
|
+
retrieval.semanticUnavailable = embedded.error.message;
|
|
96
|
+
return { legs, retrieval };
|
|
97
|
+
}
|
|
98
|
+
const vector = await searchVectorWithEmbedding(
|
|
99
|
+
{ store, vectorIndex, embedPort, config },
|
|
100
|
+
query,
|
|
101
|
+
new Float32Array(embedded.value),
|
|
102
|
+
{
|
|
103
|
+
collection,
|
|
104
|
+
limit: MEMORY_RECALL_RETRIEVAL_LIMIT,
|
|
105
|
+
retrievalScope: { allowedMirrorHashes },
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
if (vector.ok) {
|
|
109
|
+
legs.push({ source: "vector", results: vector.value.results });
|
|
110
|
+
retrieval.mode = "hybrid";
|
|
111
|
+
} else {
|
|
112
|
+
retrieval.semanticUnavailable = vector.error.message;
|
|
113
|
+
}
|
|
114
|
+
return { legs, retrieval };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Reciprocal-rank fusion keyed by URI (fact identity), deterministic ties. */
|
|
118
|
+
function fuseLegs(legs: RetrievalLeg[]): SearchResult[] {
|
|
119
|
+
const fused = new Map<string, { result: SearchResult; score: number }>();
|
|
120
|
+
for (const leg of legs) {
|
|
121
|
+
for (const [index, result] of leg.results.entries()) {
|
|
122
|
+
const entry = fused.get(result.uri) ?? { result, score: 0 };
|
|
123
|
+
entry.score += 1 / (MEMORY_RRF_K + index + 1);
|
|
124
|
+
fused.set(result.uri, entry);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return [...fused.values()]
|
|
128
|
+
.sort(
|
|
129
|
+
(left, right) =>
|
|
130
|
+
right.score - left.score ||
|
|
131
|
+
compareCodeUnits(left.result.uri, right.result.uri)
|
|
132
|
+
)
|
|
133
|
+
.map((entry) => ({ ...entry.result, score: entry.score }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function materializeFacts(
|
|
137
|
+
deps: MemoryServiceDeps,
|
|
138
|
+
ranked: SearchResult[]
|
|
139
|
+
): Promise<Array<{ fact: RecalledFact; rank: number }>> {
|
|
140
|
+
const materialized: Array<{ fact: RecalledFact; rank: number }> = [];
|
|
141
|
+
for (const [index, result] of ranked.entries()) {
|
|
142
|
+
if (!result.conversion?.mirrorHash || !result.egressLineage) continue;
|
|
143
|
+
const fact = await readFact(deps.store, {
|
|
144
|
+
uri: result.uri,
|
|
145
|
+
docid: result.docid,
|
|
146
|
+
mirrorHash: result.conversion.mirrorHash,
|
|
147
|
+
});
|
|
148
|
+
if (!fact) continue;
|
|
149
|
+
materialized.push({
|
|
150
|
+
rank: index + 1,
|
|
151
|
+
fact: {
|
|
152
|
+
...fact,
|
|
153
|
+
score: result.score,
|
|
154
|
+
spanHash: fact.contentHash,
|
|
155
|
+
egressLineage: result.egressLineage,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return materialized;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Token budget via the shared context-evidence selector, then the fact cap. */
|
|
163
|
+
function selectWithinBudget(
|
|
164
|
+
materialized: Array<{ fact: RecalledFact; rank: number }>,
|
|
165
|
+
maxFacts: number,
|
|
166
|
+
maxTokens: number
|
|
167
|
+
): RecalledFact[] {
|
|
168
|
+
const selection = selectContextEvidence({
|
|
169
|
+
candidates: materialized.map(({ fact, rank }) => ({
|
|
170
|
+
candidateId: fact.uri,
|
|
171
|
+
uri: fact.uri,
|
|
172
|
+
docid: fact.docid,
|
|
173
|
+
startLine: 1,
|
|
174
|
+
endLine: 1,
|
|
175
|
+
passageHash: fact.contentHash,
|
|
176
|
+
sourceHash: fact.contentHash,
|
|
177
|
+
mirrorHash: fact.contentHash,
|
|
178
|
+
text: fact.text,
|
|
179
|
+
facets: [fact.uri],
|
|
180
|
+
retrievalRank: rank,
|
|
181
|
+
value: fact,
|
|
182
|
+
})),
|
|
183
|
+
requestedFacets: materialized.map(({ fact }) => fact.uri),
|
|
184
|
+
limits: {
|
|
185
|
+
requestedBytes: maxTokens * MEMORY_TOKEN_BYTES_ESTIMATE,
|
|
186
|
+
requestedTokens: maxTokens,
|
|
187
|
+
safetyMarginBytes: 0,
|
|
188
|
+
safetyMarginTokens: 0,
|
|
189
|
+
documentShareNumerator: 1,
|
|
190
|
+
documentShareDenominator: 1,
|
|
191
|
+
},
|
|
192
|
+
projectCanonical: (state) => {
|
|
193
|
+
const texts = state.selected.map((item) => item.value.text);
|
|
194
|
+
return {
|
|
195
|
+
value: texts,
|
|
196
|
+
usedBytes: texts.reduce((sum, item) => sum + utf8Bytes(item), 0),
|
|
197
|
+
usedTokens: texts.reduce((sum, item) => sum + estimateTokens(item), 0),
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
return selection.selected.slice(0, maxFacts).map((item) => item.value);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function recallFacts(
|
|
205
|
+
deps: MemoryServiceDeps,
|
|
206
|
+
rawInput: RecallInput
|
|
207
|
+
): Promise<RecallResult> {
|
|
208
|
+
const identity = requireIdentity(rawInput);
|
|
209
|
+
const query = rawInput.query?.trim();
|
|
210
|
+
if (!query) {
|
|
211
|
+
throw new MemoryError("MEMORY_QUERY_REQUIRED", "A query is required.");
|
|
212
|
+
}
|
|
213
|
+
const collection = requireManagedCollection(
|
|
214
|
+
deps.collections,
|
|
215
|
+
rawInput.collection
|
|
216
|
+
);
|
|
217
|
+
const scopes = requireScopes(rawInput.scopes);
|
|
218
|
+
const maxFacts = rawInput.maxFacts ?? MEMORY_RECALL_MAX_FACTS;
|
|
219
|
+
const maxTokens = rawInput.maxTokens ?? MEMORY_RECALL_MAX_TOKENS;
|
|
220
|
+
if (
|
|
221
|
+
!Number.isSafeInteger(maxFacts) ||
|
|
222
|
+
maxFacts < 1 ||
|
|
223
|
+
!Number.isSafeInteger(maxTokens) ||
|
|
224
|
+
maxTokens < 1
|
|
225
|
+
) {
|
|
226
|
+
throw new MemoryError(
|
|
227
|
+
"MEMORY_BUDGET_INVALID",
|
|
228
|
+
"maxFacts and maxTokens must be positive integers."
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { legs, retrieval } = await retrieveLegs(deps, {
|
|
233
|
+
query,
|
|
234
|
+
collection: collection.name,
|
|
235
|
+
scopes,
|
|
236
|
+
});
|
|
237
|
+
const materialized = await materializeFacts(deps, fuseLegs(legs));
|
|
238
|
+
const facts = selectWithinBudget(materialized, maxFacts, maxTokens);
|
|
239
|
+
const usedTokens = facts.reduce(
|
|
240
|
+
(sum, fact) => sum + estimateTokens(fact.text),
|
|
241
|
+
0
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const receipt = buildRecallReceipt({
|
|
245
|
+
identity,
|
|
246
|
+
issuedAt: memoryNow(deps).toISOString(),
|
|
247
|
+
memoryIds: facts.map((fact) => fact.docid),
|
|
248
|
+
spanHashes: [...new Set(facts.map((fact) => fact.spanHash))].sort(),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
facts,
|
|
253
|
+
receipt,
|
|
254
|
+
budget: {
|
|
255
|
+
maxFacts,
|
|
256
|
+
maxTokens,
|
|
257
|
+
usedTokens,
|
|
258
|
+
omitted: materialized.length - facts.length,
|
|
259
|
+
},
|
|
260
|
+
retrieval,
|
|
261
|
+
...(facts.length > 0
|
|
262
|
+
? {
|
|
263
|
+
egressLineage: mergeEgressLineages(
|
|
264
|
+
facts.map((fact) => fact.egressLineage)
|
|
265
|
+
),
|
|
266
|
+
}
|
|
267
|
+
: { hint: MEMORY_EMPTY_RECALL_HINT }),
|
|
268
|
+
};
|
|
269
|
+
}
|