@frockbot/plugin-memory 0.0.0 → 0.1.1
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/frockbot.json +15 -0
- package/package.json +35 -6
- package/src/agent.test.ts +590 -0
- package/src/agent.ts +1135 -0
- package/src/chunker.ts +104 -0
- package/src/documents.ts +97 -0
- package/src/embeddings.ts +23 -0
- package/src/facts.test.ts +126 -0
- package/src/facts.ts +258 -0
- package/src/index.ts +15 -0
- package/src/indexer.test.ts +91 -0
- package/src/indexer.ts +0 -0
- package/src/manifest.ts +3 -0
- package/src/projects.ts +85 -0
- package/src/render.test.ts +438 -0
- package/src/render.ts +478 -0
- package/src/roots.ts +158 -0
- package/src/searcher.ts +159 -0
- package/src/secrets.ts +45 -0
- package/src/store.test.ts +475 -0
- package/src/store.ts +568 -0
- package/src/testing.ts +98 -0
- package/src/types.ts +54 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/searcher.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Searching Memory over the derived index.
|
|
2
|
+
//
|
|
3
|
+
// The index is derived and rebuildable, so it is never authoritative about a
|
|
4
|
+
// fact: a hit names a document and a line range, and the caller reads the
|
|
5
|
+
// bytes back out of the Workspace before showing them. That is the same rule
|
|
6
|
+
// the rest of the Package follows — the files are the Memory, everything else
|
|
7
|
+
// is a way of finding a part of them.
|
|
8
|
+
//
|
|
9
|
+
// Precedence here is the Memory precedence: own (`bot`) before `project`
|
|
10
|
+
// before `user`, "the most specific wins", applied after scoring so a strong
|
|
11
|
+
// shared hit still ranks above a weak own one within the same document.
|
|
12
|
+
import type { MemoryScopeNameV1 } from "@frockbot/kernel-contracts";
|
|
13
|
+
import {
|
|
14
|
+
memoryVectorNamespaceV1,
|
|
15
|
+
type MemoryIndexChunkV1,
|
|
16
|
+
type MemoryIndexV1,
|
|
17
|
+
} from "./indexer.js";
|
|
18
|
+
import type {
|
|
19
|
+
EmbedMemory,
|
|
20
|
+
MemorySearchResult,
|
|
21
|
+
MemoryVectorIndex,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
const SNIPPET_MAX_CHARS = 700;
|
|
25
|
+
|
|
26
|
+
const SCOPE_ORDER: Record<MemoryScopeNameV1, number> = {
|
|
27
|
+
bot: 0,
|
|
28
|
+
project: 1,
|
|
29
|
+
user: 2,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function truncate(text: string): string {
|
|
33
|
+
if (text.length <= SNIPPET_MAX_CHARS) return text;
|
|
34
|
+
const slice = text.slice(0, SNIPPET_MAX_CHARS);
|
|
35
|
+
const boundary = slice.lastIndexOf(" ");
|
|
36
|
+
return `${boundary > 0 ? slice.slice(0, boundary) : slice}…`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resultOf(
|
|
40
|
+
chunk: MemoryIndexChunkV1,
|
|
41
|
+
score?: number,
|
|
42
|
+
): MemorySearchResult {
|
|
43
|
+
return {
|
|
44
|
+
scope: chunk.scope,
|
|
45
|
+
projectId: chunk.projectId,
|
|
46
|
+
path: chunk.path,
|
|
47
|
+
startLine: chunk.startLine,
|
|
48
|
+
endLine: chunk.endLine,
|
|
49
|
+
snippet: truncate(chunk.content),
|
|
50
|
+
...(score === undefined ? {} : { score }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function lexicalScore(content: string, terms: string[]): number {
|
|
55
|
+
const haystack = content.toLowerCase();
|
|
56
|
+
let hits = 0;
|
|
57
|
+
for (const term of terms) if (haystack.includes(term)) hits += 1;
|
|
58
|
+
return terms.length === 0 ? 0 : hits / terms.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SearchMemoryOptionsV1 {
|
|
62
|
+
index: MemoryIndexV1;
|
|
63
|
+
query: string;
|
|
64
|
+
maxResults: number;
|
|
65
|
+
scope?: MemoryScopeNameV1;
|
|
66
|
+
embed?: EmbedMemory;
|
|
67
|
+
vectorize?: MemoryVectorIndex;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Vector search when an embedder and a vector index are configured, lexical
|
|
72
|
+
* search otherwise, and lexical search as the fallback when the embedder
|
|
73
|
+
* fails. A Memory search must not fail a Turn because a model binding is
|
|
74
|
+
* briefly unavailable.
|
|
75
|
+
*/
|
|
76
|
+
export async function searchMemoryV1(
|
|
77
|
+
options: SearchMemoryOptionsV1,
|
|
78
|
+
): Promise<MemorySearchResult[]> {
|
|
79
|
+
const query = options.query.trim();
|
|
80
|
+
if (!query) return [];
|
|
81
|
+
const candidates = options.index.chunks.filter(
|
|
82
|
+
(chunk) => options.scope === undefined || chunk.scope === options.scope,
|
|
83
|
+
);
|
|
84
|
+
if (candidates.length === 0) return [];
|
|
85
|
+
|
|
86
|
+
const scored = new Map<MemoryIndexChunkV1, number>();
|
|
87
|
+
if (options.embed && options.vectorize) {
|
|
88
|
+
try {
|
|
89
|
+
const [vector] = await options.embed([query]);
|
|
90
|
+
if (vector) {
|
|
91
|
+
const namespaces = new Set(candidates.map(memoryVectorNamespaceV1));
|
|
92
|
+
const byHash = new Map(
|
|
93
|
+
candidates.map((chunk) => [chunk.hash, chunk] as const),
|
|
94
|
+
);
|
|
95
|
+
for (const namespace of namespaces) {
|
|
96
|
+
const response = await options.vectorize.query(vector, {
|
|
97
|
+
topK: Math.min(options.maxResults * 3, 20),
|
|
98
|
+
namespace,
|
|
99
|
+
returnMetadata: "all",
|
|
100
|
+
});
|
|
101
|
+
for (const match of response.matches) {
|
|
102
|
+
const hash = match.metadata?.hash;
|
|
103
|
+
const chunk =
|
|
104
|
+
typeof hash === "string" ? byHash.get(hash) : undefined;
|
|
105
|
+
if (!chunk) continue;
|
|
106
|
+
scored.set(
|
|
107
|
+
chunk,
|
|
108
|
+
Math.max(scored.get(chunk) ?? 0, match.score ?? 0),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error(
|
|
115
|
+
"[memory] vector search failed; using lexical search",
|
|
116
|
+
error,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (scored.size === 0) {
|
|
122
|
+
const terms = query
|
|
123
|
+
.toLowerCase()
|
|
124
|
+
.split(/\s+/)
|
|
125
|
+
.filter((term) => term.length > 1);
|
|
126
|
+
for (const chunk of candidates) {
|
|
127
|
+
const score = lexicalScore(chunk.content, terms);
|
|
128
|
+
if (score > 0) scored.set(chunk, score);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return [...scored.entries()]
|
|
133
|
+
.sort(([leftChunk, leftScore], [rightChunk, rightScore]) => {
|
|
134
|
+
if (rightScore !== leftScore) return rightScore - leftScore;
|
|
135
|
+
const order =
|
|
136
|
+
SCOPE_ORDER[leftChunk.scope] - SCOPE_ORDER[rightChunk.scope];
|
|
137
|
+
if (order !== 0) return order;
|
|
138
|
+
return leftChunk.path.localeCompare(rightChunk.path);
|
|
139
|
+
})
|
|
140
|
+
.slice(0, options.maxResults)
|
|
141
|
+
.map(([chunk, score]) => resultOf(chunk, score));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The compact rendering a tool result carries. */
|
|
145
|
+
export function formatMemoryResultsV1(results: MemorySearchResult[]): string {
|
|
146
|
+
if (results.length === 0) return "No memory matches.";
|
|
147
|
+
return results
|
|
148
|
+
.map((result, index) => {
|
|
149
|
+
const where = result.projectId
|
|
150
|
+
? `${result.scope}/${result.projectId}`
|
|
151
|
+
: result.scope;
|
|
152
|
+
const score =
|
|
153
|
+
result.score === undefined
|
|
154
|
+
? ""
|
|
155
|
+
: ` (score: ${result.score.toFixed(3)})`;
|
|
156
|
+
return `[${index + 1}] ${where}:${result.path}:${result.startLine}-${result.endLine}${score}\n${result.snippet}`;
|
|
157
|
+
})
|
|
158
|
+
.join("\n\n---\n\n");
|
|
159
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// "Memory contains no secrets and no credential references."
|
|
2
|
+
//
|
|
3
|
+
// This is Package policy and it is only here. `kernel-contracts` deliberately
|
|
4
|
+
// declares nothing about it, because the kernel's file contract carries bytes
|
|
5
|
+
// and cannot classify them; the Memory Package owns what may be written into a
|
|
6
|
+
// Memory root, so the refusal belongs at its write path.
|
|
7
|
+
//
|
|
8
|
+
// The *shapes* it recognises are not owned here. They live in
|
|
9
|
+
// `@frockbot/secret-shapes`, because the Audit Package needs the same list to
|
|
10
|
+
// redact a durable preview and two drifting copies of a redaction list is the
|
|
11
|
+
// failure mode worth one small package. What is owned here is the policy: a
|
|
12
|
+
// match is a refusal, in words a Bot can act on.
|
|
13
|
+
//
|
|
14
|
+
// The check is deliberately bounded and deliberately shallow. It refuses
|
|
15
|
+
// *obvious credential shapes* — the ones a model pastes into a fact because it
|
|
16
|
+
// just read them out of a config file — and it makes no claim to be a secret
|
|
17
|
+
// scanner. A determined encoding gets through, and that is the honest bound:
|
|
18
|
+
// the rule it enforces is "do not write the API key into Memory", not "prove
|
|
19
|
+
// this string holds no entropy". A refusal is a declared outcome the tool
|
|
20
|
+
// reports, never a throw and never a silent redaction, because a Bot told its
|
|
21
|
+
// fact was refused can write a different one.
|
|
22
|
+
import { matchSecretShapeV1 } from "@frockbot/secret-shapes";
|
|
23
|
+
|
|
24
|
+
/** One refusal: the pattern that matched, in words a Bot can act on. */
|
|
25
|
+
export interface MemorySecretRefusalV1 {
|
|
26
|
+
reason: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Answers a refusal when a fact looks like a credential, and `undefined`
|
|
31
|
+
* otherwise. Pure and total: the same text always gets the same answer, and no
|
|
32
|
+
* input throws.
|
|
33
|
+
*/
|
|
34
|
+
export function refuseMemorySecretV1(
|
|
35
|
+
text: string,
|
|
36
|
+
): MemorySecretRefusalV1 | undefined {
|
|
37
|
+
// Bounded input: a fact is already length-capped by the tool, and the shared
|
|
38
|
+
// table caps its own input too, so the bound is the scanner's rather than a
|
|
39
|
+
// caller's promise.
|
|
40
|
+
const match = matchSecretShapeV1(text);
|
|
41
|
+
if (!match) return undefined;
|
|
42
|
+
return {
|
|
43
|
+
reason: `Memory contains no secrets and no credential references, and ${match.reason}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
// The Memory Package's writer and reader, against the production
|
|
2
|
+
// object-storage store over an in-memory bucket and generation ledger.
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type {
|
|
5
|
+
WorkspaceFilesV1,
|
|
6
|
+
WorkspaceWriterV1,
|
|
7
|
+
} from "@frockbot/kernel-contracts";
|
|
8
|
+
import {
|
|
9
|
+
botMemoryRootV1,
|
|
10
|
+
projectMemoryRootV1,
|
|
11
|
+
userMemoryRootV1,
|
|
12
|
+
} from "./roots.ts";
|
|
13
|
+
import { MemoryStore, MEMORY_MAX_FILES_PER_TIER } from "./store.ts";
|
|
14
|
+
import { createTestMemoryFilesV1 } from "./testing.ts";
|
|
15
|
+
|
|
16
|
+
const OWNER = { userId: "user-1", botId: "bot-1" };
|
|
17
|
+
const AT = new Date("2026-08-31T10:00:00.000Z");
|
|
18
|
+
|
|
19
|
+
function writerFor(botId: string): WorkspaceWriterV1 {
|
|
20
|
+
return {
|
|
21
|
+
kind: "bot",
|
|
22
|
+
botId,
|
|
23
|
+
sessionId: `user-1:${botId}`,
|
|
24
|
+
turnId: "turn-1",
|
|
25
|
+
runId: "run-1",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function storeFor(
|
|
30
|
+
botId: string,
|
|
31
|
+
files = createTestMemoryFilesV1({ userId: "user-1" }),
|
|
32
|
+
) {
|
|
33
|
+
return {
|
|
34
|
+
files,
|
|
35
|
+
store: new MemoryStore({
|
|
36
|
+
files,
|
|
37
|
+
owner: { userId: "user-1", botId },
|
|
38
|
+
botNames: { "bot-1": "General", "bot-2": "School" },
|
|
39
|
+
clock: () => AT,
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("the Memory writer", () => {
|
|
45
|
+
test("writes a Bot fact to profile.md and a log fact to log/YYYY-MM.md", async () => {
|
|
46
|
+
const { store } = storeFor("bot-1");
|
|
47
|
+
const root = botMemoryRootV1(OWNER);
|
|
48
|
+
|
|
49
|
+
const profile = await store.write({
|
|
50
|
+
root,
|
|
51
|
+
tier: "profile",
|
|
52
|
+
fact: "Tim lives in Wollongong.",
|
|
53
|
+
writer: writerFor("bot-1"),
|
|
54
|
+
});
|
|
55
|
+
const log = await store.write({
|
|
56
|
+
root,
|
|
57
|
+
tier: "log",
|
|
58
|
+
fact: "Term ends on the 12th.",
|
|
59
|
+
writer: writerFor("bot-1"),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(profile).toMatchObject({ status: "ok", path: "profile.md" });
|
|
63
|
+
expect(log).toMatchObject({ status: "ok", path: "log/2026-08.md" });
|
|
64
|
+
|
|
65
|
+
const tier = await store.read(root);
|
|
66
|
+
expect(tier.profile.map((fact) => fact.text)).toEqual([
|
|
67
|
+
"Tim lives in Wollongong.",
|
|
68
|
+
]);
|
|
69
|
+
expect(tier.recent.map((fact) => fact.text)).toEqual([
|
|
70
|
+
"Term ends on the 12th.",
|
|
71
|
+
]);
|
|
72
|
+
expect(tier.profile[0]?.date).toBe("2026-08-31");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("writes a shared fact into the writing Bot's own shard only", async () => {
|
|
76
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
77
|
+
const { store } = storeFor("bot-1", files);
|
|
78
|
+
const root = userMemoryRootV1(OWNER);
|
|
79
|
+
|
|
80
|
+
const written = await store.write({
|
|
81
|
+
root,
|
|
82
|
+
tier: "profile",
|
|
83
|
+
fact: "Tim prefers blunt answers.",
|
|
84
|
+
writer: writerFor("bot-1"),
|
|
85
|
+
});
|
|
86
|
+
expect(written).toMatchObject({
|
|
87
|
+
status: "ok",
|
|
88
|
+
path: "by-agent/bot-1/profile.md",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Another Bot's shard is refused, whatever this Bot asks for.
|
|
92
|
+
const foreign = await new MemoryStore({
|
|
93
|
+
files,
|
|
94
|
+
owner: { userId: "user-1", botId: "bot-1" },
|
|
95
|
+
clock: () => AT,
|
|
96
|
+
}).writeFile({
|
|
97
|
+
path: { root, path: "by-agent/bot-2/profile.md" },
|
|
98
|
+
text: "- (2026-08-31) Forged.\n",
|
|
99
|
+
writer: writerFor("bot-1"),
|
|
100
|
+
});
|
|
101
|
+
expect(foreign.status).toBe("refused");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("merges shards and tags every shared fact with the Bot that learned it", async () => {
|
|
105
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
106
|
+
const root = userMemoryRootV1(OWNER);
|
|
107
|
+
const one = storeFor("bot-1", files);
|
|
108
|
+
const two = storeFor("bot-2", files);
|
|
109
|
+
await one.store.write({
|
|
110
|
+
root,
|
|
111
|
+
tier: "profile",
|
|
112
|
+
fact: "Tim lives in Wollongong.",
|
|
113
|
+
writer: writerFor("bot-1"),
|
|
114
|
+
});
|
|
115
|
+
await two.store.write({
|
|
116
|
+
root,
|
|
117
|
+
tier: "profile",
|
|
118
|
+
fact: "Tim teaches on Tuesdays.",
|
|
119
|
+
writer: writerFor("bot-2"),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const tier = await one.store.read(root);
|
|
123
|
+
expect(
|
|
124
|
+
tier.profile.map((fact) => `${fact.via}: ${fact.text}`).sort(),
|
|
125
|
+
).toEqual([
|
|
126
|
+
"General: Tim lives in Wollongong.",
|
|
127
|
+
"School: Tim teaches on Tuesdays.",
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("dedupes a fact that is already recorded, without writing", async () => {
|
|
132
|
+
const { store } = storeFor("bot-1");
|
|
133
|
+
const root = botMemoryRootV1(OWNER);
|
|
134
|
+
const first = await store.write({
|
|
135
|
+
root,
|
|
136
|
+
tier: "profile",
|
|
137
|
+
fact: "Tim lives in Wollongong.",
|
|
138
|
+
writer: writerFor("bot-1"),
|
|
139
|
+
});
|
|
140
|
+
const again = await store.write({
|
|
141
|
+
root,
|
|
142
|
+
tier: "profile",
|
|
143
|
+
fact: "tim lives in wollongong.",
|
|
144
|
+
writer: writerFor("bot-1"),
|
|
145
|
+
});
|
|
146
|
+
expect(first).toMatchObject({ status: "ok", duplicate: false });
|
|
147
|
+
expect(again).toMatchObject({ status: "ok", duplicate: true });
|
|
148
|
+
expect((await store.read(root)).profile).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("refuses a fact that looks like a credential", async () => {
|
|
152
|
+
const { store } = storeFor("bot-1");
|
|
153
|
+
for (const fact of [
|
|
154
|
+
"The API key is sk-abcdefghijklmnopqrstuvwx.",
|
|
155
|
+
"Use Bearer eyJhbGciOiJIUzI1NiJ9.aaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbb",
|
|
156
|
+
"-----BEGIN RSA PRIVATE KEY-----",
|
|
157
|
+
"password = hunter2hunter2hunter2",
|
|
158
|
+
]) {
|
|
159
|
+
const outcome = await store.write({
|
|
160
|
+
root: botMemoryRootV1(OWNER),
|
|
161
|
+
tier: "log",
|
|
162
|
+
fact,
|
|
163
|
+
writer: writerFor("bot-1"),
|
|
164
|
+
});
|
|
165
|
+
expect(outcome.status).toBe("refused");
|
|
166
|
+
expect(outcome.status === "refused" ? outcome.reason : "").toContain(
|
|
167
|
+
"no secrets",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
expect((await store.read(botMemoryRootV1(OWNER))).recent).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("forgetting", () => {
|
|
175
|
+
test("removes a fact this Bot recorded from its own shard", async () => {
|
|
176
|
+
const { store } = storeFor("bot-1");
|
|
177
|
+
const root = botMemoryRootV1(OWNER);
|
|
178
|
+
await store.write({
|
|
179
|
+
root,
|
|
180
|
+
tier: "profile",
|
|
181
|
+
fact: "Tim lives in Wollongong.",
|
|
182
|
+
writer: writerFor("bot-1"),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const forgotten = await store.forget({
|
|
186
|
+
root,
|
|
187
|
+
fact: "Tim lives in Wollongong.",
|
|
188
|
+
writer: writerFor("bot-1"),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(forgotten.status).toBe("ok");
|
|
192
|
+
expect(forgotten.retracted).toBeUndefined();
|
|
193
|
+
expect((await store.read(root)).profile).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("retracts another Bot's shared fact in this Bot's own shard, never editing theirs", async () => {
|
|
197
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
198
|
+
const root = userMemoryRootV1(OWNER);
|
|
199
|
+
const two = storeFor("bot-2", files);
|
|
200
|
+
await two.store.write({
|
|
201
|
+
root,
|
|
202
|
+
tier: "profile",
|
|
203
|
+
fact: "Tim teaches on Tuesdays.",
|
|
204
|
+
writer: writerFor("bot-2"),
|
|
205
|
+
});
|
|
206
|
+
const one = storeFor("bot-1", files);
|
|
207
|
+
|
|
208
|
+
const forgotten = await one.store.forget({
|
|
209
|
+
root,
|
|
210
|
+
fact: "Tim teaches on Tuesdays.",
|
|
211
|
+
writer: writerFor("bot-1"),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(forgotten).toMatchObject({ status: "ok", retracted: true });
|
|
215
|
+
// The other Bot's file is untouched: its bytes still hold the fact.
|
|
216
|
+
const theirs = await files.read({
|
|
217
|
+
root,
|
|
218
|
+
path: "by-agent/bot-2/profile.md",
|
|
219
|
+
});
|
|
220
|
+
expect(
|
|
221
|
+
theirs.status === "ok" ? new TextDecoder().decode(theirs.file.bytes) : "",
|
|
222
|
+
).toContain("Tim teaches on Tuesdays.");
|
|
223
|
+
// But the merged tier no longer carries it: newest wins.
|
|
224
|
+
expect((await one.store.read(root)).profile).toEqual([]);
|
|
225
|
+
expect((await two.store.read(root)).profile).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("removes every marker variant of a fact, given the body or the marker", async () => {
|
|
229
|
+
const { store } = storeFor("bot-1");
|
|
230
|
+
const root = botMemoryRootV1(OWNER);
|
|
231
|
+
const writer = writerFor("bot-1");
|
|
232
|
+
// A note and a durable log fact making the same claim are two records:
|
|
233
|
+
// dedupe is on the full text, so the second write is not a duplicate.
|
|
234
|
+
await store.write({ root, tier: "log", fact: "we ship on Friday", writer });
|
|
235
|
+
const second = await store.write({
|
|
236
|
+
root,
|
|
237
|
+
tier: "note",
|
|
238
|
+
fact: "[note] we ship on Friday",
|
|
239
|
+
writer,
|
|
240
|
+
});
|
|
241
|
+
expect(second).toMatchObject({ status: "ok", duplicate: false });
|
|
242
|
+
expect((await store.read(root)).recent.map((f) => f.text).sort()).toEqual([
|
|
243
|
+
"[note] we ship on Friday",
|
|
244
|
+
"we ship on Friday",
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
// Forgetting the body removes both — a User can forget a note whose
|
|
248
|
+
// `[note] ` prefix they were never shown.
|
|
249
|
+
const forgotten = await store.forget({
|
|
250
|
+
root,
|
|
251
|
+
fact: "we ship on Friday",
|
|
252
|
+
writer,
|
|
253
|
+
});
|
|
254
|
+
expect(forgotten.status).toBe("ok");
|
|
255
|
+
expect((await store.read(root)).recent).toEqual([]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("a forget that does pass the marker matches too", async () => {
|
|
259
|
+
const { store } = storeFor("bot-1");
|
|
260
|
+
const root = botMemoryRootV1(OWNER);
|
|
261
|
+
const writer = writerFor("bot-1");
|
|
262
|
+
await store.write({
|
|
263
|
+
root,
|
|
264
|
+
tier: "note",
|
|
265
|
+
fact: "[note] we ship on Friday",
|
|
266
|
+
writer,
|
|
267
|
+
});
|
|
268
|
+
const forgotten = await store.forget({
|
|
269
|
+
root,
|
|
270
|
+
fact: "[note] we ship on Friday",
|
|
271
|
+
writer,
|
|
272
|
+
});
|
|
273
|
+
expect(forgotten.status).toBe("ok");
|
|
274
|
+
expect((await store.read(root)).recent).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("retracts another Bot's note by its body, naming the recorded text", async () => {
|
|
278
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
279
|
+
const root = userMemoryRootV1(OWNER);
|
|
280
|
+
const two = storeFor("bot-2", files);
|
|
281
|
+
await two.store.write({
|
|
282
|
+
root,
|
|
283
|
+
tier: "note",
|
|
284
|
+
fact: "[note] Tim teaches on Tuesdays.",
|
|
285
|
+
writer: writerFor("bot-2"),
|
|
286
|
+
});
|
|
287
|
+
const one = storeFor("bot-1", files);
|
|
288
|
+
|
|
289
|
+
const forgotten = await one.store.forget({
|
|
290
|
+
root,
|
|
291
|
+
fact: "Tim teaches on Tuesdays.",
|
|
292
|
+
writer: writerFor("bot-1"),
|
|
293
|
+
});
|
|
294
|
+
expect(forgotten).toMatchObject({ status: "ok", retracted: true });
|
|
295
|
+
|
|
296
|
+
// The retraction names the *recorded* text, marker and all, because that
|
|
297
|
+
// is the text newest-wins resolves against.
|
|
298
|
+
const mine = await files.read({
|
|
299
|
+
root,
|
|
300
|
+
path: "by-agent/bot-1/log/2026-08.md",
|
|
301
|
+
});
|
|
302
|
+
expect(
|
|
303
|
+
mine.status === "ok" ? new TextDecoder().decode(mine.file.bytes) : "",
|
|
304
|
+
).toContain("[forgotten] [note] Tim teaches on Tuesdays.");
|
|
305
|
+
expect((await one.store.read(root)).recent).toEqual([]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("refuses a forget of a fact nobody recorded", async () => {
|
|
309
|
+
const { store } = storeFor("bot-1");
|
|
310
|
+
const outcome = await store.forget({
|
|
311
|
+
root: botMemoryRootV1(OWNER),
|
|
312
|
+
fact: "Never said.",
|
|
313
|
+
writer: writerFor("bot-1"),
|
|
314
|
+
});
|
|
315
|
+
expect(outcome.status).toBe("refused");
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("project memory", () => {
|
|
320
|
+
test("shards a Project tier per writing Bot exactly as the User tier does", async () => {
|
|
321
|
+
const { store } = storeFor("bot-1");
|
|
322
|
+
const root = projectMemoryRootV1(OWNER, "ghetto-movement");
|
|
323
|
+
const written = await store.write({
|
|
324
|
+
root,
|
|
325
|
+
tier: "log",
|
|
326
|
+
fact: "The shoot is on Friday.",
|
|
327
|
+
writer: writerFor("bot-1"),
|
|
328
|
+
});
|
|
329
|
+
expect(written).toMatchObject({
|
|
330
|
+
status: "ok",
|
|
331
|
+
path: "by-agent/bot-1/log/2026-08.md",
|
|
332
|
+
});
|
|
333
|
+
expect((await store.read(root)).recent.map((fact) => fact.via)).toEqual([
|
|
334
|
+
"General",
|
|
335
|
+
]);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe("a tier read that a declared bound cut short", () => {
|
|
340
|
+
/** One profile shard per Bot, past the per-tier file bound. */
|
|
341
|
+
async function crowdedUserTier(shards: number) {
|
|
342
|
+
const files = createTestMemoryFilesV1({ userId: "user-1" });
|
|
343
|
+
for (let index = 0; index < shards; index += 1) {
|
|
344
|
+
const botId = `bot-${String(index).padStart(3, "0")}`;
|
|
345
|
+
const store = new MemoryStore({
|
|
346
|
+
files,
|
|
347
|
+
owner: { userId: "user-1", botId },
|
|
348
|
+
clock: () => AT,
|
|
349
|
+
});
|
|
350
|
+
const written = await store.write({
|
|
351
|
+
root: userMemoryRootV1(OWNER),
|
|
352
|
+
tier: "profile",
|
|
353
|
+
fact: `Shard ${String(index).padStart(3, "0")} learned something.`,
|
|
354
|
+
writer: writerFor(botId),
|
|
355
|
+
});
|
|
356
|
+
expect(written.status).toBe("ok");
|
|
357
|
+
}
|
|
358
|
+
return files;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
test("keeps the newest files, because injection is about recent facts", async () => {
|
|
362
|
+
const shards = MEMORY_MAX_FILES_PER_TIER + 2;
|
|
363
|
+
const files = await crowdedUserTier(shards);
|
|
364
|
+
const store = new MemoryStore({
|
|
365
|
+
files,
|
|
366
|
+
owner: { userId: "user-1", botId: "bot-000" },
|
|
367
|
+
clock: () => AT,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const tier = await store.read(userMemoryRootV1(OWNER));
|
|
371
|
+
|
|
372
|
+
expect(tier.sources).toHaveLength(MEMORY_MAX_FILES_PER_TIER);
|
|
373
|
+
expect(tier.omitted).toContain("the newest");
|
|
374
|
+
const shardIds = tier.sources.map((source) => source.botId).sort();
|
|
375
|
+
// The two oldest shards were dropped; the newest write is still read.
|
|
376
|
+
expect(shardIds).not.toContain("bot-000");
|
|
377
|
+
expect(shardIds).not.toContain("bot-001");
|
|
378
|
+
expect(shardIds).toContain(`bot-${String(shards - 1).padStart(3, "0")}`);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("a forget refuses rather than reporting a fact it could not have removed", async () => {
|
|
382
|
+
const files = await crowdedUserTier(MEMORY_MAX_FILES_PER_TIER + 1);
|
|
383
|
+
const store = new MemoryStore({
|
|
384
|
+
files,
|
|
385
|
+
owner: { userId: "user-1", botId: "bot-000" },
|
|
386
|
+
clock: () => AT,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const forgotten = await store.forget({
|
|
390
|
+
root: userMemoryRootV1(OWNER),
|
|
391
|
+
fact: "Shard 000 learned something.",
|
|
392
|
+
writer: writerFor("bot-000"),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
expect(forgotten.status).toBe("unavailable");
|
|
396
|
+
if (forgotten.status !== "unavailable") throw new Error("unreachable");
|
|
397
|
+
expect(forgotten.reason).toContain("read bound");
|
|
398
|
+
// And the fact is still on disk, which is what the refusal is about.
|
|
399
|
+
const own = await files.read({
|
|
400
|
+
root: userMemoryRootV1(OWNER),
|
|
401
|
+
path: "by-agent/bot-000/profile.md",
|
|
402
|
+
});
|
|
403
|
+
expect(own.status).toBe("ok");
|
|
404
|
+
if (own.status !== "ok") return;
|
|
405
|
+
expect(new TextDecoder().decode(own.file.bytes)).toContain(
|
|
406
|
+
"Shard 000 learned something.",
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("keeps every omission when two bounds bite at once", async () => {
|
|
411
|
+
// A listing that never ends, of more files than the tier bound reads:
|
|
412
|
+
// both the page bound and the file bound cut this read, and a caller that
|
|
413
|
+
// must refuse on an incomplete read needs to be told both.
|
|
414
|
+
const entries = (page: number) =>
|
|
415
|
+
Array.from({ length: 100 }, (_, index) => {
|
|
416
|
+
const botId = `bot-${String(page * 100 + index).padStart(4, "0")}`;
|
|
417
|
+
return {
|
|
418
|
+
path: {
|
|
419
|
+
root: userMemoryRootV1(OWNER),
|
|
420
|
+
path: `by-agent/${botId}/profile.md`,
|
|
421
|
+
},
|
|
422
|
+
generation: {
|
|
423
|
+
schemaVersion: 1 as const,
|
|
424
|
+
generationId: `${String(page * 100 + index).padStart(9, "0")}`,
|
|
425
|
+
contentHash: "0".repeat(64),
|
|
426
|
+
size: 12,
|
|
427
|
+
writer: writerFor(botId),
|
|
428
|
+
writtenAt: AT.toISOString(),
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
});
|
|
432
|
+
const endless: WorkspaceFilesV1 = {
|
|
433
|
+
list: (request) => {
|
|
434
|
+
const page = Number(request.cursor ?? "0");
|
|
435
|
+
return Promise.resolve({
|
|
436
|
+
status: "ok",
|
|
437
|
+
entries: entries(page),
|
|
438
|
+
cursor: String(page + 1),
|
|
439
|
+
});
|
|
440
|
+
},
|
|
441
|
+
read: (path) =>
|
|
442
|
+
Promise.resolve({
|
|
443
|
+
status: "ok",
|
|
444
|
+
file: {
|
|
445
|
+
path,
|
|
446
|
+
generation: {
|
|
447
|
+
schemaVersion: 1,
|
|
448
|
+
generationId: "000000001",
|
|
449
|
+
contentHash: "0".repeat(64),
|
|
450
|
+
size: 12,
|
|
451
|
+
writer: writerFor("bot-0000"),
|
|
452
|
+
writtenAt: AT.toISOString(),
|
|
453
|
+
},
|
|
454
|
+
bytes: new TextEncoder().encode("- 2026-08-31 A fact.\n"),
|
|
455
|
+
},
|
|
456
|
+
}),
|
|
457
|
+
stat: () =>
|
|
458
|
+
Promise.resolve({ status: "not-found", reason: "unused in this test" }),
|
|
459
|
+
write: () =>
|
|
460
|
+
Promise.resolve({ status: "refused", reason: "unused in this test" }),
|
|
461
|
+
delete: () =>
|
|
462
|
+
Promise.resolve({ status: "refused", reason: "unused in this test" }),
|
|
463
|
+
};
|
|
464
|
+
const store = new MemoryStore({
|
|
465
|
+
files: endless,
|
|
466
|
+
owner: { userId: "user-1", botId: "bot-0000" },
|
|
467
|
+
clock: () => AT,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const tier = await store.read(userMemoryRootV1(OWNER));
|
|
471
|
+
|
|
472
|
+
expect(tier.omitted).toContain("did not finish listing");
|
|
473
|
+
expect(tier.omitted).toContain("read bound were not read");
|
|
474
|
+
});
|
|
475
|
+
});
|