memdir 0.0.1 → 0.0.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/README.md +56 -107
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +389 -0
- package/dist/index.js.map +1 -0
- package/package.json +16 -6
- package/index.d.ts +0 -30
- package/index.js +0 -532
package/README.md
CHANGED
|
@@ -1,56 +1,80 @@
|
|
|
1
1
|
# memdir
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lightweight memory manager for AI agents.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Core ideas:
|
|
6
|
+
- Local-first — no network calls, no external services, private by design
|
|
7
|
+
- File-based — memory is inspectable and editable
|
|
8
|
+
- Embedding-based — semantic search without a vector DB
|
|
9
|
+
- Plug-and-play — works with Ollama, llama.cpp, or any local LLM provider
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
Perfect if you’re developing small-medium agents that need to remember things across sessions without relying on vector DBs or paid services.
|
|
8
12
|
|
|
9
|
-
##
|
|
13
|
+
## Installation
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- `memory.md` long-term memory
|
|
16
|
-
- `YYYY-MM-DD.jsonl` past conversation logs
|
|
17
|
-
|
|
18
|
-
On startup, it rebuilds an in-memory semantic index from `memory.md` and recent log files.
|
|
15
|
+
```
|
|
16
|
+
npm i memdir
|
|
17
|
+
```
|
|
19
18
|
|
|
20
19
|
## Usage
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
Ollama example:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
23
24
|
import { Memory } from "memdir"
|
|
24
25
|
import { Ollama } from "ollama"
|
|
25
|
-
import { SOUL, AGENT } from "./systemPrompts.js"
|
|
26
26
|
|
|
27
27
|
const memory = new Memory()
|
|
28
28
|
const ollama = new Ollama()
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
async function embed(prompt) {
|
|
31
|
+
const { embedding } = await ollama.embeddings({
|
|
32
|
+
model: "nomic-embed-text",
|
|
33
|
+
prompt,
|
|
34
|
+
})
|
|
35
|
+
return embedding
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { memoryPrompt, tools } = await memory.init(embed)
|
|
39
|
+
|
|
40
|
+
// Assemble the system prompt
|
|
41
|
+
const systemPrompt = `You are a helpful agent. ${memoryPrompt}`
|
|
42
|
+
|
|
43
|
+
let messages = [{ role: "system", content: systemPrompt }]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
node-llama-cpp example:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { Memory } from "memdir"
|
|
50
|
+
import { getLlama } from "node-llama-cpp"
|
|
34
51
|
|
|
35
|
-
const
|
|
36
|
-
|
|
52
|
+
const memory = new Memory()
|
|
53
|
+
|
|
54
|
+
const llama = await getLlama()
|
|
55
|
+
const model = await llama.loadModel({ modelPath: "./models/bge-small-en-v1.5-q8_0.gguf" })
|
|
56
|
+
const context = await model.createEmbeddingContext()
|
|
37
57
|
|
|
38
|
-
|
|
58
|
+
async function embed(text) {
|
|
59
|
+
const embedding = await context.getEmbeddingFor(text)
|
|
60
|
+
return embedding.vector
|
|
61
|
+
}
|
|
62
|
+
const { memoryPrompt, tools } = await memory.init(embed)
|
|
39
63
|
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
// Assemble the system prompt
|
|
65
|
+
const systemPrompt = `You are a helpful agent. ${memoryPrompt}`
|
|
42
66
|
|
|
43
67
|
let messages = [{ role: "system", content: systemPrompt }]
|
|
44
68
|
```
|
|
45
69
|
|
|
46
70
|
`init()` returns two things to wire into your agent:
|
|
47
71
|
|
|
48
|
-
-
|
|
49
|
-
-
|
|
72
|
+
- `memoryPrompt` — memory instructions and stored facts. Append to your own system prompt.
|
|
73
|
+
- `tools` — `memory_write` and `memory_search` tools. Pass these to your model.
|
|
50
74
|
|
|
51
|
-
Then after each turn:
|
|
75
|
+
Then after each turn do:
|
|
52
76
|
|
|
53
|
-
```
|
|
77
|
+
```ts
|
|
54
78
|
messages = await memory.afterTurn(messages)
|
|
55
79
|
```
|
|
56
80
|
|
|
@@ -58,19 +82,17 @@ messages = await memory.afterTurn(messages)
|
|
|
58
82
|
|
|
59
83
|
### `new Memory({ dir? })`
|
|
60
84
|
|
|
61
|
-
|
|
62
|
-
| ------ | ------------ | ------------------------------------ |
|
|
63
|
-
| `dir` | `'./memory'` | Directory where all files are stored |
|
|
85
|
+
Creates a new Memory instance. Accepts an optional `dir` option (default: `'./memory'`) telling where all files should be stored.
|
|
64
86
|
|
|
65
87
|
### `await memory.init(embedding)`
|
|
66
88
|
|
|
67
|
-
|
|
89
|
+
Initializes the memory manager and builds semantic index. Must be called once before anything else.
|
|
68
90
|
|
|
69
|
-
-
|
|
91
|
+
- `embedding` — `async (text) => number[]`
|
|
70
92
|
|
|
71
93
|
The embedding function must have this shape:
|
|
72
94
|
|
|
73
|
-
```
|
|
95
|
+
```ts
|
|
74
96
|
async function embed(text) {
|
|
75
97
|
return [
|
|
76
98
|
/* numbers */
|
|
@@ -82,81 +104,8 @@ Returns `{ memoryPrompt, tools }`.
|
|
|
82
104
|
|
|
83
105
|
### `await memory.afterTurn(messages)`
|
|
84
106
|
|
|
85
|
-
|
|
86
|
-
already present in `messages`, appends it to today's log, then runs `maybeFlush()`
|
|
87
|
-
and returns the updated message array.
|
|
88
|
-
|
|
89
|
-
The latest completed user and assistant messages must already be in `messages`.
|
|
107
|
+
Call this after each completed turn. It appends the latest user/assistant pair to today's log, trims and refreshes the chat if it has grown past the character threshold, and returns the updated message array.
|
|
90
108
|
|
|
91
109
|
### `await memory.reindex()`
|
|
92
110
|
|
|
93
111
|
Rebuilds the in-memory index from `memory.md` and recent log files. Runs automatically on `init()`. Call it manually if you edit memory files outside the library.
|
|
94
|
-
|
|
95
|
-
## Tools
|
|
96
|
-
|
|
97
|
-
The agent gets two tools automatically:
|
|
98
|
-
|
|
99
|
-
**`memory_write`** — saves a fact to `memory.md` that should persist across future conversations.
|
|
100
|
-
|
|
101
|
-
**`memory_search`** — searches past conversations and stored facts by semantic similarity. Use when the current message likely depends on prior context.
|
|
102
|
-
|
|
103
|
-
Your app should assemble the final system prompt itself. A good pattern is:
|
|
104
|
-
|
|
105
|
-
```js
|
|
106
|
-
import { SOUL, AGENT } from "./systemPrompts.js"
|
|
107
|
-
|
|
108
|
-
const systemPrompt = `
|
|
109
|
-
${SOUL}
|
|
110
|
-
|
|
111
|
-
${AGENT}
|
|
112
|
-
|
|
113
|
-
${memoryPrompt}
|
|
114
|
-
`.trim()
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
## Embeddings
|
|
118
|
-
|
|
119
|
-
The library does not create embeddings for you. You should pass a function that
|
|
120
|
-
turns a single string into an embedding vector.
|
|
121
|
-
|
|
122
|
-
Ollama example:
|
|
123
|
-
|
|
124
|
-
```js
|
|
125
|
-
import { Ollama } from "ollama"
|
|
126
|
-
import { Memory } from "memdir"
|
|
127
|
-
import agentPrompt from "./agentPrompt.js"
|
|
128
|
-
|
|
129
|
-
const memory = new Memory()
|
|
130
|
-
const ollama = new Ollama()
|
|
131
|
-
|
|
132
|
-
async function embed(text) {
|
|
133
|
-
const { embedding } = await ollama.embeddings({
|
|
134
|
-
model: "nomic-embed-text",
|
|
135
|
-
prompt: text,
|
|
136
|
-
})
|
|
137
|
-
return embedding
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const { systemContent, tools } = await memory.init(agentPrompt, embed)
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
node-llama-cpp example:
|
|
144
|
-
|
|
145
|
-
```js
|
|
146
|
-
import { getLlama } from "node-llama-cpp"
|
|
147
|
-
import { Memory } from "memdir"
|
|
148
|
-
import agentPrompt from "./agentPrompt.js"
|
|
149
|
-
|
|
150
|
-
const memory = new Memory()
|
|
151
|
-
|
|
152
|
-
const llama = await getLlama()
|
|
153
|
-
const model = await llama.loadModel({ modelPath: "./models/bge-small-en-v1.5-q8_0.gguf" })
|
|
154
|
-
const context = await model.createEmbeddingContext()
|
|
155
|
-
|
|
156
|
-
async function embed(text) {
|
|
157
|
-
const embedding = await context.getEmbeddingFor(text)
|
|
158
|
-
return embedding.vector
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const { systemContent, tools } = await memory.init(agentPrompt, embed)
|
|
162
|
-
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type EmbedFn = (text: string) => Promise<number[]>;
|
|
2
|
+
export type Message = {
|
|
3
|
+
role: string;
|
|
4
|
+
content: string;
|
|
5
|
+
};
|
|
6
|
+
export type InitResult = {
|
|
7
|
+
memoryPrompt: string;
|
|
8
|
+
tools: object[];
|
|
9
|
+
};
|
|
10
|
+
export type FlushOptions = {
|
|
11
|
+
charThreshold?: number;
|
|
12
|
+
maxHistory?: number;
|
|
13
|
+
basePrompt?: string;
|
|
14
|
+
};
|
|
15
|
+
export type MemoryOptions = {
|
|
16
|
+
dir?: string;
|
|
17
|
+
};
|
|
18
|
+
export declare class Memory {
|
|
19
|
+
#private;
|
|
20
|
+
constructor({ dir }?: MemoryOptions);
|
|
21
|
+
init(embedFn: EmbedFn): Promise<InitResult>;
|
|
22
|
+
appendLog(userContent: string, assistantContent: string): Promise<void>;
|
|
23
|
+
afterTurn(messages: Message[]): Promise<Message[]>;
|
|
24
|
+
reindex(): Promise<void>;
|
|
25
|
+
maybeFlush(messages: Message[], { charThreshold, maxHistory, basePrompt, }?: FlushOptions): Promise<Message[] | null>;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAQA,KAAK,OAAO,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;AAGlD,MAAM,MAAM,OAAO,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAA;AAYvD,MAAM,MAAM,UAAU,GAAG;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,CAAA;AAElE,MAAM,MAAM,YAAY,GAAG;IACzB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AA6G5C,qBAAa,MAAM;;gBAUL,EAAE,GAAgB,EAAE,GAAE,aAAkB;IAS9C,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,UAAU,CAAC;IAa3C,SAAS,CACb,WAAW,EAAE,MAAM,EACnB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC;IAiBV,SAAS,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAWlD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA4BxB,UAAU,CACd,QAAQ,EAAE,OAAO,EAAE,EACnB,EACE,aAAoC,EACpC,UAA8B,EAC9B,UAAe,GAChB,GAAE,YAAiB,GACnB,OAAO,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC;CA2R7B"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Constants
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const FLUSH_CHAR_THRESHOLD = 24_000;
|
|
8
|
+
const FLUSH_MAX_HISTORY = 50;
|
|
9
|
+
const LOG_LOOKBACK_DAYS = 30;
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// WriteQueue — serialises async mutations on a single promise chain
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
class WriteQueue {
|
|
14
|
+
#tail = Promise.resolve();
|
|
15
|
+
run(fn) {
|
|
16
|
+
const op = this.#tail.then(fn);
|
|
17
|
+
this.#tail = op.catch((err) => {
|
|
18
|
+
console.error("[WriteQueue]", err.message);
|
|
19
|
+
});
|
|
20
|
+
return op;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Pure helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
27
|
+
function cosineSim(a, b) {
|
|
28
|
+
let dot = 0, ma = 0, mb = 0;
|
|
29
|
+
for (let i = 0; i < a.length; i++) {
|
|
30
|
+
dot += a[i] * b[i];
|
|
31
|
+
ma += a[i] * a[i];
|
|
32
|
+
mb += b[i] * b[i];
|
|
33
|
+
}
|
|
34
|
+
return ma === 0 || mb === 0 ? 0 : dot / (Math.sqrt(ma) * Math.sqrt(mb));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Atomic write: write to .tmp then rename into place.
|
|
38
|
+
* Falls back gracefully on Windows cross-device rename (EXDEV).
|
|
39
|
+
*/
|
|
40
|
+
async function atomicWrite(filePath, content) {
|
|
41
|
+
const tmp = `${filePath}.tmp`;
|
|
42
|
+
await fs.promises.writeFile(tmp, content, "utf-8");
|
|
43
|
+
try {
|
|
44
|
+
await fs.promises.rename(tmp, filePath);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err.code !== "EXDEV")
|
|
48
|
+
throw err;
|
|
49
|
+
await fs.promises.writeFile(filePath, content, "utf-8");
|
|
50
|
+
await fs.promises.unlink(tmp).catch(() => { });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function readSafe(filePath) {
|
|
54
|
+
try {
|
|
55
|
+
return await fs.promises.readFile(filePath, "utf-8");
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (err.code === "ENOENT")
|
|
59
|
+
return "";
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function parseJsonl(text) {
|
|
64
|
+
return text
|
|
65
|
+
.split("\n")
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.flatMap((line) => {
|
|
68
|
+
try {
|
|
69
|
+
return [JSON.parse(line)];
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
console.warn("[memory] skipping malformed line:", line.slice(0, 80));
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function dateStr(offsetDays = 0) {
|
|
78
|
+
const d = new Date();
|
|
79
|
+
d.setDate(d.getDate() + offsetDays);
|
|
80
|
+
return d.toISOString().slice(0, 10);
|
|
81
|
+
}
|
|
82
|
+
function assertEmbedding(embedding) {
|
|
83
|
+
if (!Array.isArray(embedding)) {
|
|
84
|
+
throw new Error("embedding function must return a number[]");
|
|
85
|
+
}
|
|
86
|
+
return embedding;
|
|
87
|
+
}
|
|
88
|
+
function resolveEmbed(embed) {
|
|
89
|
+
if (typeof embed !== "function") {
|
|
90
|
+
throw new TypeError("init() requires an embedFn: async (text) => number[]");
|
|
91
|
+
}
|
|
92
|
+
return async (texts) => Promise.all(texts.map(async (text) => assertEmbedding(await embed(text))));
|
|
93
|
+
}
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Memory
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
export class Memory {
|
|
98
|
+
#dir;
|
|
99
|
+
#memoryFile;
|
|
100
|
+
#embed = null;
|
|
101
|
+
#index = [];
|
|
102
|
+
#queue = new WriteQueue();
|
|
103
|
+
#indexQueue = new WriteQueue();
|
|
104
|
+
constructor({ dir = "./memory" } = {}) {
|
|
105
|
+
this.#dir = path.resolve(dir);
|
|
106
|
+
this.#memoryFile = path.join(this.#dir, "memory.md");
|
|
107
|
+
}
|
|
108
|
+
// -------------------------------------------------------------------------
|
|
109
|
+
// Public API
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
async init(embedFn) {
|
|
112
|
+
this.#embed = resolveEmbed(embedFn);
|
|
113
|
+
await fs.promises.mkdir(this.#dir, { recursive: true });
|
|
114
|
+
await this.reindex();
|
|
115
|
+
const memory = await this.#readMemory();
|
|
116
|
+
return {
|
|
117
|
+
memoryPrompt: this.#buildSystemContent(memory),
|
|
118
|
+
tools: this.#buildTools(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async appendLog(userContent, assistantContent) {
|
|
122
|
+
this.#embedder; // guard
|
|
123
|
+
const entry = {
|
|
124
|
+
ts: new Date().toISOString(),
|
|
125
|
+
user: userContent,
|
|
126
|
+
assistant: assistantContent,
|
|
127
|
+
};
|
|
128
|
+
const file = path.join(this.#dir, `${dateStr()}.jsonl`);
|
|
129
|
+
await this.#queue.run(() => fs.promises.appendFile(file, JSON.stringify(entry) + "\n", "utf-8"));
|
|
130
|
+
await this.#indexText(this.#logEntryToText(entry), "log", dateStr());
|
|
131
|
+
}
|
|
132
|
+
async afterTurn(messages) {
|
|
133
|
+
this.#embedder; // guard
|
|
134
|
+
const exchange = this.#latestExchange(messages);
|
|
135
|
+
if (exchange) {
|
|
136
|
+
await this.appendLog(exchange.user, exchange.assistant);
|
|
137
|
+
}
|
|
138
|
+
return (await this.maybeFlush(messages)) ?? messages;
|
|
139
|
+
}
|
|
140
|
+
async reindex() {
|
|
141
|
+
this.#embedder; // guard
|
|
142
|
+
const [logChunks, memoryChunks] = await Promise.all([
|
|
143
|
+
this.#collectLogChunks(),
|
|
144
|
+
this.#collectMemoryChunks(),
|
|
145
|
+
]);
|
|
146
|
+
const unique = [
|
|
147
|
+
...new Map([...memoryChunks, ...logChunks].map((entry) => [entry.id, entry])).values(),
|
|
148
|
+
];
|
|
149
|
+
await this.#indexQueue.run(async () => {
|
|
150
|
+
if (unique.length === 0) {
|
|
151
|
+
this.#index = [];
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const embeddings = await this.#embedder(unique.map((entry) => entry.text));
|
|
155
|
+
this.#index = unique.map((entry, i) => ({
|
|
156
|
+
...entry,
|
|
157
|
+
embedding: embeddings[i],
|
|
158
|
+
}));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async maybeFlush(messages, { charThreshold = FLUSH_CHAR_THRESHOLD, maxHistory = FLUSH_MAX_HISTORY, basePrompt = "", } = {}) {
|
|
162
|
+
this.#embedder; // guard
|
|
163
|
+
if (messages.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
const totalChars = messages.reduce((n, m) => n + (m.content?.length ?? 0), 0);
|
|
166
|
+
if (totalChars < charThreshold)
|
|
167
|
+
return null;
|
|
168
|
+
const memory = await this.#readMemory();
|
|
169
|
+
const memoryPrompt = this.#buildSystemContent(memory);
|
|
170
|
+
const systemContent = [basePrompt, memoryPrompt]
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
.join("\n\n");
|
|
173
|
+
const system = { ...messages[0], content: systemContent };
|
|
174
|
+
const rest = messages.slice(1);
|
|
175
|
+
const tail = maxHistory > 1 ? rest.slice(-(maxHistory - 1)) : [];
|
|
176
|
+
return [system, ...tail];
|
|
177
|
+
}
|
|
178
|
+
// -------------------------------------------------------------------------
|
|
179
|
+
// Private: embedder guard
|
|
180
|
+
// -------------------------------------------------------------------------
|
|
181
|
+
get #embedder() {
|
|
182
|
+
if (!this.#embed) {
|
|
183
|
+
throw new Error("Memory not initialised — call init() first");
|
|
184
|
+
}
|
|
185
|
+
return this.#embed;
|
|
186
|
+
}
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
// Private: memory.md
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
#readMemory() {
|
|
191
|
+
return readSafe(this.#memoryFile);
|
|
192
|
+
}
|
|
193
|
+
async #writeMemory(content) {
|
|
194
|
+
const bullet = content.trim().startsWith("-")
|
|
195
|
+
? content.trim()
|
|
196
|
+
: `- ${content.trim()}`;
|
|
197
|
+
await this.#queue.run(async () => {
|
|
198
|
+
const existing = await this.#readMemory();
|
|
199
|
+
const updated = existing
|
|
200
|
+
? `${existing.trimEnd()}\n${bullet}\n`
|
|
201
|
+
: `${bullet}\n`;
|
|
202
|
+
await atomicWrite(this.#memoryFile, updated);
|
|
203
|
+
});
|
|
204
|
+
await this.#indexText(bullet, "memory");
|
|
205
|
+
}
|
|
206
|
+
// -------------------------------------------------------------------------
|
|
207
|
+
// Private: log helpers
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
#logEntryToText({ ts, user, assistant }) {
|
|
210
|
+
return `[${ts}] user: ${user}\n[${ts}] assistant: ${assistant}`;
|
|
211
|
+
}
|
|
212
|
+
#latestExchange(messages) {
|
|
213
|
+
let assistant = null;
|
|
214
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
215
|
+
const message = messages[i];
|
|
216
|
+
if (assistant === null &&
|
|
217
|
+
message?.role === "assistant" &&
|
|
218
|
+
typeof message.content === "string" &&
|
|
219
|
+
message.content.trim()) {
|
|
220
|
+
assistant = message.content;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (assistant !== null &&
|
|
224
|
+
message?.role === "user" &&
|
|
225
|
+
typeof message.content === "string" &&
|
|
226
|
+
message.content.trim()) {
|
|
227
|
+
return { user: message.content, assistant };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
async #collectLogChunks() {
|
|
233
|
+
let files;
|
|
234
|
+
try {
|
|
235
|
+
files = await fs.promises.readdir(this.#dir);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
if (err.code === "ENOENT")
|
|
239
|
+
return [];
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
const chunks = await Promise.all(files
|
|
243
|
+
.filter((f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f))
|
|
244
|
+
.filter((f) => f.replace(".jsonl", "") >= dateStr(-LOG_LOOKBACK_DAYS))
|
|
245
|
+
.map(async (file) => {
|
|
246
|
+
const date = file.replace(".jsonl", "");
|
|
247
|
+
const content = await readSafe(path.join(this.#dir, file));
|
|
248
|
+
return parseJsonl(content).flatMap((entry) => {
|
|
249
|
+
if (typeof entry?.ts !== "string" ||
|
|
250
|
+
typeof entry?.user !== "string" ||
|
|
251
|
+
typeof entry?.assistant !== "string") {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
const logEntry = {
|
|
255
|
+
ts: entry.ts,
|
|
256
|
+
user: entry.user,
|
|
257
|
+
assistant: entry.assistant,
|
|
258
|
+
};
|
|
259
|
+
const text = this.#logEntryToText(logEntry);
|
|
260
|
+
return [{ text, id: sha256(text), date, source: "log" }];
|
|
261
|
+
});
|
|
262
|
+
}));
|
|
263
|
+
return chunks.flat();
|
|
264
|
+
}
|
|
265
|
+
async #collectMemoryChunks() {
|
|
266
|
+
const content = await this.#readMemory();
|
|
267
|
+
return content
|
|
268
|
+
.split("\n")
|
|
269
|
+
.filter((l) => l.trim().startsWith("-"))
|
|
270
|
+
.map((bullet) => ({
|
|
271
|
+
text: bullet.trim(),
|
|
272
|
+
id: sha256(bullet.trim()),
|
|
273
|
+
source: "memory",
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
async #indexText(text, source, date) {
|
|
277
|
+
await this.#indexQueue.run(async () => {
|
|
278
|
+
const id = sha256(text);
|
|
279
|
+
if (this.#index.some((entry) => entry.id === id))
|
|
280
|
+
return;
|
|
281
|
+
const [embedding] = await this.#embedder([text]);
|
|
282
|
+
this.#index = [...this.#index, { id, text, date, source, embedding }];
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// -------------------------------------------------------------------------
|
|
286
|
+
// Private: system prompt
|
|
287
|
+
// -------------------------------------------------------------------------
|
|
288
|
+
#buildSystemContent(memory) {
|
|
289
|
+
const memorySection = memory
|
|
290
|
+
? `## Profile Memory\n\n${memory}\n\nProfile memory contains only stable facts across conversations. Do not surface it unless directly relevant to the current reply.`
|
|
291
|
+
: null;
|
|
292
|
+
const whenToAccess = [
|
|
293
|
+
"## When to access memories",
|
|
294
|
+
"- When memories seem relevant, or the user references prior-conversation work.",
|
|
295
|
+
"- You MUST use memory_search when the user explicitly asks you to check, recall, or remember.",
|
|
296
|
+
"- If the user says to ignore or not use memory: proceed as if memory were empty. Do not apply remembered facts, cite, compare against, or mention memory content.",
|
|
297
|
+
"- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering based solely on a memory, verify it is still correct. If a recalled memory conflicts with what you observe now, trust what you observe — and update or remove the stale memory.",
|
|
298
|
+
].join("\n");
|
|
299
|
+
const beforeRecommending = [
|
|
300
|
+
"## Before recommending from memory",
|
|
301
|
+
"A memory that names a specific function, file, or flag is a claim that it existed when the memory was written. It may have been renamed, removed, or never merged. Before recommending it:",
|
|
302
|
+
"- If the user is about to act on your recommendation (not just asking about history), verify first.",
|
|
303
|
+
'"The memory says X exists" is not the same as "X exists now."',
|
|
304
|
+
].join("\n");
|
|
305
|
+
const whenToSave = [
|
|
306
|
+
"## When to save memories",
|
|
307
|
+
"Save immediately when you learn something worth remembering — do not wait for the user to ask. Save when:",
|
|
308
|
+
"- You learn details about the user's role, preferences, responsibilities, or knowledge",
|
|
309
|
+
"- The user corrects your approach or confirms a non-obvious approach worked — include why, so edge cases can be judged later",
|
|
310
|
+
"- You learn about ongoing work, goals, or deadlines not derivable from the conversation",
|
|
311
|
+
"- The user explicitly asks you to remember something",
|
|
312
|
+
"Do not save:",
|
|
313
|
+
"- Ephemeral details: in-progress work, temporary state, or summaries of the current turn",
|
|
314
|
+
"- Guesses, assumptions, or one-off topics",
|
|
315
|
+
].join("\n");
|
|
316
|
+
return [memorySection, whenToSave, whenToAccess, beforeRecommending]
|
|
317
|
+
.filter(Boolean)
|
|
318
|
+
.join("\n\n");
|
|
319
|
+
}
|
|
320
|
+
// -------------------------------------------------------------------------
|
|
321
|
+
// Private: tools
|
|
322
|
+
// -------------------------------------------------------------------------
|
|
323
|
+
#buildTools() {
|
|
324
|
+
return [
|
|
325
|
+
{
|
|
326
|
+
name: "memory_write",
|
|
327
|
+
description: "Save a fact about the user that is worth remembering.",
|
|
328
|
+
parameters: {
|
|
329
|
+
type: "object",
|
|
330
|
+
properties: {
|
|
331
|
+
content: {
|
|
332
|
+
type: "string",
|
|
333
|
+
description: 'A single concise sentence. State only what was explicitly said — no inference, no editorializing. For behavioral guidance, add the reason after a dash: "Prefers concise responses — finds long explanations condescending."',
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
required: ["content"],
|
|
337
|
+
},
|
|
338
|
+
function: async ({ content }) => {
|
|
339
|
+
await this.#writeMemory(content);
|
|
340
|
+
return "Memory saved.";
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: "memory_search",
|
|
345
|
+
description: "Search past conversations and stored facts by semantic similarity. " +
|
|
346
|
+
"Call this only when the current message likely depends on prior context — " +
|
|
347
|
+
"for example the user refers to a past conversation, an ongoing project, a saved " +
|
|
348
|
+
"preference, or an unresolved task. Do not call it for greetings, acknowledgements, " +
|
|
349
|
+
"or standalone factual questions.",
|
|
350
|
+
parameters: {
|
|
351
|
+
type: "object",
|
|
352
|
+
properties: {
|
|
353
|
+
query: {
|
|
354
|
+
type: "string",
|
|
355
|
+
description: "Natural language description of what you want to recall.",
|
|
356
|
+
},
|
|
357
|
+
k: {
|
|
358
|
+
type: "number",
|
|
359
|
+
description: "Number of results to return (default 5, max 20).",
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
required: ["query"],
|
|
363
|
+
},
|
|
364
|
+
function: async ({ query, k = 5 }) => {
|
|
365
|
+
if (this.#index.length === 0)
|
|
366
|
+
return "No history indexed yet.";
|
|
367
|
+
const [queryEmbedding] = await this.#embedder([query]);
|
|
368
|
+
const safeK = Math.min(Math.max(1, k), 20);
|
|
369
|
+
const results = this.#index
|
|
370
|
+
.filter((e) => e.embedding?.length)
|
|
371
|
+
.map((e) => ({
|
|
372
|
+
...e,
|
|
373
|
+
score: cosineSim(queryEmbedding, e.embedding),
|
|
374
|
+
}))
|
|
375
|
+
.sort((a, b) => b.score - a.score)
|
|
376
|
+
.slice(0, safeK);
|
|
377
|
+
if (results.length === 0)
|
|
378
|
+
return "No relevant history found.";
|
|
379
|
+
return results
|
|
380
|
+
.map((e) => e.date
|
|
381
|
+
? `[${e.source} / ${e.date}]\n${e.text}`
|
|
382
|
+
: `[${e.source}]\n${e.text}`)
|
|
383
|
+
.join("\n\n---\n\n");
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memdir",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
5
|
-
"main": "index.js",
|
|
6
|
-
"types": "index.d.ts",
|
|
7
|
-
"files": [
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Lightweight memory manager for AI agents.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"prepublishOnly": "npm run build",
|
|
12
|
+
"build": "tsc"
|
|
13
|
+
},
|
|
8
14
|
"repository": {
|
|
9
15
|
"type": "git",
|
|
10
16
|
"url": "git+ssh://git@github.com/artiebits/memdir.git"
|
|
@@ -16,5 +22,9 @@
|
|
|
16
22
|
"ai-memory",
|
|
17
23
|
"semantic-search",
|
|
18
24
|
"llm"
|
|
19
|
-
]
|
|
25
|
+
],
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^25.5.0",
|
|
28
|
+
"typescript": "^6.0.2"
|
|
29
|
+
}
|
|
20
30
|
}
|
package/index.d.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
export type EmbedFn = (text: string) => Promise<number[]>
|
|
2
|
-
|
|
3
|
-
export type Message = {
|
|
4
|
-
role: string
|
|
5
|
-
content: string
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export type InitResult = {
|
|
9
|
-
memoryPrompt: string
|
|
10
|
-
tools: object[]
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export type FlushOptions = {
|
|
14
|
-
charThreshold?: number
|
|
15
|
-
maxHistory?: number
|
|
16
|
-
basePrompt?: string
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export type MemoryOptions = {
|
|
20
|
-
dir?: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export class Memory {
|
|
24
|
-
constructor(opts?: MemoryOptions)
|
|
25
|
-
init(embedFn: EmbedFn): Promise<InitResult>
|
|
26
|
-
appendLog(userContent: string, assistantContent: string): Promise<void>
|
|
27
|
-
afterTurn(messages: Message[]): Promise<Message[]>
|
|
28
|
-
reindex(): Promise<void>
|
|
29
|
-
maybeFlush(messages: Message[], opts?: FlushOptions): Promise<Message[] | null>
|
|
30
|
-
}
|
package/index.js
DELETED
|
@@ -1,532 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* memdir — agent memory management
|
|
3
|
-
*
|
|
4
|
-
* Directory layout:
|
|
5
|
-
* memory/
|
|
6
|
-
* memory.md — long-term facts (human-readable markdown bullets)
|
|
7
|
-
* YYYY-MM-DD.jsonl — daily conversation logs (one JSON entry per line)
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* const memory = new Memory()
|
|
11
|
-
* const { systemContent, tools } = await memory.init(agentPrompt, async (text) => await embed(text))
|
|
12
|
-
*
|
|
13
|
-
* messages = await memory.afterTurn(messages)
|
|
14
|
-
*
|
|
15
|
-
* @typedef {(text: string) => Promise<number[]>} EmbedFn
|
|
16
|
-
* @typedef {(texts: string[]) => Promise<number[][]>} BatchEmbedFn
|
|
17
|
-
* @typedef {{ role: string, content: string }} Message
|
|
18
|
-
* @typedef {{ id: string, text: string, date?: string, source: 'log'|'memory', embedding?: number[] }} IndexEntry
|
|
19
|
-
* @typedef {{ systemContent: string, tools: object[] }} InitResult
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import fs from "fs"
|
|
23
|
-
import path from "path"
|
|
24
|
-
import { createHash } from "crypto"
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Constants
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
const FLUSH_CHAR_THRESHOLD = 24_000
|
|
31
|
-
const FLUSH_MAX_HISTORY = 50
|
|
32
|
-
const LOG_LOOKBACK_DAYS = 30
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// WriteQueue — serialises async mutations on a single promise chain
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
class WriteQueue {
|
|
39
|
-
#tail = Promise.resolve()
|
|
40
|
-
|
|
41
|
-
/** @param {() => Promise<void>} fn */
|
|
42
|
-
run(fn) {
|
|
43
|
-
const op = this.#tail.then(fn)
|
|
44
|
-
this.#tail = op.catch((err) => {
|
|
45
|
-
console.error("[WriteQueue]", err.message)
|
|
46
|
-
})
|
|
47
|
-
return op
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Pure helpers
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
/** @param {string} text @returns {string} */
|
|
56
|
-
const sha256 = (text) => createHash("sha256").update(text).digest("hex")
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* @param {number[]} a
|
|
60
|
-
* @param {number[]} b
|
|
61
|
-
* @returns {number}
|
|
62
|
-
*/
|
|
63
|
-
function cosineSim(a, b) {
|
|
64
|
-
let dot = 0,
|
|
65
|
-
ma = 0,
|
|
66
|
-
mb = 0
|
|
67
|
-
for (let i = 0; i < a.length; i++) {
|
|
68
|
-
dot += a[i] * b[i]
|
|
69
|
-
ma += a[i] * a[i]
|
|
70
|
-
mb += b[i] * b[i]
|
|
71
|
-
}
|
|
72
|
-
return ma === 0 || mb === 0 ? 0 : dot / (Math.sqrt(ma) * Math.sqrt(mb))
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Atomic write: write to .tmp then rename into place.
|
|
77
|
-
* Falls back gracefully on Windows cross-device rename (EXDEV).
|
|
78
|
-
* @param {string} filePath
|
|
79
|
-
* @param {string} content
|
|
80
|
-
*/
|
|
81
|
-
async function atomicWrite(filePath, content) {
|
|
82
|
-
const tmp = `${filePath}.tmp`
|
|
83
|
-
await fs.promises.writeFile(tmp, content, "utf-8")
|
|
84
|
-
try {
|
|
85
|
-
await fs.promises.rename(tmp, filePath)
|
|
86
|
-
} catch (err) {
|
|
87
|
-
if (err.code !== "EXDEV") throw err
|
|
88
|
-
await fs.promises.writeFile(filePath, content, "utf-8")
|
|
89
|
-
await fs.promises.unlink(tmp).catch(() => {})
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** @param {string} filePath @returns {Promise<string>} */
|
|
94
|
-
async function readSafe(filePath) {
|
|
95
|
-
try {
|
|
96
|
-
return await fs.promises.readFile(filePath, "utf-8")
|
|
97
|
-
} catch (err) {
|
|
98
|
-
if (err.code === "ENOENT") return ""
|
|
99
|
-
throw err
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Parse a JSONL string, skipping malformed lines with a warning.
|
|
105
|
-
* @template T
|
|
106
|
-
* @param {string} text
|
|
107
|
-
* @returns {T[]}
|
|
108
|
-
*/
|
|
109
|
-
function parseJsonl(text) {
|
|
110
|
-
return text
|
|
111
|
-
.split("\n")
|
|
112
|
-
.filter(Boolean)
|
|
113
|
-
.flatMap((line) => {
|
|
114
|
-
try {
|
|
115
|
-
return [JSON.parse(line)]
|
|
116
|
-
} catch {
|
|
117
|
-
console.warn("[memory] skipping malformed line:", line.slice(0, 80))
|
|
118
|
-
return []
|
|
119
|
-
}
|
|
120
|
-
})
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** @param {number} offsetDays @returns {string} YYYY-MM-DD */
|
|
124
|
-
function dateStr(offsetDays = 0) {
|
|
125
|
-
const d = new Date()
|
|
126
|
-
d.setDate(d.getDate() + offsetDays)
|
|
127
|
-
return d.toISOString().slice(0, 10)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** @param {unknown} embedding @returns {number[]} */
|
|
131
|
-
function assertEmbedding(embedding) {
|
|
132
|
-
if (!Array.isArray(embedding)) {
|
|
133
|
-
throw new Error("embedding function must return a number[]")
|
|
134
|
-
}
|
|
135
|
-
return embedding
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** @param {EmbedFn} embed @returns {BatchEmbedFn} */
|
|
139
|
-
function resolveEmbed(embed) {
|
|
140
|
-
if (typeof embed !== "function") {
|
|
141
|
-
throw new TypeError("init() requires an embedFn: async (text) => number[]")
|
|
142
|
-
}
|
|
143
|
-
return async (texts) => Promise.all(texts.map(async (text) => assertEmbedding(await embed(text))))
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
// Memory
|
|
148
|
-
// ---------------------------------------------------------------------------
|
|
149
|
-
|
|
150
|
-
export class Memory {
|
|
151
|
-
#dir
|
|
152
|
-
#memoryFile // <dir>/memory.md
|
|
153
|
-
|
|
154
|
-
/** @type {BatchEmbedFn|null} */
|
|
155
|
-
#embed = null
|
|
156
|
-
/** @type {IndexEntry[]} */
|
|
157
|
-
#index = []
|
|
158
|
-
|
|
159
|
-
#queue = new WriteQueue()
|
|
160
|
-
#indexQueue = new WriteQueue()
|
|
161
|
-
|
|
162
|
-
/** @param {{ dir?: string }} opts */
|
|
163
|
-
constructor({ dir = "./memory" } = {}) {
|
|
164
|
-
this.#dir = path.resolve(dir)
|
|
165
|
-
this.#memoryFile = path.join(this.#dir, "memory.md")
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// -------------------------------------------------------------------------
|
|
169
|
-
// Public API
|
|
170
|
-
// -------------------------------------------------------------------------
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Initialise the manager. Must be called once before anything else.
|
|
174
|
-
*
|
|
175
|
-
* @param {EmbedFn} embedFn
|
|
176
|
-
* @returns {Promise<{ memoryPrompt: string, tools: object[] }>}
|
|
177
|
-
*/
|
|
178
|
-
async init(embedFn) {
|
|
179
|
-
this.#embed = resolveEmbed(embedFn)
|
|
180
|
-
|
|
181
|
-
await fs.promises.mkdir(this.#dir, { recursive: true })
|
|
182
|
-
await this.reindex()
|
|
183
|
-
|
|
184
|
-
const memory = await this.#readMemory()
|
|
185
|
-
return {
|
|
186
|
-
memoryPrompt: this.#buildSystemContent(memory),
|
|
187
|
-
tools: this.#buildTools(),
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Append one exchange to today's JSONL log.
|
|
193
|
-
* Write is serialised through the queue, then the in-memory index is updated.
|
|
194
|
-
* Throws on write or indexing failure — callers should handle.
|
|
195
|
-
*
|
|
196
|
-
* @param {string} userContent
|
|
197
|
-
* @param {string} assistantContent
|
|
198
|
-
*/
|
|
199
|
-
async appendLog(userContent, assistantContent) {
|
|
200
|
-
this.#assertReady()
|
|
201
|
-
|
|
202
|
-
const entry = {
|
|
203
|
-
ts: new Date().toISOString(),
|
|
204
|
-
user: userContent,
|
|
205
|
-
assistant: assistantContent,
|
|
206
|
-
}
|
|
207
|
-
const file = path.join(this.#dir, `${dateStr()}.jsonl`)
|
|
208
|
-
|
|
209
|
-
await this.#queue.run(() => fs.promises.appendFile(file, JSON.stringify(entry) + "\n", "utf-8"))
|
|
210
|
-
|
|
211
|
-
await this.#indexText(this.#logEntryToText(entry), "log", dateStr())
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Convenience helper for completed turns. Logs the latest user/assistant pair
|
|
216
|
-
* already present in messages, then flushes if needed.
|
|
217
|
-
*
|
|
218
|
-
* @param {Message[]} messages
|
|
219
|
-
* @returns {Promise<Message[]>}
|
|
220
|
-
*/
|
|
221
|
-
async afterTurn(messages) {
|
|
222
|
-
this.#assertReady()
|
|
223
|
-
|
|
224
|
-
const exchange = this.#latestExchange(messages)
|
|
225
|
-
if (exchange) {
|
|
226
|
-
await this.appendLog(exchange.user, exchange.assistant)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return (await this.maybeFlush(messages)) ?? messages
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Rebuild the in-memory index from memory.md and recent log files.
|
|
234
|
-
*/
|
|
235
|
-
async reindex() {
|
|
236
|
-
this.#assertReady()
|
|
237
|
-
|
|
238
|
-
const [logChunks, memoryChunks] = await Promise.all([this.#collectLogChunks(), this.#collectMemoryChunks()])
|
|
239
|
-
|
|
240
|
-
const unique = [...new Map([...memoryChunks, ...logChunks].map((entry) => [entry.id, entry])).values()]
|
|
241
|
-
|
|
242
|
-
await this.#indexQueue.run(async () => {
|
|
243
|
-
if (unique.length === 0) {
|
|
244
|
-
this.#index = []
|
|
245
|
-
return
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const embeddings = await this.#embed(unique.map((entry) => entry.text))
|
|
249
|
-
this.#index = unique.map((entry, i) => ({
|
|
250
|
-
...entry,
|
|
251
|
-
embedding: embeddings[i],
|
|
252
|
-
}))
|
|
253
|
-
})
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Trim the conversation if it has grown past the char threshold, refreshing
|
|
258
|
-
* the system message with the latest memory.
|
|
259
|
-
*
|
|
260
|
-
* Returns a new array when trimmed, null when no action was needed.
|
|
261
|
-
* Usage: messages = await mm.maybeFlush(messages, { basePrompt }) ?? messages
|
|
262
|
-
*
|
|
263
|
-
* @param {Message[]} messages
|
|
264
|
-
* @param {{ charThreshold?: number, maxHistory?: number, basePrompt?: string }} opts
|
|
265
|
-
* @returns {Promise<Message[] | null>}
|
|
266
|
-
*/
|
|
267
|
-
async maybeFlush(messages, { charThreshold = FLUSH_CHAR_THRESHOLD, maxHistory = FLUSH_MAX_HISTORY, basePrompt = '' } = {}) {
|
|
268
|
-
this.#assertReady()
|
|
269
|
-
|
|
270
|
-
if (messages.length === 0) return null
|
|
271
|
-
|
|
272
|
-
const totalChars = messages.reduce((n, m) => n + (m.content?.length ?? 0), 0)
|
|
273
|
-
if (totalChars < charThreshold) return null
|
|
274
|
-
|
|
275
|
-
const memory = await this.#readMemory()
|
|
276
|
-
const memoryPrompt = this.#buildSystemContent(memory)
|
|
277
|
-
const systemContent = [basePrompt, memoryPrompt].filter(Boolean).join('\n\n')
|
|
278
|
-
const system = { ...messages[0], content: systemContent }
|
|
279
|
-
const rest = messages.slice(1)
|
|
280
|
-
const tail = maxHistory > 1 ? rest.slice(-(maxHistory - 1)) : []
|
|
281
|
-
|
|
282
|
-
return [system, ...tail]
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// -------------------------------------------------------------------------
|
|
286
|
-
// Private: memory.md
|
|
287
|
-
// -------------------------------------------------------------------------
|
|
288
|
-
|
|
289
|
-
/** @returns {Promise<string>} */
|
|
290
|
-
#readMemory() {
|
|
291
|
-
return readSafe(this.#memoryFile)
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Append a markdown bullet to memory.md and index it.
|
|
296
|
-
* Read-modify-write is safe because all writes go through the queue.
|
|
297
|
-
* @param {string} content
|
|
298
|
-
*/
|
|
299
|
-
async #writeMemory(content) {
|
|
300
|
-
const bullet = content.trim().startsWith("-") ? content.trim() : `- ${content.trim()}`
|
|
301
|
-
|
|
302
|
-
await this.#queue.run(async () => {
|
|
303
|
-
const existing = await this.#readMemory()
|
|
304
|
-
const updated = existing ? `${existing.trimEnd()}\n${bullet}\n` : `${bullet}\n`
|
|
305
|
-
await atomicWrite(this.#memoryFile, updated)
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
await this.#indexText(bullet, "memory")
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// -------------------------------------------------------------------------
|
|
312
|
-
// Private: log helpers
|
|
313
|
-
// -------------------------------------------------------------------------
|
|
314
|
-
|
|
315
|
-
/** @param {{ ts: string, user: string, assistant: string }} entry @returns {string} */
|
|
316
|
-
#logEntryToText({ ts, user, assistant }) {
|
|
317
|
-
return `[${ts}] user: ${user}\n[${ts}] assistant: ${assistant}`
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** @param {Message[]} messages @returns {{ user: string, assistant: string } | null} */
|
|
321
|
-
#latestExchange(messages) {
|
|
322
|
-
let assistant = null
|
|
323
|
-
|
|
324
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
325
|
-
const message = messages[i]
|
|
326
|
-
|
|
327
|
-
if (
|
|
328
|
-
assistant === null &&
|
|
329
|
-
message?.role === "assistant" &&
|
|
330
|
-
typeof message.content === "string" &&
|
|
331
|
-
message.content.trim()
|
|
332
|
-
) {
|
|
333
|
-
assistant = message.content
|
|
334
|
-
continue
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
if (
|
|
338
|
-
assistant !== null &&
|
|
339
|
-
message?.role === "user" &&
|
|
340
|
-
typeof message.content === "string" &&
|
|
341
|
-
message.content.trim()
|
|
342
|
-
) {
|
|
343
|
-
return { user: message.content, assistant }
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return null
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** @returns {Promise<Array<{ text: string, id: string, date: string, source: 'log' }>>} */
|
|
351
|
-
async #collectLogChunks() {
|
|
352
|
-
let files
|
|
353
|
-
try {
|
|
354
|
-
files = await fs.promises.readdir(this.#dir)
|
|
355
|
-
} catch (err) {
|
|
356
|
-
if (err.code === "ENOENT") return []
|
|
357
|
-
throw err
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const chunks = await Promise.all(
|
|
361
|
-
files
|
|
362
|
-
.filter((f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f))
|
|
363
|
-
.filter((f) => f.replace(".jsonl", "") >= dateStr(-LOG_LOOKBACK_DAYS))
|
|
364
|
-
.map(async (file) => {
|
|
365
|
-
const date = file.replace(".jsonl", "")
|
|
366
|
-
const content = await readSafe(path.join(this.#dir, file))
|
|
367
|
-
return parseJsonl(content).flatMap((entry) => {
|
|
368
|
-
if (
|
|
369
|
-
typeof entry?.ts !== "string" ||
|
|
370
|
-
typeof entry?.user !== "string" ||
|
|
371
|
-
typeof entry?.assistant !== "string"
|
|
372
|
-
) {
|
|
373
|
-
return []
|
|
374
|
-
}
|
|
375
|
-
const text = this.#logEntryToText(entry)
|
|
376
|
-
return [{ text, id: sha256(text), date, source: /** @type {'log'} */ ("log") }]
|
|
377
|
-
})
|
|
378
|
-
}),
|
|
379
|
-
)
|
|
380
|
-
return chunks.flat()
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** @returns {Promise<Array<{ text: string, id: string, source: 'memory' }>>} */
|
|
384
|
-
async #collectMemoryChunks() {
|
|
385
|
-
const content = await this.#readMemory()
|
|
386
|
-
return content
|
|
387
|
-
.split("\n")
|
|
388
|
-
.filter((l) => l.trim().startsWith("-"))
|
|
389
|
-
.map((bullet) => ({
|
|
390
|
-
text: bullet.trim(),
|
|
391
|
-
id: sha256(bullet.trim()),
|
|
392
|
-
source: /** @type {'memory'} */ ("memory"),
|
|
393
|
-
}))
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Embed a single text and add it to the in-memory index if not already present.
|
|
398
|
-
* @param {string} text
|
|
399
|
-
* @param {'log'|'memory'} source
|
|
400
|
-
* @param {string=} date
|
|
401
|
-
*/
|
|
402
|
-
async #indexText(text, source, date) {
|
|
403
|
-
await this.#indexQueue.run(async () => {
|
|
404
|
-
const id = sha256(text)
|
|
405
|
-
if (this.#index.some((entry) => entry.id === id)) return
|
|
406
|
-
|
|
407
|
-
const [embedding] = await this.#embed([text])
|
|
408
|
-
this.#index = [...this.#index, { id, text, date, source, embedding }]
|
|
409
|
-
})
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// -------------------------------------------------------------------------
|
|
413
|
-
// Private: system prompt
|
|
414
|
-
// -------------------------------------------------------------------------
|
|
415
|
-
|
|
416
|
-
/** @param {string} memory @returns {string} */
|
|
417
|
-
#buildSystemContent(memory) {
|
|
418
|
-
const memorySection = memory
|
|
419
|
-
? `## Profile Memory\n\n${memory}\n\nProfile memory contains only stable facts across conversations. Do not surface it unless directly relevant to the current reply.`
|
|
420
|
-
: null
|
|
421
|
-
|
|
422
|
-
const whenToAccess = [
|
|
423
|
-
"## When to access memories",
|
|
424
|
-
"- When memories seem relevant, or the user references prior-conversation work.",
|
|
425
|
-
"- You MUST use memory_search when the user explicitly asks you to check, recall, or remember.",
|
|
426
|
-
"- If the user says to ignore or not use memory: proceed as if memory were empty. Do not apply remembered facts, cite, compare against, or mention memory content.",
|
|
427
|
-
"- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering based solely on a memory, verify it is still correct. If a recalled memory conflicts with what you observe now, trust what you observe — and update or remove the stale memory.",
|
|
428
|
-
].join("\n")
|
|
429
|
-
|
|
430
|
-
const beforeRecommending = [
|
|
431
|
-
"## Before recommending from memory",
|
|
432
|
-
"A memory that names a specific function, file, or flag is a claim that it existed when the memory was written. It may have been renamed, removed, or never merged. Before recommending it:",
|
|
433
|
-
"- If the user is about to act on your recommendation (not just asking about history), verify first.",
|
|
434
|
-
'"The memory says X exists" is not the same as "X exists now."',
|
|
435
|
-
].join("\n")
|
|
436
|
-
|
|
437
|
-
const whenToSave = [
|
|
438
|
-
"## When to save memories",
|
|
439
|
-
"Save immediately when you learn something worth remembering — do not wait for the user to ask. Save when:",
|
|
440
|
-
"- You learn details about the user's role, preferences, responsibilities, or knowledge",
|
|
441
|
-
"- The user corrects your approach or confirms a non-obvious approach worked — include why, so edge cases can be judged later",
|
|
442
|
-
"- You learn about ongoing work, goals, or deadlines not derivable from the conversation",
|
|
443
|
-
"- The user explicitly asks you to remember something",
|
|
444
|
-
"Do not save:",
|
|
445
|
-
"- Ephemeral details: in-progress work, temporary state, or summaries of the current turn",
|
|
446
|
-
"- Guesses, assumptions, or one-off topics",
|
|
447
|
-
].join("\n")
|
|
448
|
-
|
|
449
|
-
return [memorySection, whenToSave, whenToAccess, beforeRecommending]
|
|
450
|
-
.filter(Boolean)
|
|
451
|
-
.join("\n\n")
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// -------------------------------------------------------------------------
|
|
455
|
-
// Private: tools
|
|
456
|
-
// -------------------------------------------------------------------------
|
|
457
|
-
|
|
458
|
-
/** @returns {object[]} */
|
|
459
|
-
#buildTools() {
|
|
460
|
-
return [
|
|
461
|
-
{
|
|
462
|
-
name: "memory_write",
|
|
463
|
-
description: "Save a fact about the user that is worth remembering.",
|
|
464
|
-
parameters: {
|
|
465
|
-
type: "object",
|
|
466
|
-
properties: {
|
|
467
|
-
content: {
|
|
468
|
-
type: "string",
|
|
469
|
-
description: "A single concise sentence. State only what was explicitly said — no inference, no editorializing. For behavioral guidance, add the reason after a dash: \"Prefers concise responses — finds long explanations condescending.\"",
|
|
470
|
-
},
|
|
471
|
-
},
|
|
472
|
-
required: ["content"],
|
|
473
|
-
},
|
|
474
|
-
function: async ({ content }) => {
|
|
475
|
-
await this.#writeMemory(content)
|
|
476
|
-
return "Memory saved."
|
|
477
|
-
},
|
|
478
|
-
},
|
|
479
|
-
{
|
|
480
|
-
name: "memory_search",
|
|
481
|
-
description:
|
|
482
|
-
"Search past conversations and stored facts by semantic similarity. " +
|
|
483
|
-
"Call this only when the current message likely depends on prior context — " +
|
|
484
|
-
"for example the user refers to a past conversation, an ongoing project, a saved " +
|
|
485
|
-
"preference, or an unresolved task. Do not call it for greetings, acknowledgements, " +
|
|
486
|
-
"or standalone factual questions.",
|
|
487
|
-
parameters: {
|
|
488
|
-
type: "object",
|
|
489
|
-
properties: {
|
|
490
|
-
query: {
|
|
491
|
-
type: "string",
|
|
492
|
-
description: "Natural language description of what you want to recall.",
|
|
493
|
-
},
|
|
494
|
-
k: {
|
|
495
|
-
type: "number",
|
|
496
|
-
description: "Number of results to return (default 5, max 20).",
|
|
497
|
-
},
|
|
498
|
-
},
|
|
499
|
-
required: ["query"],
|
|
500
|
-
},
|
|
501
|
-
function: async ({ query, k = 5 }) => {
|
|
502
|
-
if (this.#index.length === 0) return "No history indexed yet."
|
|
503
|
-
|
|
504
|
-
const [queryEmbedding] = await this.#embed([query])
|
|
505
|
-
const safeK = Math.min(Math.max(1, k), 20)
|
|
506
|
-
|
|
507
|
-
const results = this.#index
|
|
508
|
-
.filter((e) => e.embedding?.length)
|
|
509
|
-
.map((e) => ({ ...e, score: cosineSim(queryEmbedding, e.embedding) }))
|
|
510
|
-
.sort((a, b) => b.score - a.score)
|
|
511
|
-
.slice(0, safeK)
|
|
512
|
-
|
|
513
|
-
if (results.length === 0) return "No relevant history found."
|
|
514
|
-
|
|
515
|
-
return results
|
|
516
|
-
.map((e) => (e.date ? `[${e.source} / ${e.date}]\n${e.text}` : `[${e.source}]\n${e.text}`))
|
|
517
|
-
.join("\n\n---\n\n")
|
|
518
|
-
},
|
|
519
|
-
},
|
|
520
|
-
]
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// -------------------------------------------------------------------------
|
|
524
|
-
// Private: guard
|
|
525
|
-
// -------------------------------------------------------------------------
|
|
526
|
-
|
|
527
|
-
#assertReady() {
|
|
528
|
-
if (!this.#embed) {
|
|
529
|
-
throw new Error("Memory not initialised — call init() first")
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|