dsh-memory-vector 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-memory contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # dsh-memory-vector · 不死记忆
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 给 DSH 伙伴的本地长期记忆。上下文会满、会话会压缩,但它不该因此忘记自己是谁、也不该忘记你们说过什么。
4
+
5
+ - 每条消息落本地 SQLite,带向量;
6
+ - 每轮开始前自动检索相关记忆,注入上下文;
7
+ - 核心记忆常驻,不依赖检索命中;
8
+ - 五个工具:`memory_search` / `memory_add` / `memory_pin` / `memory_forget` / `memory_stats`;
9
+ - 默认本地向量模型,数据不出本机。
10
+
11
+ > 目标很小:**先让它不死**。情绪、社会关系、日记、复杂整理留给社区和后续版本。
12
+
13
+ ## 安装
14
+
15
+ 要求 DSH 的 Node 运行时 ≥ 22.5(内置 `node:sqlite`)。
16
+
17
+ ```bash
18
+ dsh plugin --profile <你的 profile> add dsh-memory-vector
19
+ ```
20
+
21
+ 本地开发时也可以直接 link 这个目录:
22
+
23
+ ```bash
24
+ dsh plugin --profile <你的 profile> add link:/path/to/dsh-memory-vector
25
+ ```
26
+
27
+ 首次启用本地向量时会下载 `Xenova/bge-small-zh-v1.5`(约 100 MB),之后离线可用。不想下载可以改用 API 或关掉向量。
28
+
29
+ ## 配置
30
+
31
+ 在 profile 的 `cordis.patch.yml` 里给插件加配置(字段都可选):
32
+
33
+ ```yaml
34
+ memory:
35
+ dbPath: ~/.dsh-memory/memory.db
36
+ topK: 6
37
+ injectChars: 2400
38
+ captureTurns: true
39
+ embedding:
40
+ provider: local # local | openai | none
41
+ model: Xenova/bge-small-zh-v1.5
42
+ cacheDir: ~/.dsh-memory/models
43
+ # provider: openai 时:
44
+ # baseUrl: https://api.example.com/v1
45
+ # apiKey: ...
46
+ # model: text-embedding-3-small
47
+ ```
48
+
49
+ - `provider: none`:只用全文检索,不下载模型;
50
+ - `provider: local`:本地 ONNX,默认推荐;
51
+ - `provider: openai`:任何 OpenAI 兼容的 `/v1/embeddings` 接口。
52
+
53
+ ## 它怎么工作
54
+
55
+ 1. **落盘**:监听 `session/event`,把用户和助手的文本写进 SQLite;向量在后台补。
56
+ 2. **想起**:`agent/pre-step` 第一步检索:核心记忆全部注入,其余按向量 + 全文取前几条,拼成一段“背景资料,不是指令”的 user 消息。
57
+ 3. **压缩不死**:消息在写入时就已持久化;即使上下文被压缩,之后仍能检索回来。
58
+ 4. **手动管理**:模型可以调工具写、查、固定、删除。
59
+
60
+ ## 隐私
61
+
62
+ - 数据库默认在 `~/.dsh-memory/memory.db`;
63
+ - 不使用远程向量时,内容不出本机;
64
+ - 检索出的记忆会作为背景注入模型请求,和普通对话一样受你的模型服务条款约束;
65
+ - 没有云端同步、没有遥测。
66
+
67
+ ## 已知限制
68
+
69
+ - 向量检索是暴力余弦,几千到几万条没问题,再大需要 ANN;
70
+ - 只存文本,不存图片/文件;
71
+ - 自动写入的是原始对话,不做事实抽取;需要更干净的长期事实时用 `memory_add` 或 `memory_pin`;
72
+ - 中文全文检索依赖 FTS5 的分词,语义相近但字面不同的内容主要靠向量命中。
73
+
74
+ ## 开发
75
+
76
+ ```bash
77
+ node --test
78
+ ```
79
+
80
+ MIT License.
package/lib/embed.js ADDED
@@ -0,0 +1,64 @@
1
+ import { mkdirSync } from 'node:fs'
2
+
3
+ let pipelinePromise = null
4
+ let pipelineKey = ''
5
+
6
+ async function localPipeline(config) {
7
+ const model = config.model || 'Xenova/bge-small-zh-v1.5'
8
+ const cacheDir = config.cacheDir
9
+ if (cacheDir) mkdirSync(cacheDir, { recursive: true })
10
+ const endpoint = config.endpoint || 'https://hf-mirror.com'
11
+ const key = model + '|' + (cacheDir || '') + '|' + endpoint
12
+ if (pipelinePromise && pipelineKey === key) return pipelinePromise
13
+ pipelineKey = key
14
+ pipelinePromise = (async () => {
15
+ const transformers = await import('@huggingface/transformers')
16
+ if (cacheDir) transformers.env.cacheDir = cacheDir
17
+ transformers.env.remoteHost = endpoint
18
+ return transformers.pipeline('feature-extraction', model, { dtype: 'q8' })
19
+ })()
20
+ return pipelinePromise
21
+ }
22
+
23
+ async function localEmbed(texts, config) {
24
+ const extractor = await localPipeline(config)
25
+ const output = await extractor(texts, { pooling: 'mean', normalize: true })
26
+ return output.tolist()
27
+ }
28
+
29
+ async function remoteEmbed(texts, config) {
30
+ const base = String(config.baseUrl || '').replace(/\/+$/, '')
31
+ if (!base) throw new Error('embedding.baseUrl is required for the openai provider')
32
+ const response = await fetch(base + '/embeddings', {
33
+ method: 'POST',
34
+ headers: {
35
+ 'content-type': 'application/json',
36
+ ...(config.apiKey ? { authorization: 'Bearer ' + config.apiKey } : {}),
37
+ },
38
+ body: JSON.stringify({ model: config.model || 'text-embedding-3-small', input: texts }),
39
+ signal: AbortSignal.timeout(config.timeoutMs || 20000),
40
+ })
41
+ if (!response.ok) throw new Error('embedding HTTP ' + response.status)
42
+ const payload = await response.json()
43
+ const rows = Array.isArray(payload.data) ? payload.data : []
44
+ return rows.sort((a, b) => a.index - b.index).map(row => row.embedding)
45
+ }
46
+
47
+ export async function embedTexts(texts, config = {}) {
48
+ const list = (Array.isArray(texts) ? texts : [texts]).map(text => String(text || ''))
49
+ if (list.length === 0 || list.every(text => !text.trim())) return null
50
+ const provider = config.provider || 'local'
51
+ if (provider === 'none') return null
52
+ try {
53
+ if (provider === 'openai') return await remoteEmbed(list, config)
54
+ return await localEmbed(list, config)
55
+ } catch (error) {
56
+ if (typeof config.onError === 'function') config.onError(error)
57
+ return null
58
+ }
59
+ }
60
+
61
+ export function resetEmbedder() {
62
+ pipelinePromise = null
63
+ pipelineKey = ''
64
+ }
package/lib/index.js ADDED
@@ -0,0 +1,237 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { MemoryStore } from './store.js'
5
+ import { embedTexts } from './embed.js'
6
+
7
+ export const name = 'memory'
8
+ export const inject = ['tools']
9
+
10
+ const DEFAULTS = {
11
+ dbPath: join(homedir(), '.dsh-memory', 'memory.db'),
12
+ topK: 6,
13
+ scan: 4000,
14
+ injectChars: 2400,
15
+ captureTurns: true,
16
+ embedding: {
17
+ provider: 'local',
18
+ model: 'Xenova/bge-small-zh-v1.5',
19
+ cacheDir: join(homedir(), '.dsh-memory', 'models'),
20
+ },
21
+ }
22
+
23
+ function configured(config = {}) {
24
+ return {
25
+ ...DEFAULTS,
26
+ ...config,
27
+ embedding: { ...DEFAULTS.embedding, ...(config.embedding || {}) },
28
+ }
29
+ }
30
+
31
+ function textOf(content) {
32
+ if (typeof content === 'string') return content
33
+ if (!Array.isArray(content)) return ''
34
+ return content
35
+ .filter(block => block && block.type === 'text' && typeof block.text === 'string')
36
+ .map(block => block.text)
37
+ .join('\n')
38
+ .trim()
39
+ }
40
+
41
+ function buildTool(name, description, parameters, handler) {
42
+ return {
43
+ name,
44
+ description,
45
+ parameters: {
46
+ type: 'object',
47
+ properties: parameters,
48
+ additionalProperties: false,
49
+ },
50
+ output: {
51
+ schema: {
52
+ type: 'object',
53
+ properties: { result: { type: 'string' } },
54
+ required: ['result'],
55
+ additionalProperties: false,
56
+ },
57
+ render(_args, value) {
58
+ return [{ type: 'text', text: String(value?.result ?? '') }]
59
+ },
60
+ },
61
+ async execute(args) {
62
+ try {
63
+ return { result: String(await handler(args || {}) ?? '') }
64
+ } catch (error) {
65
+ return { result: 'memory error: ' + (error?.message || String(error)) }
66
+ }
67
+ },
68
+ }
69
+ }
70
+
71
+ export function apply(ctx, rawConfig = {}) {
72
+ const config = configured(rawConfig)
73
+ const injected = new Set()
74
+ let store
75
+ try {
76
+ store = new MemoryStore(config.dbPath)
77
+ } catch (error) {
78
+ ctx.logger?.error?.('dsh-memory: cannot open ' + config.dbPath + ': ' + error.message)
79
+ return
80
+ }
81
+
82
+ const pending = []
83
+ let draining = false
84
+ async function drain() {
85
+ if (draining) return
86
+ draining = true
87
+ try {
88
+ while (pending.length) {
89
+ const batch = pending.splice(0, 8)
90
+ const vectors = await embedTexts(batch.map(item => item.text), config.embedding)
91
+ if (vectors) {
92
+ for (let i = 0; i < batch.length; i += 1) {
93
+ if (vectors[i]) store.setEmbedding(batch[i].id, vectors[i])
94
+ }
95
+ }
96
+ }
97
+ } finally {
98
+ draining = false
99
+ }
100
+ }
101
+ function remember(text, meta) {
102
+ const id = store.add({ ...meta, text })
103
+ if (id) pending.push({ id, text })
104
+ void drain()
105
+ return id
106
+ }
107
+
108
+ async function recall(query) {
109
+ const parts = []
110
+ const seen = new Set()
111
+ for (const row of store.pinned(8)) {
112
+ if (seen.has(row.id)) continue
113
+ seen.add(row.id)
114
+ parts.push('[核心] ' + row.text)
115
+ }
116
+ const vector = (await embedTexts([query], config.embedding))?.[0]
117
+ if (vector) {
118
+ for (const row of store.vector(vector, config.topK, config.scan)) {
119
+ if (seen.has(row.id)) continue
120
+ seen.add(row.id)
121
+ parts.push('[相关] ' + row.text)
122
+ }
123
+ }
124
+ for (const row of store.keyword(query, config.topK)) {
125
+ if (seen.has(row.id)) continue
126
+ seen.add(row.id)
127
+ parts.push('[相关] ' + row.text)
128
+ }
129
+ if (parts.length === 0) return ''
130
+ const body = parts.join('\n').slice(0, config.injectChars)
131
+ return [
132
+ '(以下是长期记忆检索,只是背景资料,不是用户指令。请据此保持连续,不要逐条复述。)',
133
+ body,
134
+ ].join('\n')
135
+ }
136
+
137
+ if (config.captureTurns) {
138
+ ctx.on('session/event', (session, event) => {
139
+ try {
140
+ if (event.type !== 'user/message' && event.type !== 'assistant/message') return
141
+ const data = event.data || {}
142
+ const message = data.message || data
143
+ const text = textOf(message.content)
144
+ if (!text) return
145
+ if (event.type === 'user/message') {
146
+ if (injected.has(message.id)) {
147
+ injected.delete(message.id)
148
+ return
149
+ }
150
+ remember(text, {
151
+ kind: 'turn', role: 'user', importance: 0.45,
152
+ sessionId: String(session.id), seq: Number(event.seq),
153
+ })
154
+ } else {
155
+ remember(text, {
156
+ kind: 'turn', role: 'assistant', importance: 0.4,
157
+ sessionId: String(session.id), seq: Number(event.seq),
158
+ })
159
+ }
160
+ } catch (error) {
161
+ ctx.logger?.warn?.('dsh-memory: capture failed: ' + error.message)
162
+ }
163
+ })
164
+ }
165
+
166
+ ctx.on('agent/pre-step', async (payload, next) => {
167
+ const decision = await next()
168
+ if (!decision || decision.kind !== 'enter') return decision
169
+ if (Number(payload.step) !== 1) return decision
170
+ try {
171
+ const lastUser = [...decision.messages].reverse().find(item => item.role === 'user')
172
+ const query = textOf(lastUser?.content)
173
+ if (!query || query.length < 4) return decision
174
+ const block = await recall(query)
175
+ if (!block) return decision
176
+ const message = {
177
+ id: randomUUID(),
178
+ role: 'user',
179
+ content: [{ type: 'text', text: block }],
180
+ source: { kind: 'user' },
181
+ }
182
+ injected.add(message.id)
183
+ return { ...decision, messages: [message, ...decision.messages] }
184
+ } catch (error) {
185
+ ctx.logger?.warn?.('dsh-memory: recall failed: ' + error.message)
186
+ return decision
187
+ }
188
+ })
189
+
190
+ ctx.tools.register(buildTool('memory_search', 'Search long-term memory when you need to remember something from earlier conversations.', {
191
+ query: { type: 'string', description: 'What to look for.' },
192
+ limit: { type: 'integer', description: 'Max results (default 6).' },
193
+ }, async args => {
194
+ const limit = Math.min(Math.max(Number(args.limit) || 6, 1), 20)
195
+ const rows = store.keyword(String(args.query || ''), limit)
196
+ if (rows.length === 0) return '没有找到相关记忆。'
197
+ return rows.map(row => `[${row.kind}${row.pinned ? '·核心' : ''}] ${row.text}`).join('\n')
198
+ }))
199
+
200
+ ctx.tools.register(buildTool('memory_add', 'Store one durable fact the user wants remembered. Keep it short and self-contained.', {
201
+ text: { type: 'string', description: 'The fact, self-contained.' },
202
+ kind: { type: 'string', description: 'fact | preference | event | relation | core. Default fact.' },
203
+ importance: { type: 'number', description: '0 to 1. Default 0.6.' },
204
+ pinned: { type: 'boolean', description: 'Always inject this memory. Default false.' },
205
+ }, async args => {
206
+ const text = String(args.text || '').trim()
207
+ if (!text) return '没有内容可记。'
208
+ const id = remember(text, {
209
+ kind: String(args.kind || 'fact'), importance: Number(args.importance) || 0.6,
210
+ pinned: Boolean(args.pinned), role: 'memory',
211
+ })
212
+ return id ? '记下了 #' + id : '这条已经记过了。'
213
+ }))
214
+
215
+ ctx.tools.register(buildTool('memory_pin', 'Pin an existing memory so it is always injected.', {
216
+ id: { type: 'integer', description: 'Memory id.' },
217
+ }, async args => {
218
+ const id = Number(args.id)
219
+ if (!id || !store.get(id)) return '没有这条记忆。'
220
+ store.db.prepare('UPDATE memories SET pinned = 1 WHERE id = ?').run(id)
221
+ return '已固定 #' + id
222
+ }))
223
+
224
+ ctx.tools.register(buildTool('memory_forget', 'Delete one memory by id. Use only when the user asks to forget it.', {
225
+ id: { type: 'integer', description: 'Memory id.' },
226
+ }, async args => {
227
+ return store.forget(Number(args.id)) ? '已删除。' : '没有这条记忆。'
228
+ }))
229
+
230
+ ctx.tools.register(buildTool('memory_stats', 'Show memory counts and where the database lives.', {}, async () => {
231
+ const stats = store.stats()
232
+ return `共 ${stats.total} 条,核心 ${stats.pinned} 条,已向量化 ${stats.embedded} 条\n${stats.path}`
233
+ }))
234
+
235
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => store.close())
236
+ ctx.logger?.info?.('dsh-memory ready: ' + config.dbPath)
237
+ }
package/lib/store.js ADDED
@@ -0,0 +1,179 @@
1
+ import { DatabaseSync } from 'node:sqlite'
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { mkdirSync } from 'node:fs'
4
+ import { dirname } from 'node:path'
5
+
6
+ function hash(text) {
7
+ return createHash('sha256').update(String(text)).digest('hex')
8
+ }
9
+
10
+ function ftsQuery(query) {
11
+ const tokens = String(query || '')
12
+ .split(/[^\p{L}\p{N}_]+/u)
13
+ .map(token => token.trim())
14
+ .filter(token => token.length > 0)
15
+ .slice(0, 12)
16
+ if (tokens.length === 0) return ''
17
+ return tokens.map(token => '"' + token.replaceAll('"', '""') + '"').join(' OR ')
18
+ }
19
+
20
+ function toBuffer(vector) {
21
+ return Buffer.from(new Float32Array(vector).buffer)
22
+ }
23
+
24
+ function toVector(blob) {
25
+ if (!blob) return null
26
+ const buffer = Buffer.isBuffer(blob) ? blob : Buffer.from(blob)
27
+ if (buffer.byteLength % 4 !== 0) return null
28
+ return new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4)
29
+ }
30
+
31
+ function cosine(a, b) {
32
+ if (!a || !b || a.length !== b.length) return 0
33
+ let dot = 0
34
+ let na = 0
35
+ let nb = 0
36
+ for (let i = 0; i < a.length; i += 1) {
37
+ dot += a[i] * b[i]
38
+ na += a[i] * a[i]
39
+ nb += b[i] * b[i]
40
+ }
41
+ if (na === 0 || nb === 0) return 0
42
+ return dot / Math.sqrt(na * nb)
43
+ }
44
+
45
+ export class MemoryStore {
46
+ constructor(path) {
47
+ if (!path) throw new Error('MemoryStore requires a database path')
48
+ mkdirSync(dirname(path), { recursive: true })
49
+ this.path = path
50
+ this.db = new DatabaseSync(path)
51
+ this.db.exec(`
52
+ PRAGMA journal_mode = WAL;
53
+ CREATE TABLE IF NOT EXISTS memories (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ hash TEXT NOT NULL UNIQUE,
56
+ kind TEXT NOT NULL DEFAULT 'fact',
57
+ text TEXT NOT NULL,
58
+ session_id TEXT,
59
+ seq INTEGER,
60
+ role TEXT,
61
+ importance REAL NOT NULL DEFAULT 0.5,
62
+ pinned INTEGER NOT NULL DEFAULT 0,
63
+ embedding BLOB,
64
+ dim INTEGER,
65
+ created_at INTEGER NOT NULL
66
+ );
67
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(text);
68
+ CREATE INDEX IF NOT EXISTS memories_kind ON memories(kind);
69
+ CREATE INDEX IF NOT EXISTS memories_pinned ON memories(pinned);
70
+ `)
71
+ }
72
+
73
+ add({ text, kind = 'fact', sessionId = null, seq = null, role = null, importance = 0.5, pinned = false, embedding = null }) {
74
+ const content = String(text || '').trim()
75
+ if (!content) return null
76
+ const digest = hash(content)
77
+ const existing = this.db.prepare('SELECT id FROM memories WHERE hash = ?').get(digest)
78
+ if (existing) return Number(existing.id)
79
+ const info = this.db.prepare(`
80
+ INSERT INTO memories (hash, kind, text, session_id, seq, role, importance, pinned, embedding, dim, created_at)
81
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
82
+ `).run(digest, kind, content, sessionId, seq, role, Number(importance) || 0.5,
83
+ pinned ? 1 : 0, embedding ? toBuffer(embedding) : null,
84
+ embedding ? embedding.length : null, Date.now())
85
+ const id = Number(info.lastInsertRowid)
86
+ this.db.prepare('INSERT INTO memories_fts (rowid, text) VALUES (?, ?)').run(id, content)
87
+ return id
88
+ }
89
+
90
+ setEmbedding(id, vector) {
91
+ if (!vector || !vector.length) return false
92
+ this.db.prepare('UPDATE memories SET embedding = ?, dim = ? WHERE id = ?')
93
+ .run(toBuffer(vector), vector.length, Number(id))
94
+ return true
95
+ }
96
+
97
+ get(id) {
98
+ return this.db.prepare('SELECT * FROM memories WHERE id = ?').get(Number(id)) || null
99
+ }
100
+
101
+ pinned(limit = 12) {
102
+ return this.db.prepare('SELECT * FROM memories WHERE pinned = 1 ORDER BY importance DESC, id DESC LIMIT ?')
103
+ .all(Number(limit))
104
+ .map(row => this.#view(row))
105
+ }
106
+
107
+ keyword(query, limit = 8) {
108
+ const match = ftsQuery(query)
109
+ let rows = []
110
+ if (match) {
111
+ try {
112
+ rows = this.db.prepare(`
113
+ SELECT m.* FROM memories_fts f JOIN memories m ON m.id = f.rowid
114
+ WHERE memories_fts MATCH ? ORDER BY rank LIMIT ?
115
+ `).all(match, Number(limit))
116
+ } catch {
117
+ rows = []
118
+ }
119
+ }
120
+ if (rows.length === 0) {
121
+ const like = '%' + String(query || '').trim() + '%'
122
+ rows = this.db.prepare('SELECT * FROM memories WHERE text LIKE ? ORDER BY importance DESC, id DESC LIMIT ?')
123
+ .all(like, Number(limit))
124
+ }
125
+ return rows.map(row => this.#view(row))
126
+ }
127
+
128
+ vector(queryVector, limit = 8, scan = 4000) {
129
+ if (!queryVector || !queryVector.length) return []
130
+ const rows = this.db.prepare('SELECT * FROM memories WHERE embedding IS NOT NULL ORDER BY id DESC LIMIT ?')
131
+ .all(Number(scan))
132
+ const scored = []
133
+ for (const row of rows) {
134
+ const vector = toVector(row.embedding)
135
+ if (!vector) continue
136
+ const score = cosine(queryVector, vector)
137
+ if (score > 0.2) scored.push({ row, score })
138
+ }
139
+ scored.sort((a, b) => b.score - a.score)
140
+ return scored.slice(0, Number(limit)).map(item => ({ ...this.#view(item.row), score: item.score }))
141
+ }
142
+
143
+ forget(id) {
144
+ const numeric = Number(id)
145
+ this.db.prepare('DELETE FROM memories_fts WHERE rowid = ?').run(numeric)
146
+ return this.db.prepare('DELETE FROM memories WHERE id = ?').run(numeric).changes > 0
147
+ }
148
+
149
+ stats() {
150
+ const total = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memories').get().n)
151
+ const pinned = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memories WHERE pinned = 1').get().n)
152
+ const embedded = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memories WHERE embedding IS NOT NULL').get().n)
153
+ const byKind = this.db.prepare('SELECT kind, COUNT(*) AS n FROM memories GROUP BY kind').all()
154
+ .map(row => ({ kind: row.kind, count: Number(row.n) }))
155
+ return { total, pinned, embedded, byKind, path: this.path }
156
+ }
157
+
158
+ #view(row) {
159
+ return {
160
+ id: Number(row.id),
161
+ kind: row.kind,
162
+ text: row.text,
163
+ sessionId: row.session_id,
164
+ seq: row.seq === null ? null : Number(row.seq),
165
+ role: row.role,
166
+ importance: Number(row.importance),
167
+ pinned: Boolean(row.pinned),
168
+ createdAt: Number(row.created_at),
169
+ }
170
+ }
171
+
172
+ close() {
173
+ this.db.close()
174
+ }
175
+ }
176
+
177
+ export function newMemoryId() {
178
+ return randomUUID()
179
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "dsh-memory-vector",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "不死记忆:给 DSH 伙伴的本地长期记忆,SQLite + 本地向量,上下文压缩后仍能想起你是谁。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js"
9
+ },
10
+ "files": [
11
+ "lib",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "dsh",
17
+ "dsh-plugin",
18
+ "memory",
19
+ "companion",
20
+ "sqlite"
21
+ ],
22
+ "license": "MIT",
23
+ "engines": {
24
+ "node": ">=22.5"
25
+ },
26
+ "dependencies": {
27
+ "@huggingface/transformers": "^4.3.2"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }