amiru 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.js ADDED
@@ -0,0 +1,251 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import {
3
+ appendFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
10
+ import { dirname, join } from 'node:path'
11
+
12
+ function readJson(path, fallback) {
13
+ try {
14
+ if (!existsSync(path)) return fallback
15
+ const text = readFileSync(path, 'utf8').trim()
16
+ if (!text) return fallback
17
+ return JSON.parse(text)
18
+ } catch {
19
+ return fallback
20
+ }
21
+ }
22
+
23
+ function writeJsonAtomic(path, value) {
24
+ mkdirSync(dirname(path), { recursive: true })
25
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
26
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
27
+ renameSync(tmp, path)
28
+ }
29
+
30
+ function textOfMessage(message) {
31
+ const content = message?.content
32
+ if (!Array.isArray(content)) return ''
33
+ return content
34
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
35
+ .map((block) => block.text)
36
+ .join('\n')
37
+ .trim()
38
+ }
39
+
40
+ function truncate(text, max) {
41
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim()
42
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s
43
+ }
44
+
45
+ export function bindingIdFor(accountId, chatKey) {
46
+ return createHash('sha256').update(`${accountId}|${chatKey}`).digest('hex').slice(0, 24)
47
+ }
48
+
49
+ export class AmiruStore {
50
+ constructor(dataDir) {
51
+ this.dir = dataDir
52
+ this.bindingsPath = join(dataDir, 'bindings.json')
53
+ this.memoriesPath = join(dataDir, 'memories.json')
54
+ this.messagesPath = join(dataDir, 'messages.jsonl')
55
+ this.bindings = readJson(this.bindingsPath, {})
56
+ this.memories = readJson(this.memoriesPath, {})
57
+ this.messages = this.#loadMessages()
58
+ mkdirSync(dataDir, { recursive: true })
59
+ }
60
+
61
+ #loadMessages() {
62
+ if (!existsSync(this.messagesPath)) return []
63
+ try {
64
+ const lines = readFileSync(this.messagesPath, 'utf8').split('\n')
65
+ const out = []
66
+ for (const line of lines) {
67
+ if (!line.trim()) continue
68
+ try {
69
+ out.push(JSON.parse(line))
70
+ } catch {
71
+ // One torn line must not make the rest of the diary unreadable.
72
+ }
73
+ }
74
+ return out.slice(-5000)
75
+ } catch {
76
+ return []
77
+ }
78
+ }
79
+
80
+ #saveBindings() {
81
+ writeJsonAtomic(this.bindingsPath, this.bindings)
82
+ }
83
+
84
+ #saveMemories() {
85
+ writeJsonAtomic(this.memoriesPath, this.memories)
86
+ }
87
+
88
+ getBinding(bindingId) {
89
+ return this.bindings[bindingId]
90
+ }
91
+
92
+ getBindingBySession(sessionId) {
93
+ return Object.values(this.bindings).find((b) => b.sessionId === sessionId)
94
+ }
95
+
96
+ listBindings() {
97
+ return Object.values(this.bindings)
98
+ }
99
+
100
+ ensureBinding({ accountId, chatKey, scope, peerId, sessionId, appId, personaId }) {
101
+ const bindingId = bindingIdFor(accountId, chatKey)
102
+ let binding = this.bindings[bindingId]
103
+ const now = Date.now()
104
+ if (!binding) {
105
+ binding = {
106
+ bindingId,
107
+ accountId,
108
+ appId,
109
+ chatKey,
110
+ scope,
111
+ peerId,
112
+ sessionId,
113
+ personaId: personaId || 'default',
114
+ createdAt: now,
115
+ lastUserAt: 0,
116
+ lastAssistantAt: 0,
117
+ lastProactiveAt: 0,
118
+ proactiveDay: '',
119
+ proactiveCount: 0,
120
+ unansweredProactive: 0,
121
+ userMessageCount: 0,
122
+ assistantMessageCount: 0,
123
+ proactiveEnabled: true,
124
+ optedOut: false,
125
+ }
126
+ } else if (binding.sessionId !== sessionId) {
127
+ binding.sessionId = sessionId
128
+ }
129
+ this.bindings[bindingId] = binding
130
+ this.#saveBindings()
131
+ return binding
132
+ }
133
+
134
+ updateBinding(bindingId, patch) {
135
+ const binding = this.bindings[bindingId]
136
+ if (!binding) return undefined
137
+ Object.assign(binding, patch)
138
+ this.#saveBindings()
139
+ return binding
140
+ }
141
+
142
+ setPersona(bindingId, personaId) {
143
+ return this.updateBinding(bindingId, { personaId })
144
+ }
145
+
146
+ appendMessage({ bindingId, sessionId, role, message, time = Date.now() }) {
147
+ const text = textOfMessage(message)
148
+ if (!text) return null
149
+ const row = {
150
+ id: randomUUID(),
151
+ bindingId,
152
+ sessionId,
153
+ role,
154
+ text,
155
+ time,
156
+ }
157
+ appendFileSync(this.messagesPath, `${JSON.stringify(row)}\n`, 'utf8')
158
+ this.messages.push(row)
159
+ if (this.messages.length > 5000) this.messages.splice(0, this.messages.length - 5000)
160
+ return row
161
+ }
162
+
163
+ recentMessages(bindingId, limit = 10, { excludeSessionId } = {}) {
164
+ const rows = this.messages.filter(
165
+ (m) => m.bindingId === bindingId && (!excludeSessionId || m.sessionId !== excludeSessionId),
166
+ )
167
+ return rows.slice(-limit)
168
+ }
169
+
170
+ previousSessionMessages(bindingId, currentSessionId, limit = 10) {
171
+ const rows = this.messages.filter(
172
+ (m) => m.bindingId === bindingId && m.sessionId !== currentSessionId,
173
+ )
174
+ return rows.slice(-limit)
175
+ }
176
+
177
+ addMemory(bindingId, { text, kind = 'note', importance = 3 }) {
178
+ const value = String(text ?? '').trim()
179
+ if (!value) return null
180
+ const item = {
181
+ id: randomUUID(),
182
+ bindingId,
183
+ text: value.slice(0, 1000),
184
+ kind: String(kind || 'note').slice(0, 40),
185
+ importance: Math.max(1, Math.min(5, Number(importance) || 3)),
186
+ time: Date.now(),
187
+ }
188
+ const list = this.memories[bindingId] ?? []
189
+ list.push(item)
190
+ this.memories[bindingId] = list.slice(-200)
191
+ this.#saveMemories()
192
+ return item
193
+ }
194
+
195
+ listMemories(bindingId, limit = 8) {
196
+ const list = this.memories[bindingId] ?? []
197
+ return [...list]
198
+ .sort((a, b) => (b.importance - a.importance) || (b.time - a.time))
199
+ .slice(0, limit)
200
+ }
201
+
202
+ recordUserMessage(bindingId, time = Date.now()) {
203
+ const b = this.bindings[bindingId]
204
+ if (!b) return
205
+ b.lastUserAt = time
206
+ b.userMessageCount = (b.userMessageCount || 0) + 1
207
+ b.unansweredProactive = 0
208
+ this.#saveBindings()
209
+ }
210
+
211
+ recordAssistantMessage(bindingId, time = Date.now()) {
212
+ const b = this.bindings[bindingId]
213
+ if (!b) return
214
+ b.lastAssistantAt = time
215
+ b.assistantMessageCount = (b.assistantMessageCount || 0) + 1
216
+ this.#saveBindings()
217
+ }
218
+
219
+ recordProactive(bindingId, day, time = Date.now()) {
220
+ const b = this.bindings[bindingId]
221
+ if (!b) return
222
+ if (b.proactiveDay !== day) {
223
+ b.proactiveDay = day
224
+ b.proactiveCount = 0
225
+ }
226
+ b.lastProactiveAt = time
227
+ b.proactiveCount = (b.proactiveCount || 0) + 1
228
+ b.unansweredProactive = (b.unansweredProactive || 0) + 1
229
+ this.#saveBindings()
230
+ }
231
+
232
+ stats(bindingId) {
233
+ const b = this.bindings[bindingId]
234
+ if (!b) return null
235
+ const messages = this.messages.filter((m) => m.bindingId === bindingId)
236
+ return {
237
+ binding: b,
238
+ messages: messages.length,
239
+ memories: (this.memories[bindingId] ?? []).length,
240
+ lastMessage: messages.at(-1) ?? null,
241
+ }
242
+ }
243
+ }
244
+
245
+ export function extractMessageText(message) {
246
+ return textOfMessage(message)
247
+ }
248
+
249
+ export function shorten(text, max = 160) {
250
+ return truncate(text, max)
251
+ }