luckytri 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +14 -12
- package/README.md +4 -2
- package/docs/en/CHANGELOG.md +24 -0
- package/docs/en/architecture.md +12 -10
- package/docs/en/chat-style.md +13 -13
- package/docs/en/cognition.md +12 -4
- package/docs/en/evolution.md +35 -0
- package/docs/en/her-life.md +5 -2
- package/docs/en/vision.md +50 -0
- package/docs/zh/CHANGELOG.md +24 -0
- package/docs/zh/architecture.md +12 -10
- package/docs/zh/chat-style.md +17 -17
- package/docs/zh/cognition.md +12 -4
- package/docs/zh/evolution.md +35 -0
- package/docs/zh/her-life.md +5 -2
- package/docs/zh/vision.md +50 -0
- package/package.json +4 -1
- package/public/app/assets/{ChatsPage-IfBgNdaD.js → ChatsPage-eDqFIg97.js} +1 -1
- package/public/app/assets/{Empty-DK9nQPCf.js → Empty-CAGOJsfD.js} +1 -1
- package/public/app/assets/{Field-BzKFhuFd.js → Field-CRz-JmN1.js} +1 -1
- package/public/app/assets/{HeartPage-CKxrbfFQ.js → HeartPage-BGWoeMrC.js} +1 -1
- package/public/app/assets/{LifePage-CtxPEi6E.js → LifePage-iao3o3wt.js} +1 -1
- package/public/app/assets/{MemoryPage-B8FLXzLi.js → MemoryPage-Dwi2yi7E.js} +1 -1
- package/public/app/assets/NaturePage-DniImo9U.js +3 -0
- package/public/app/assets/{NaturePage-CHkpb5NK.css → NaturePage-zguDpGWY.css} +1 -1
- package/public/app/assets/{PeoplePage-D3yn0HV0.js → PeoplePage-D-MC3JEt.js} +1 -1
- package/public/app/assets/{PluginsPage-ojv8zawj.js → PluginsPage-D7dJTf96.js} +1 -1
- package/public/app/assets/SystemPage-C5CUHjGh.js +1 -0
- package/public/app/assets/{SystemPage-CGVTvNZw.css → SystemPage-C_Y5M7JU.css} +1 -1
- package/public/app/assets/{TimePage-Dyd-FFbq.js → TimePage-Dwhr3-92.js} +1 -1
- package/public/app/assets/{index-BJfzfgnm.js → index-L0bHax8C.js} +3 -3
- package/public/app/assets/{knowledge-C-bND0Ub.js → knowledge-BTDNafzl.js} +1 -1
- package/public/app/assets/{media-D7G3Nco6.js → media-BBzOAfNG.js} +1 -1
- package/public/app/assets/{mind-ec-G6iNZ.js → mind-CBTkFje2.js} +1 -1
- package/public/app/index.html +3 -3
- package/public/guide.en.html +3 -3
- package/public/guide.html +3 -3
- package/scripts/migrate-instance.js +27 -0
- package/scripts/update-instance.js +21 -2
- package/server/channels/contract.js +5 -1
- package/server/channels/hub.js +2 -2
- package/server/channels/onebot/adapter.js +291 -288
- package/server/channels/onebot/index.js +2 -2
- package/server/channels/qqbot/api.js +194 -199
- package/server/core/context-builder.js +20 -3
- package/server/core/conversation-cues.js +5 -4
- package/server/core/conversation-grounding.js +20 -17
- package/server/core/conversation-needs.js +32 -0
- package/server/core/dialogue-context.js +2 -5
- package/server/core/dialogue-contract.js +23 -16
- package/server/core/message-scheduler.js +85 -78
- package/server/core/model-manager.js +113 -16
- package/server/core/model-presets.js +1 -1
- package/server/core/orchestrator.js +18 -2
- package/server/core/persona-manager.js +14 -5
- package/server/core/reader-check.js +47 -42
- package/server/core/reply-delivery.js +125 -34
- package/server/core/response-validator.js +28 -1
- package/server/core/retired-prompts.js +5 -0
- package/server/core/speech-length.js +43 -0
- package/server/core/turn-processor.js +38 -5
- package/server/core/turn.js +70 -16
- package/server/i18n/errors.js +30 -0
- package/server/i18n/labels.js +5 -0
- package/server/index.js +3 -0
- package/server/knowledge/embedding-profile.js +126 -29
- package/server/knowledge/manager.js +103 -34
- package/server/knowledge/schema.js +197 -158
- package/server/mind/affect.js +267 -265
- package/server/mind/aftermath.js +169 -0
- package/server/mind/anticipations.js +2 -0
- package/server/mind/clock.js +91 -83
- package/server/mind/discretion.js +20 -0
- package/server/mind/episodes.js +2 -1
- package/server/mind/index.js +17 -2
- package/server/mind/initiative.js +29 -14
- package/server/mind/memory.js +47 -5
- package/server/mind/recall/cue.js +67 -0
- package/server/mind/recall/index.js +183 -29
- package/server/mind/recall/roots.js +231 -0
- package/server/mind/recall/schema.js +2 -0
- package/server/mind/recall/store.js +113 -30
- package/server/mind/salience.js +22 -1
- package/server/mind/schema.js +5 -0
- package/server/mind/thoughts.js +276 -274
- package/server/mind/view.js +100 -60
- package/server/plugins/install.js +1 -6
- package/server/plugins/zip.js +18 -4
- package/server/runtime/data-migration.js +193 -0
- package/server/runtime/update-execution.js +43 -4
- package/server/runtime/update-manager.js +4 -1
- package/server/storage/archive.js +1 -1
- package/server/storage/backup-scheduler.js +15 -3
- package/public/app/assets/NaturePage-C7i607ul.js +0 -3
- package/public/app/assets/SystemPage-Cv3rFvyT.js +0 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { interestTerms } from "../attention.js";
|
|
2
|
+
import { evidenceRoots } from "../evidence.js";
|
|
3
|
+
import { SELF_KINDS } from "../self.js";
|
|
4
|
+
import { parse, text } from "../util.js";
|
|
5
|
+
import { fixedReplyHabit } from "../../core/conversation-grounding.js";
|
|
6
|
+
|
|
7
|
+
// What each of her records ultimately rests on: the messages, observations
|
|
8
|
+
// and finished work its sources resolve to. Derived from the sources she
|
|
9
|
+
// already keeps, so it can be dropped and refilled at any time; it asserts
|
|
10
|
+
// nothing new. Two records that rest on the same moment belong together.
|
|
11
|
+
const KINDS = {
|
|
12
|
+
episode: {
|
|
13
|
+
pending: (
|
|
14
|
+
from,
|
|
15
|
+
) => `SELECT id ref, created stamp, sources FROM ${from("mind_episodes")} e
|
|
16
|
+
WHERE NOT EXISTS (SELECT 1 FROM mind_rooted r WHERE r.item_id='episode:'||e.id) LIMIT ?`,
|
|
17
|
+
roots: (db, row) => evidenceRoots(db, parse(row.sources, [])),
|
|
18
|
+
},
|
|
19
|
+
thought: {
|
|
20
|
+
pending: (
|
|
21
|
+
from,
|
|
22
|
+
) => `SELECT id ref, created*2+hidden stamp FROM ${from("mind_thoughts")} t
|
|
23
|
+
WHERE NOT EXISTS (SELECT 1 FROM mind_rooted r WHERE r.item_id='thought:'||t.id AND r.stamp=t.created*2+t.hidden) LIMIT ?`,
|
|
24
|
+
roots: (db, row) => evidenceRoots(db, [`t:${row.ref}`]),
|
|
25
|
+
},
|
|
26
|
+
anticipation: {
|
|
27
|
+
pending: (
|
|
28
|
+
from,
|
|
29
|
+
) => `SELECT id ref, created stamp FROM ${from("mind_anticipations")} a
|
|
30
|
+
WHERE NOT EXISTS (SELECT 1 FROM mind_rooted r WHERE r.item_id='anticipation:'||a.id) LIMIT ?`,
|
|
31
|
+
roots: (db, row) => evidenceRoots(db, [`a:${row.ref}`]),
|
|
32
|
+
},
|
|
33
|
+
// A self thread grows new versions and a note or thread may be taken
|
|
34
|
+
// back; either changes the stamp (time doubled, plus one once revoked),
|
|
35
|
+
// so it is looked at again.
|
|
36
|
+
self: {
|
|
37
|
+
pending: (
|
|
38
|
+
from,
|
|
39
|
+
) => `SELECT ref, stamp FROM (SELECT thread ref, MAX(created)*2+EXISTS(SELECT 1 FROM mind_revocations v WHERE v.target_kind='self' AND v.target_id=thread) stamp FROM mind_self WHERE thread IN (SELECT thread FROM ${from("mind_self")} y) GROUP BY thread) x
|
|
40
|
+
WHERE NOT EXISTS (SELECT 1 FROM mind_rooted r WHERE r.item_id='self:'||x.ref AND r.stamp=x.stamp) LIMIT ?`,
|
|
41
|
+
roots: (db, row) => evidenceRoots(db, [`s:${row.ref}`]),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function migrateRoots(db) {
|
|
46
|
+
db.exec(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS mind_roots(
|
|
48
|
+
item_id TEXT NOT NULL,
|
|
49
|
+
root TEXT NOT NULL,
|
|
50
|
+
PRIMARY KEY(item_id,root)
|
|
51
|
+
);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS mind_roots_root ON mind_roots(root);
|
|
53
|
+
CREATE TABLE IF NOT EXISTS mind_rooted(
|
|
54
|
+
item_id TEXT PRIMARY KEY,
|
|
55
|
+
stamp INTEGER NOT NULL
|
|
56
|
+
);
|
|
57
|
+
`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Works through records not yet looked at, a page at a time. Idempotent.
|
|
61
|
+
// `recent` only looks among the newest rows of each table, cheap enough for
|
|
62
|
+
// a turn; the background pass looks at everything.
|
|
63
|
+
export function fillRoots(db, limit = 500, { recent = 0 } = {}) {
|
|
64
|
+
const from = (table) =>
|
|
65
|
+
recent
|
|
66
|
+
? `(SELECT * FROM ${table} ORDER BY rowid DESC LIMIT ${Math.floor(recent)})`
|
|
67
|
+
: table;
|
|
68
|
+
let done = 0;
|
|
69
|
+
const clear = db.prepare("DELETE FROM mind_roots WHERE item_id=?");
|
|
70
|
+
const put = db.prepare(
|
|
71
|
+
"INSERT OR IGNORE INTO mind_roots(item_id,root) VALUES (?,?)",
|
|
72
|
+
);
|
|
73
|
+
const mark = db.prepare(
|
|
74
|
+
"INSERT INTO mind_rooted(item_id,stamp) VALUES (?,?) ON CONFLICT(item_id) DO UPDATE SET stamp=excluded.stamp",
|
|
75
|
+
);
|
|
76
|
+
db.exec("SAVEPOINT mind_roots_fill");
|
|
77
|
+
try {
|
|
78
|
+
for (const [kind, spec] of Object.entries(KINDS)) {
|
|
79
|
+
if (done >= limit) break;
|
|
80
|
+
for (const row of db.prepare(spec.pending(from)).all(limit - done)) {
|
|
81
|
+
const id = `${kind}:${row.ref}`;
|
|
82
|
+
clear.run(id);
|
|
83
|
+
for (const root of spec.roots(db, row)) put.run(id, root);
|
|
84
|
+
mark.run(id, row.stamp);
|
|
85
|
+
done++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
db.exec("RELEASE mind_roots_fill");
|
|
89
|
+
} catch (error) {
|
|
90
|
+
db.exec("ROLLBACK TO mind_roots_fill");
|
|
91
|
+
db.exec("RELEASE mind_roots_fill");
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
return done;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const LINE = 60;
|
|
98
|
+
const ANTICIPATION_STATE = {
|
|
99
|
+
pending: "还没做",
|
|
100
|
+
done: "做了",
|
|
101
|
+
missed: "没做成",
|
|
102
|
+
let_go: "放下了",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Her own notes, intentions and self threads that rest on the same moments
|
|
106
|
+
// as an affair, and that speak of it (two shared words, one from its title,
|
|
107
|
+
// or close in meaning when both have vectors). At most two short lines: the first and the latest of a
|
|
108
|
+
// revised note ("当时想 / 后来想"), then an intention of hers, then a self
|
|
109
|
+
// thread. Records already in front of her are left out. Each line carries
|
|
110
|
+
// the index id it came from, for the record of what came to mind.
|
|
111
|
+
export function mineFor(
|
|
112
|
+
mind,
|
|
113
|
+
steps,
|
|
114
|
+
{ session, now = Date.now(), exclude = new Set(), close = () => false } = {},
|
|
115
|
+
) {
|
|
116
|
+
const db = mind.db;
|
|
117
|
+
const ids = steps.map((s) => `episode:${s.id}`);
|
|
118
|
+
if (!ids.length) return [];
|
|
119
|
+
const marks = ids.map(() => "?").join(",");
|
|
120
|
+
const linked = db
|
|
121
|
+
.prepare(
|
|
122
|
+
`SELECT DISTINCT b.item_id id FROM mind_roots a JOIN mind_roots b ON b.root=a.root
|
|
123
|
+
WHERE a.item_id IN (${marks}) AND a.root NOT LIKE 's:%'
|
|
124
|
+
AND (b.item_id LIKE 'thought:%' OR b.item_id LIKE 'anticipation:%' OR b.item_id LIKE 'self:%')`,
|
|
125
|
+
)
|
|
126
|
+
.all(...ids)
|
|
127
|
+
.map((r) => r.id)
|
|
128
|
+
.filter((id) => !exclude.has(id));
|
|
129
|
+
if (!linked.length) return [];
|
|
130
|
+
const terms = interestTerms(
|
|
131
|
+
steps.map((s) => [s.title, s.content, s.outcome].join(" ")),
|
|
132
|
+
);
|
|
133
|
+
// One shared pair is too easy to come by ("最近", "晚上"); it counts only
|
|
134
|
+
// when it is in the affair's own title. Otherwise two, or close meaning.
|
|
135
|
+
const titled = interestTerms(steps.map((s) => s.title));
|
|
136
|
+
const speaks = (id, words) => {
|
|
137
|
+
const shared = [...interestTerms([words])].filter((t) => terms.has(t));
|
|
138
|
+
return (
|
|
139
|
+
shared.length >= 2 || shared.some((t) => titled.has(t)) || close(id, ids)
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
const fits = (words) =>
|
|
143
|
+
words && !fixedReplyHabit(words) && mind.meetings.sayable(words, session);
|
|
144
|
+
const lines = [];
|
|
145
|
+
// Notes: one revision chain at a time, told by its first and latest step.
|
|
146
|
+
const notes = new Map();
|
|
147
|
+
for (const id of linked.filter((x) => x.startsWith("thought:"))) {
|
|
148
|
+
const row = db
|
|
149
|
+
.prepare(
|
|
150
|
+
"SELECT * FROM mind_thoughts WHERE id=? AND hidden=0 AND created<=?",
|
|
151
|
+
)
|
|
152
|
+
.get(id.slice(8), now);
|
|
153
|
+
if (!row || !parse(row.sessions, []).includes(session)) continue;
|
|
154
|
+
if (!fits(row.content) || !speaks(id, row.content)) continue;
|
|
155
|
+
notes.set(row.id, row);
|
|
156
|
+
}
|
|
157
|
+
const chainOf = (row) => {
|
|
158
|
+
let head = row;
|
|
159
|
+
for (let seen = 0; head.parent_id && seen < 20; seen++) {
|
|
160
|
+
const up = db
|
|
161
|
+
.prepare("SELECT * FROM mind_thoughts WHERE id=? AND hidden=0")
|
|
162
|
+
.get(head.parent_id);
|
|
163
|
+
if (!up) break;
|
|
164
|
+
head = up;
|
|
165
|
+
}
|
|
166
|
+
let last = head;
|
|
167
|
+
for (let seen = 0; seen < 20; seen++) {
|
|
168
|
+
const down = db
|
|
169
|
+
.prepare(
|
|
170
|
+
"SELECT * FROM mind_thoughts WHERE parent_id=? AND hidden=0 AND created<=? ORDER BY created DESC LIMIT 1",
|
|
171
|
+
)
|
|
172
|
+
.get(last.id, now);
|
|
173
|
+
if (!down) break;
|
|
174
|
+
last = down;
|
|
175
|
+
}
|
|
176
|
+
return { head, last };
|
|
177
|
+
};
|
|
178
|
+
const chains = new Map();
|
|
179
|
+
for (const row of [...notes.values()].sort((a, b) => a.created - b.created)) {
|
|
180
|
+
const { head, last } = chainOf(row);
|
|
181
|
+
if (!chains.has(head.id)) chains.set(head.id, { head, last });
|
|
182
|
+
}
|
|
183
|
+
for (const { head, last } of chains.values()) {
|
|
184
|
+
if (lines.length >= 2) break;
|
|
185
|
+
if (exclude.has(`thought:${head.id}`)) continue;
|
|
186
|
+
lines.push({
|
|
187
|
+
id: `thought:${head.id}`,
|
|
188
|
+
text: `当时想:${text(head.content, LINE)}`,
|
|
189
|
+
});
|
|
190
|
+
if (
|
|
191
|
+
last.id !== head.id &&
|
|
192
|
+
lines.length < 2 &&
|
|
193
|
+
!exclude.has(`thought:${last.id}`) &&
|
|
194
|
+
fits(last.content)
|
|
195
|
+
)
|
|
196
|
+
lines.push({
|
|
197
|
+
id: `thought:${last.id}`,
|
|
198
|
+
text: `后来想:${text(last.content, LINE)}`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Her own intentions about it.
|
|
202
|
+
for (const id of linked.filter((x) => x.startsWith("anticipation:"))) {
|
|
203
|
+
if (lines.length >= 2) break;
|
|
204
|
+
const row = db
|
|
205
|
+
.prepare(
|
|
206
|
+
`SELECT * FROM mind_anticipations WHERE id=? AND created<=? AND kind IN ('promise','plan')
|
|
207
|
+
AND id NOT IN (SELECT target_id FROM mind_revocations WHERE target_kind='anticipation')`,
|
|
208
|
+
)
|
|
209
|
+
.get(id.slice(13), now);
|
|
210
|
+
if (!row || (row.discretion !== "open" && row.session_id !== session))
|
|
211
|
+
continue;
|
|
212
|
+
if (!fits(row.content) || !speaks(id, row.content)) continue;
|
|
213
|
+
lines.push({
|
|
214
|
+
id,
|
|
215
|
+
text: `我打算过:${text(row.content, LINE)}(${ANTICIPATION_STATE[row.status] || row.status})`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// A self thread that grew from it and is not already in view.
|
|
219
|
+
for (const id of linked.filter((x) => x.startsWith("self:"))) {
|
|
220
|
+
if (lines.length >= 2) break;
|
|
221
|
+
const row = mind.self
|
|
222
|
+
.latest(now)
|
|
223
|
+
.find((t) => t.thread === id.slice(5) && t.status !== "closed");
|
|
224
|
+
if (!row || !fits(row.content) || !speaks(id, row.content)) continue;
|
|
225
|
+
lines.push({
|
|
226
|
+
id,
|
|
227
|
+
text: `${SELF_KINDS[row.kind] || "自己"}:${text(row.content, LINE)}`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return lines.slice(0, 2);
|
|
231
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { recallTokens } from "./store.js";
|
|
2
|
+
import { migrateRoots } from "./roots.js";
|
|
2
3
|
|
|
3
4
|
// The recall index is derived: every row can be rebuilt from the tables that
|
|
4
5
|
// hold her actual memories, so it may be dropped and refilled at any time.
|
|
@@ -129,4 +130,5 @@ export function migrateRecall(db) {
|
|
|
129
130
|
JSON.stringify({ time: Date.now() }),
|
|
130
131
|
);
|
|
131
132
|
}
|
|
133
|
+
migrateRoots(db);
|
|
132
134
|
}
|
|
@@ -20,15 +20,42 @@ const fromBlob = (blob) => {
|
|
|
20
20
|
);
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
const kindOf = (id) => String(id).slice(0, String(id).indexOf(":"));
|
|
24
|
+
|
|
23
25
|
// The searchable shadow of her memories: one row per item, its words for
|
|
24
26
|
// keyword search, its people for structured lookups, and vectors kept per
|
|
25
27
|
// embedding profile. Vectors of the active profile are held in memory for
|
|
26
|
-
// exact cosine search
|
|
28
|
+
// exact cosine search, split by kind so a search for facts never walks a
|
|
29
|
+
// month of messages. The memory copy is kept in step item by item: a new
|
|
30
|
+
// message, a rewritten fact or a removed item touches only its own entry.
|
|
31
|
+
// Reading every vector back from the database happens once per profile,
|
|
32
|
+
// and again only after a rolled-back write.
|
|
27
33
|
export class RecallStore {
|
|
28
34
|
constructor(db) {
|
|
29
35
|
this.db = db;
|
|
30
36
|
this.vectors = new Map();
|
|
31
37
|
}
|
|
38
|
+
// Forget the memory copy; the next search reads it again.
|
|
39
|
+
invalidate() {
|
|
40
|
+
for (const cache of this.vectors.values()) cache.dirty = true;
|
|
41
|
+
}
|
|
42
|
+
#drop(id) {
|
|
43
|
+
for (const cache of this.vectors.values()) {
|
|
44
|
+
if (cache.dirty) continue;
|
|
45
|
+
const part = cache.kinds.get(kindOf(id));
|
|
46
|
+
const at = part?.index.get(id);
|
|
47
|
+
if (at === undefined) continue;
|
|
48
|
+
const last = part.ids.length - 1;
|
|
49
|
+
if (at !== last) {
|
|
50
|
+
part.ids[at] = part.ids[last];
|
|
51
|
+
part.rows[at] = part.rows[last];
|
|
52
|
+
part.index.set(part.ids[at], at);
|
|
53
|
+
}
|
|
54
|
+
part.ids.pop();
|
|
55
|
+
part.rows.pop();
|
|
56
|
+
part.index.delete(id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
32
59
|
get(id) {
|
|
33
60
|
return this.db.prepare("SELECT * FROM mind_index WHERE id=?").get(id);
|
|
34
61
|
}
|
|
@@ -95,8 +122,12 @@ export class RecallStore {
|
|
|
95
122
|
this.db
|
|
96
123
|
.prepare("INSERT INTO mind_index_fts(item_id,tokens) VALUES (?,?)")
|
|
97
124
|
.run(item.id, tokens);
|
|
98
|
-
|
|
99
|
-
|
|
125
|
+
if (old) {
|
|
126
|
+
this.db
|
|
127
|
+
.prepare("DELETE FROM mind_vectors WHERE item_id=?")
|
|
128
|
+
.run(item.id);
|
|
129
|
+
this.#drop(item.id);
|
|
130
|
+
}
|
|
100
131
|
}
|
|
101
132
|
this.db
|
|
102
133
|
.prepare("DELETE FROM mind_index_people WHERE item_id=?")
|
|
@@ -114,7 +145,7 @@ export class RecallStore {
|
|
|
114
145
|
this.db.prepare("DELETE FROM mind_index_fts WHERE item_id=?").run(id);
|
|
115
146
|
this.db.prepare("DELETE FROM mind_index_people WHERE item_id=?").run(id);
|
|
116
147
|
this.db.prepare("DELETE FROM mind_vectors WHERE item_id=?").run(id);
|
|
117
|
-
|
|
148
|
+
this.#drop(id);
|
|
118
149
|
return !!existed;
|
|
119
150
|
}
|
|
120
151
|
// Re-read one memory from where it lives. Gone or hidden means gone here.
|
|
@@ -150,6 +181,7 @@ export class RecallStore {
|
|
|
150
181
|
this.db.exec("RELEASE recall_reconcile");
|
|
151
182
|
} catch (error) {
|
|
152
183
|
this.db.exec("ROLLBACK TO recall_reconcile");
|
|
184
|
+
this.invalidate();
|
|
153
185
|
this.db.exec("RELEASE recall_reconcile");
|
|
154
186
|
throw error;
|
|
155
187
|
}
|
|
@@ -199,6 +231,7 @@ export class RecallStore {
|
|
|
199
231
|
this.db.exec("RELEASE recall_catch_up");
|
|
200
232
|
} catch (error) {
|
|
201
233
|
this.db.exec("ROLLBACK TO recall_catch_up");
|
|
234
|
+
this.invalidate();
|
|
202
235
|
this.db.exec("RELEASE recall_catch_up");
|
|
203
236
|
throw error;
|
|
204
237
|
}
|
|
@@ -232,6 +265,7 @@ export class RecallStore {
|
|
|
232
265
|
this.db.exec("COMMIT");
|
|
233
266
|
} catch (error) {
|
|
234
267
|
this.db.exec("ROLLBACK");
|
|
268
|
+
this.invalidate();
|
|
235
269
|
throw error;
|
|
236
270
|
}
|
|
237
271
|
await new Promise((resolve) => setImmediate(resolve));
|
|
@@ -271,6 +305,27 @@ export class RecallStore {
|
|
|
271
305
|
)
|
|
272
306
|
.get(profile);
|
|
273
307
|
}
|
|
308
|
+
// Vectors are derived data kept per embedding profile. After a model
|
|
309
|
+
// change the old profile's rows would stay forever, doubling the table
|
|
310
|
+
// with every switch. The active profile and the most recent other one (a
|
|
311
|
+
// way back without paying to embed everything again) are kept; older
|
|
312
|
+
// profiles are dropped. Returns how many rows were removed.
|
|
313
|
+
pruneProfiles(active, { keepOthers = 1 } = {}) {
|
|
314
|
+
if (!active) return 0;
|
|
315
|
+
const others = this.db
|
|
316
|
+
.prepare(
|
|
317
|
+
"SELECT profile,MAX(created) last FROM mind_vectors WHERE profile!=? GROUP BY profile ORDER BY last DESC",
|
|
318
|
+
)
|
|
319
|
+
.all(active)
|
|
320
|
+
.slice(keepOthers);
|
|
321
|
+
let removed = 0;
|
|
322
|
+
for (const { profile } of others)
|
|
323
|
+
removed += Number(
|
|
324
|
+
this.db.prepare("DELETE FROM mind_vectors WHERE profile=?").run(profile)
|
|
325
|
+
.changes,
|
|
326
|
+
);
|
|
327
|
+
return removed;
|
|
328
|
+
}
|
|
274
329
|
// A vector is stored only if the words it was made from are still current.
|
|
275
330
|
saveVector(id, profile, hash, vector, now) {
|
|
276
331
|
const current = this.db
|
|
@@ -283,53 +338,67 @@ export class RecallStore {
|
|
|
283
338
|
)
|
|
284
339
|
.run(id, profile, hash, vector.length, toBlob(vector), now);
|
|
285
340
|
const cache = this.vectors.get(profile);
|
|
286
|
-
if (cache && !cache.dirty)
|
|
287
|
-
|
|
288
|
-
if (at === undefined) {
|
|
289
|
-
cache.index.set(id, cache.ids.length);
|
|
290
|
-
cache.ids.push(id);
|
|
291
|
-
cache.rows.push(vector);
|
|
292
|
-
} else cache.rows[at] = vector;
|
|
293
|
-
}
|
|
341
|
+
if (cache && !cache.dirty)
|
|
342
|
+
this.#place(cache, id, Float32Array.from(vector));
|
|
294
343
|
return true;
|
|
295
344
|
}
|
|
345
|
+
#place(cache, id, vector) {
|
|
346
|
+
const kind = kindOf(id);
|
|
347
|
+
let part = cache.kinds.get(kind);
|
|
348
|
+
if (!part) {
|
|
349
|
+
part = { ids: [], rows: [], index: new Map() };
|
|
350
|
+
cache.kinds.set(kind, part);
|
|
351
|
+
}
|
|
352
|
+
const at = part.index.get(id);
|
|
353
|
+
if (at === undefined) {
|
|
354
|
+
part.index.set(id, part.ids.length);
|
|
355
|
+
part.ids.push(id);
|
|
356
|
+
part.rows.push(vector);
|
|
357
|
+
} else part.rows[at] = vector;
|
|
358
|
+
}
|
|
296
359
|
#cache(profile) {
|
|
297
360
|
let cache = this.vectors.get(profile);
|
|
298
361
|
if (!cache || cache.dirty) {
|
|
299
|
-
cache = {
|
|
362
|
+
cache = { kinds: new Map(), dirty: false };
|
|
300
363
|
for (const row of this.db
|
|
301
364
|
.prepare(
|
|
302
365
|
"SELECT v.item_id,v.vector FROM mind_vectors v JOIN mind_index i ON i.id=v.item_id AND i.text_hash=v.text_hash WHERE v.profile=?",
|
|
303
366
|
)
|
|
304
|
-
.iterate(profile))
|
|
305
|
-
cache
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
367
|
+
.iterate(profile))
|
|
368
|
+
this.#place(cache, row.item_id, fromBlob(row.vector));
|
|
369
|
+
// Only the active profile is searched; a copy of an old one would sit
|
|
370
|
+
// in memory for nothing.
|
|
371
|
+
for (const key of this.vectors.keys())
|
|
372
|
+
if (key !== profile) this.vectors.delete(key);
|
|
309
373
|
this.vectors.set(profile, cache);
|
|
310
374
|
}
|
|
311
375
|
return cache;
|
|
312
376
|
}
|
|
313
377
|
vector(id, profile) {
|
|
314
|
-
const
|
|
315
|
-
const at =
|
|
316
|
-
return at === undefined ? null :
|
|
378
|
+
const part = this.#cache(profile).kinds.get(kindOf(id));
|
|
379
|
+
const at = part?.index.get(id);
|
|
380
|
+
return at === undefined ? null : part.rows[at];
|
|
317
381
|
}
|
|
318
|
-
// Exact nearest items. `
|
|
319
|
-
// recalled here never takes a
|
|
382
|
+
// Exact nearest items. `kinds` limits the walk to those kinds; `allow`
|
|
383
|
+
// filters before ranking, so what may not be recalled here never takes a
|
|
384
|
+
// place in the list.
|
|
320
385
|
nearest(
|
|
321
386
|
profile,
|
|
322
387
|
query,
|
|
323
|
-
{ limit = 50, allow = () => true, minScore = 0 } = {},
|
|
388
|
+
{ limit = 50, allow = () => true, minScore = 0, kinds = null } = {},
|
|
324
389
|
) {
|
|
325
390
|
const cache = this.#cache(profile);
|
|
326
391
|
const hits = [];
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
392
|
+
const parts = kinds
|
|
393
|
+
? kinds.map((kind) => cache.kinds.get(kind)).filter(Boolean)
|
|
394
|
+
: [...cache.kinds.values()];
|
|
395
|
+
for (const part of parts)
|
|
396
|
+
for (let i = 0; i < part.ids.length; i++) {
|
|
397
|
+
const id = part.ids[i];
|
|
398
|
+
if (!allow(id)) continue;
|
|
399
|
+
const score = dot(query, part.rows[i]);
|
|
400
|
+
if (score >= minScore) hits.push({ id, score });
|
|
401
|
+
}
|
|
333
402
|
hits.sort((a, b) => b.score - a.score);
|
|
334
403
|
return hits.slice(0, limit);
|
|
335
404
|
}
|
|
@@ -365,4 +434,18 @@ export class RecallStore {
|
|
|
365
434
|
.all(id)
|
|
366
435
|
.map((row) => row.user_id);
|
|
367
436
|
}
|
|
437
|
+
// The people of many items in one read, for ranking a candidate list.
|
|
438
|
+
peopleOf(ids) {
|
|
439
|
+
const out = new Map(ids.map((id) => [id, []]));
|
|
440
|
+
for (let i = 0; i < ids.length; i += 400) {
|
|
441
|
+
const part = ids.slice(i, i + 400);
|
|
442
|
+
for (const row of this.db
|
|
443
|
+
.prepare(
|
|
444
|
+
`SELECT item_id,user_id FROM mind_index_people WHERE item_id IN (${part.map(() => "?").join(",")})`,
|
|
445
|
+
)
|
|
446
|
+
.all(...part))
|
|
447
|
+
out.get(row.item_id)?.push(row.user_id);
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
368
451
|
}
|
package/server/mind/salience.js
CHANGED
|
@@ -97,7 +97,28 @@ export function grounded(content, texts) {
|
|
|
97
97
|
const said = interestTerms(texts);
|
|
98
98
|
const terms = [...interestTerms([content])];
|
|
99
99
|
if (!terms.length || !said.size) return false;
|
|
100
|
-
|
|
100
|
+
if (said.has(terms.at(-1))) return true;
|
|
101
|
+
// A paraphrase that ends in its own words ("把篮球也推掉了") is still
|
|
102
|
+
// grounded when every clause it makes touches something that was said.
|
|
103
|
+
// A word repeated across clauses, usually the person's name, does not
|
|
104
|
+
// count for any of them. A single clause keeps the rule above: its last
|
|
105
|
+
// word is where the claim sits ("他下个月要结婚").
|
|
106
|
+
const clauses = String(content)
|
|
107
|
+
.split(/[,,。;;!!??\n]+/)
|
|
108
|
+
.map((clause) => [...interestTerms([clause])])
|
|
109
|
+
.filter((clause) => clause.length);
|
|
110
|
+
const seen = new Map();
|
|
111
|
+
for (const clause of clauses)
|
|
112
|
+
for (const term of new Set(clause))
|
|
113
|
+
seen.set(term, (seen.get(term) || 0) + 1);
|
|
114
|
+
return (
|
|
115
|
+
clauses.length > 1 &&
|
|
116
|
+
clauses.every(
|
|
117
|
+
(clause) =>
|
|
118
|
+
new Set(clause.filter((term) => seen.get(term) === 1 && said.has(term)))
|
|
119
|
+
.size > 0,
|
|
120
|
+
)
|
|
121
|
+
);
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
// One set, or one set per speaker. An empty list means nothing was said.
|
package/server/mind/schema.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { migrateAftermath } from "./aftermath.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { NATURE_DEFAULTS, NATURE_FIELDS } from "./nature.js";
|
|
3
4
|
import { evidence, parse } from "./util.js";
|
|
@@ -88,6 +89,10 @@ export function migrateMind(db, store) {
|
|
|
88
89
|
addColumn("mind_meetings", "reason", "TEXT NOT NULL DEFAULT ''");
|
|
89
90
|
addColumn("mind_anticipations", "activity", "TEXT NOT NULL DEFAULT ''");
|
|
90
91
|
addColumn("mind_self", "subject", "TEXT");
|
|
92
|
+
// What came to mind when she chose: index ids of the past put in front of
|
|
93
|
+
// her for that turn. Kept for good, unlike the turn's trace.
|
|
94
|
+
addColumn("mind_choices", "recalled", "TEXT NOT NULL DEFAULT '[]'");
|
|
95
|
+
migrateAftermath(addColumn);
|
|
91
96
|
db.prepare(
|
|
92
97
|
"UPDATE mind_runs SET status='interrupted',finished=? WHERE status='running'",
|
|
93
98
|
).run(Date.now());
|