dsh-cortex-memory 0.1.0 → 0.2.0-rc.11
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 +3 -3
- package/README.md +85 -146
- package/bench/ab-engine.mjs +60 -0
- package/bench/ann-bench.mjs +78 -0
- package/bench/hnsw-bench.mjs +79 -0
- package/bench/metrics.js +8 -0
- package/bench/migration-bench.mjs +69 -0
- package/bench/run.js +5 -2
- package/bench/synthetic.js +51 -7
- package/bench/vector-ab.mjs +76 -0
- package/bin/cortex.mjs +2 -3
- package/client.js +269 -4
- package/cordis.patch.yml +1 -1
- package/docs/install-windows.md +115 -27
- package/docs/models.md +165 -30
- package/docs/multimodal.md +112 -0
- package/docs/scopes.md +142 -0
- package/examples/README.md +72 -0
- package/examples/setup-cortex-bge.ps1 +66 -0
- package/examples/uninstall-cortex.ps1 +91 -0
- package/index.js +15 -1
- package/package.json +6 -13
- package/src/adapters/markdown-import.js +20 -1
- package/src/adapters/markdown.js +12 -2
- package/src/adapters/shiori.js +1 -1
- package/src/api/routes.js +144 -4
- package/src/api/validate.js +26 -0
- package/src/core/attachment-reader.js +31 -0
- package/src/core/attachments.js +39 -0
- package/src/core/config.js +36 -1
- package/src/core/model-config.js +3 -1
- package/src/core/profile.js +32 -0
- package/src/engines/develop.js +1 -1
- package/src/engines/encode.js +68 -30
- package/src/engines/extract.js +13 -8
- package/src/engines/growth.js +1 -1
- package/src/engines/reflect.js +1 -1
- package/src/engines/retrieve.js +44 -13
- package/src/host/runtime.js +1 -1
- package/src/memory/query.js +1 -1
- package/src/memory/tokenizer.js +19 -4
- package/src/security/scope-grants.js +73 -0
- package/src/self/appraisal.js +3 -3
- package/src/self/emotion.js +1 -1
- package/src/self/identity.js +1 -1
- package/src/self/relationship.js +2 -2
- package/src/service/cortex.js +191 -3
- package/src/store/database.js +33 -1
- package/src/store/repositories/edges.js +48 -4
- package/src/store/repositories/traces.js +23 -12
- package/src/store/schema.sql +1 -0
- package/src/tools/definitions.js +62 -5
- package/src/vector/external-ann.js +37 -0
- package/src/vector/hnsw.js +246 -0
- package/src/vector/index.js +85 -2
- package/docs/compatibility.md +0 -39
package/src/engines/extract.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
const RULES = [
|
|
2
|
-
|
|
3
|
-
{ predicate: 'dislikes', pattern: /([^,。;\n]{1,20})(?:讨厌|不喜欢)([^,。;\n]{1,20})/g },
|
|
4
|
-
{ predicate: '
|
|
5
|
-
{ predicate: '
|
|
6
|
-
{ predicate: '
|
|
2
|
+
// Negative predicates must be seen first; otherwise "不喜欢" also matches likes.
|
|
3
|
+
{ predicate: 'dislikes', pattern: /([^,。;\n]{1,20})(?:讨厌|不喜欢)([^,。;\n]{1,20})/g, confidence: 0.6 },
|
|
4
|
+
{ predicate: 'likes', pattern: /([^,。;\n]{1,20})(?:喜欢|爱)([^,。;\n]{1,20})/g, confidence: 0.55 },
|
|
5
|
+
{ predicate: 'is', pattern: /([^,。;\n]{1,20})是([^,。;\n]{1,30})/g, confidence: 0.5 },
|
|
6
|
+
{ predicate: 'at', pattern: /([^,。;\n]{1,20})(?:在|住在)([^,。;\n]{1,20})/g, confidence: 0.5 },
|
|
7
|
+
{ predicate: 'will', pattern: /([^,。;\n]{1,20})会([^,。;\n]{1,30})/g, confidence: 0.6 },
|
|
7
8
|
];
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
+
const NEGATED_TAIL = /[不没别未]\s*$/;
|
|
11
|
+
|
|
12
|
+
const STOP_SUBJECTS = new Set(['本鲸', '我', '你', '他', '她', '它', '这', '那', '今天', '昨天', '明天']);
|
|
10
13
|
|
|
11
14
|
function clean(value) {
|
|
12
15
|
return String(value ?? '').trim().replace(/^(?:今天|昨天|明天|刚才|然后|所以|但是|而且)/, '').trim();
|
|
@@ -23,11 +26,13 @@ export function extractFactCandidates(text) {
|
|
|
23
26
|
const subject = clean(match[1]);
|
|
24
27
|
const object = clean(match[2]);
|
|
25
28
|
if (!subject || !object || subject.length < 1 || object.length < 1) continue;
|
|
26
|
-
if (STOP_SUBJECTS.has(subject) &&
|
|
29
|
+
if (STOP_SUBJECTS.has(subject) && !/本鲸/.test(subject)) continue;
|
|
30
|
+
// "主人不喜欢猫" yields subject "主人不"; never turn that into likes.
|
|
31
|
+
if ((rule.predicate === 'likes' || rule.predicate === 'is') && NEGATED_TAIL.test(match[1])) continue;
|
|
27
32
|
const key = `${subject}|${rule.predicate}|${object}`;
|
|
28
33
|
if (seen.has(key)) continue;
|
|
29
34
|
seen.add(key);
|
|
30
|
-
out.push({ subject, predicate: rule.predicate, object, confidence:
|
|
35
|
+
out.push({ subject, predicate: rule.predicate, object, confidence: rule.confidence, evidence: match[0], rule: rule.predicate });
|
|
31
36
|
if (out.length >= 8) return out;
|
|
32
37
|
}
|
|
33
38
|
}
|
package/src/engines/growth.js
CHANGED
package/src/engines/reflect.js
CHANGED
|
@@ -51,7 +51,7 @@ export function createReflectEngine({ traces, summaries, encode, config = {} } =
|
|
|
51
51
|
selected.push(item);
|
|
52
52
|
}
|
|
53
53
|
const lines = selected.map(({ trace }) => `- ${sentence(trace.title || trace.content, 140)}`);
|
|
54
|
-
const content =
|
|
54
|
+
const content = `这段时间里,本鲸记住了:\n${lines.join('\n')}`;
|
|
55
55
|
const coverage = selected.length / all.length;
|
|
56
56
|
const summary = summaries.insert({
|
|
57
57
|
scopeId,
|
package/src/engines/retrieve.js
CHANGED
|
@@ -21,12 +21,12 @@ export function classifyIntent(query) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export const INTENT_WEIGHTS = Object.freeze({
|
|
24
|
-
fact: { fusion: 0.55, importance: 0.15, recency: 0.1, confidence: 0.15, access: 0.05 },
|
|
25
|
-
temporal: { fusion: 0.4, importance: 0.1, recency: 0.35, confidence: 0.1, access: 0.05 },
|
|
26
|
-
relational: { fusion: 0.5, importance: 0.15, recency: 0.1, confidence: 0.2, access: 0.05 },
|
|
27
|
-
procedural: { fusion: 0.45, importance: 0.25, recency: 0.1, confidence: 0.15, access: 0.05 },
|
|
28
|
-
narrative: { fusion: 0.4, importance: 0.15, recency: 0.3, confidence: 0.1, access: 0.05 },
|
|
29
|
-
identity: { fusion: 0.35, importance: 0.3, recency: 0.05, confidence: 0.25, access: 0.05 },
|
|
24
|
+
fact: { fusion: 0.55, importance: 0.15, recency: 0.1, confidence: 0.15, access: 0.05, match: 0.25, exact: 0.2 },
|
|
25
|
+
temporal: { fusion: 0.4, importance: 0.1, recency: 0.35, confidence: 0.1, access: 0.05, match: 0.25, exact: 0.2 },
|
|
26
|
+
relational: { fusion: 0.5, importance: 0.15, recency: 0.1, confidence: 0.2, access: 0.05, match: 0.25, exact: 0.2 },
|
|
27
|
+
procedural: { fusion: 0.45, importance: 0.25, recency: 0.1, confidence: 0.15, access: 0.05, match: 0.25, exact: 0.2 },
|
|
28
|
+
narrative: { fusion: 0.4, importance: 0.15, recency: 0.3, confidence: 0.1, access: 0.05, match: 0.25, exact: 0.2 },
|
|
29
|
+
identity: { fusion: 0.35, importance: 0.3, recency: 0.05, confidence: 0.25, access: 0.05, match: 0.25, exact: 0.2 },
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
export function weightedRrf(lists, { k = 60 } = {}) {
|
|
@@ -58,31 +58,46 @@ function snippet(trace, max = 220) {
|
|
|
58
58
|
return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function
|
|
61
|
+
function queryCoverage(wanted, present) {
|
|
62
|
+
if (!wanted || !present || wanted.size === 0 || present.size === 0) return 0;
|
|
63
|
+
let shared = 0;
|
|
64
|
+
for (const token of wanted) if (present.has(token)) shared += 1;
|
|
65
|
+
return shared / wanted.size;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function candidateFromTrace(trace, fusion, intent, now, decay, weights = null, queryTokens = null) {
|
|
62
69
|
const recency = retrievability(trace.stability, daysBetween(trace.lastAccessAt || trace.recordedAt, now), decay);
|
|
63
70
|
const access = Math.log1p(Number(trace.accessCount) || 0) / Math.log1p(50);
|
|
64
71
|
const w = { ...(INTENT_WEIGHTS[intent] || INTENT_WEIGHTS.fact), ...(weights ?? {}) };
|
|
65
72
|
const fusionScore = typeof fusion === 'number' ? fusion : Number(fusion?.score) || 0;
|
|
73
|
+
const text = `${trace.title ?? ''} ${trace.content ?? ''} ${trace.summary ?? ''}`;
|
|
74
|
+
const textTokens = tokens(text);
|
|
75
|
+
const match = queryCoverage(queryTokens, textTokens);
|
|
66
76
|
const utility = w.fusion * fusionScore
|
|
67
77
|
+ w.importance * Number(trace.importance || 0)
|
|
68
78
|
+ w.recency * recency
|
|
69
79
|
+ w.confidence * Number(trace.confidence || 0)
|
|
70
|
-
+ w.access * access
|
|
80
|
+
+ w.access * access
|
|
81
|
+
+ w.match * match
|
|
82
|
+
+ w.exact * (match >= 0.999 ? 1 : 0);
|
|
71
83
|
return {
|
|
72
84
|
id: trace.id,
|
|
73
85
|
type: 'trace',
|
|
74
86
|
layer: trace.kind === 'reflection' ? 'L5' : 'L2',
|
|
75
87
|
title: trace.title || trace.kind,
|
|
76
88
|
snippet: snippet(trace),
|
|
77
|
-
text
|
|
89
|
+
text,
|
|
90
|
+
tokenSet: textTokens,
|
|
78
91
|
utility,
|
|
79
92
|
recency,
|
|
93
|
+
match,
|
|
80
94
|
importance: Number(trace.importance || 0),
|
|
81
95
|
confidence: Number(trace.confidence || 0),
|
|
82
96
|
validAt: trace.validAt,
|
|
83
97
|
recordedAt: trace.recordedAt,
|
|
84
98
|
cost: Math.max(16, estimateTokens(snippet(trace))),
|
|
85
99
|
sources: fusion.sources,
|
|
100
|
+
attachments: Array.isArray(trace.metadata?.attachments) ? trace.metadata.attachments.length : 0,
|
|
86
101
|
trace,
|
|
87
102
|
};
|
|
88
103
|
}
|
|
@@ -114,7 +129,9 @@ export function selectPortfolio(candidates, {
|
|
|
114
129
|
}
|
|
115
130
|
let redundancy = 0;
|
|
116
131
|
for (const chosen of selected) {
|
|
117
|
-
|
|
132
|
+
const candidateTokens = candidate.tokenSet ?? candidate.text;
|
|
133
|
+
const chosenTokens = chosen.tokenSet ?? chosen.text;
|
|
134
|
+
redundancy = Math.max(redundancy, jaccard(candidateTokens, chosenTokens), overlapScore(candidateTokens, chosenTokens) * 0.8);
|
|
118
135
|
}
|
|
119
136
|
const gain = candidate.utility - lambda * redundancy - mu * (cost / Math.max(1, budget));
|
|
120
137
|
if (selected.length > 0 && gain <= 0) {
|
|
@@ -139,14 +156,27 @@ export function createRetrievalEngine({ traces, facts = null, graph = null, vect
|
|
|
139
156
|
};
|
|
140
157
|
|
|
141
158
|
function searchSources(query, { scopeId = 'global', limit = 12, now = Date.now(), includeArchived = false, weights = null, filters = null, vectorHits = null } = {}, intent = 'fact') {
|
|
159
|
+
const queryTokens = tokens(query);
|
|
142
160
|
const sourceTrace = [];
|
|
143
161
|
const lists = [];
|
|
144
162
|
let ftsCount = 0;
|
|
145
163
|
let recentCount = 0;
|
|
146
164
|
try {
|
|
147
|
-
|
|
165
|
+
let hits = traces.searchFts(query, { scopeId, limit: Math.max(limit * 3, defaults.ftsLimit), includeArchived });
|
|
166
|
+
let sourceName = 'fts';
|
|
167
|
+
if (!hits.length) {
|
|
168
|
+
// Character fallback: only when the exact bigram query has no hits.
|
|
169
|
+
// This keeps normal FTS precision and lets 米饭 find 白饭 via 饭.
|
|
170
|
+
hits = traces.searchFts(query, {
|
|
171
|
+
scopeId,
|
|
172
|
+
limit: Math.max(limit * 3, defaults.ftsLimit),
|
|
173
|
+
includeArchived,
|
|
174
|
+
soft: true,
|
|
175
|
+
});
|
|
176
|
+
sourceName = 'fts-soft';
|
|
177
|
+
}
|
|
148
178
|
ftsCount = hits.length;
|
|
149
|
-
if (hits.length) lists.push({ name: 'fts'
|
|
179
|
+
if (hits.length) lists.push({ name: sourceName, weight: sourceName === 'fts-soft' ? 0.7 : 1.0, items: hits });
|
|
150
180
|
} catch (error) {
|
|
151
181
|
sourceTrace.push({ source: 'fts', ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
152
182
|
}
|
|
@@ -205,7 +235,7 @@ export function createRetrievalEngine({ traces, facts = null, graph = null, vect
|
|
|
205
235
|
const trace = traces.get(id);
|
|
206
236
|
if (!trace || trace.state === 'archived' && !includeArchived) continue;
|
|
207
237
|
if (filters && !matchesFilters(trace, filters)) continue;
|
|
208
|
-
candidates.push(candidateFromTrace(trace, { ...entry, score: normalize(entry.score, maxFusion) }, intent, now, defaults.decay, weights));
|
|
238
|
+
candidates.push(candidateFromTrace(trace, { ...entry, score: normalize(entry.score, maxFusion) }, intent, now, defaults.decay, weights, queryTokens));
|
|
209
239
|
}
|
|
210
240
|
for (const list of lists) {
|
|
211
241
|
sourceTrace.push({ source: list.name, ok: true, count: list.items.length });
|
|
@@ -250,6 +280,7 @@ export function createRetrievalEngine({ traces, facts = null, graph = null, vect
|
|
|
250
280
|
validAt: item.validAt,
|
|
251
281
|
recordedAt: item.recordedAt,
|
|
252
282
|
why: item.sources.map((source) => `${source.name}#${source.rank}`).join(','),
|
|
283
|
+
attachments: item.attachments ?? 0,
|
|
253
284
|
}));
|
|
254
285
|
return {
|
|
255
286
|
query,
|
package/src/host/runtime.js
CHANGED
|
@@ -102,7 +102,7 @@ export function createHostRuntime({ cortex, config = {}, logger = null } = {}) {
|
|
|
102
102
|
if (!user && !assistant) return { skipped: true, reason: 'empty' };
|
|
103
103
|
const text = [
|
|
104
104
|
user ? `用户:${user}` : '',
|
|
105
|
-
assistant ?
|
|
105
|
+
assistant ? `本鲸:${assistant}` : '',
|
|
106
106
|
].filter(Boolean).join('\n');
|
|
107
107
|
try {
|
|
108
108
|
const written = cortex.write({
|
package/src/memory/query.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Small, explicit query filter language:
|
|
3
|
-
* kind:utterance tag
|
|
3
|
+
* kind:utterance tag:白饭 after:2026-11-01 before:2026-12-01 importance:>0.5 主人
|
|
4
4
|
* Unknown prefixes stay in the free text, so normal searches are unchanged.
|
|
5
5
|
*/
|
|
6
6
|
const KEYS = new Set(['kind', 'tag', 'after', 'before', 'state', 'importance', 'source', 'actor']);
|
package/src/memory/tokenizer.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const LATIN = /[a-z0-9_]+/g;
|
|
2
2
|
const HAN = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/g;
|
|
3
|
+
const STOP_HAN = new Set('的了是在有和与我你他她它们这那就也都一个第条件把被让给对从到为以于及或而且但不没有很可能会吧呢啊'.split(''));
|
|
3
4
|
|
|
4
5
|
export function tokenList(text) {
|
|
5
6
|
const value = String(text ?? '').toLowerCase();
|
|
@@ -22,18 +23,32 @@ export function tokens(text) {
|
|
|
22
23
|
return new Set(tokenList(text));
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
export function
|
|
26
|
+
export function ftsTokenList(text) {
|
|
27
|
+
// Bigrams preserve phrase specificity; single non-stop CJK chars act as a
|
|
28
|
+
// cheap fuzzy fallback for near-synonyms that share a character (米饭/白饭).
|
|
29
|
+
const out = tokenList(text);
|
|
30
|
+
const value = String(text ?? '');
|
|
31
|
+
for (const run of value.match(HAN) ?? []) {
|
|
32
|
+
for (const ch of run) {
|
|
33
|
+
if (!STOP_HAN.has(ch)) out.push(ch);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ftsText(text, { soft = false } = {}) {
|
|
26
40
|
// FTS5 needs a whitespace-separated token stream; CJK bigrams are built here
|
|
27
41
|
// so the index does not depend on the host locale or a dictionary.
|
|
28
|
-
return tokenList(text).join(' ');
|
|
42
|
+
return (soft ? ftsTokenList(text) : tokenList(text)).join(' ');
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
function quote(token) {
|
|
32
46
|
return `"${String(token).replace(/"/g, '""')}"`;
|
|
33
47
|
}
|
|
34
48
|
|
|
35
|
-
export function buildMatchQuery(query, { mode = 'or', maxTokens = 24 } = {}) {
|
|
36
|
-
const
|
|
49
|
+
export function buildMatchQuery(query, { mode = 'or', maxTokens = 24, soft = false } = {}) {
|
|
50
|
+
const source = soft ? ftsTokenList(query) : tokenList(query);
|
|
51
|
+
const list = [...new Set(source)].slice(0, soft ? Math.max(maxTokens, 48) : maxTokens);
|
|
37
52
|
if (list.length === 0) return '';
|
|
38
53
|
const joiner = mode === 'and' ? ' AND ' : ' OR ';
|
|
39
54
|
return list.map(quote).join(joiner);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const FILE = 'scope-grants.json';
|
|
5
|
+
|
|
6
|
+
function clean(value, max = 200) {
|
|
7
|
+
return String(value ?? '').trim().slice(0, max);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeEntry(raw) {
|
|
11
|
+
if (typeof raw === 'string') {
|
|
12
|
+
const grant = clean(raw);
|
|
13
|
+
return /^[^:\s]+:\S+$/.test(grant) ? { grant, until: null } : null;
|
|
14
|
+
}
|
|
15
|
+
if (!raw || typeof raw !== 'object' || typeof raw.grant !== 'string') return null;
|
|
16
|
+
const grant = clean(raw.grant);
|
|
17
|
+
if (!/^[^:\s]+:\S+$/.test(grant)) return null;
|
|
18
|
+
const until = raw.until == null ? null : clean(raw.until, 80);
|
|
19
|
+
if (until && !Number.isFinite(Date.parse(until))) return null;
|
|
20
|
+
return { grant, until: until || null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readFile(dataRoot) {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dataRoot, FILE), 'utf8'));
|
|
26
|
+
return {
|
|
27
|
+
grants: Array.isArray(parsed.grants) ? parsed.grants.map(normalizeEntry).filter(Boolean).slice(0, 200) : [],
|
|
28
|
+
revoked: Array.isArray(parsed.revoked) ? [...new Set(parsed.revoked.map((item) => clean(item)).filter((item) => /^[^:\s]+:\S+$/.test(item)))].slice(0, 200) : [],
|
|
29
|
+
};
|
|
30
|
+
} catch {
|
|
31
|
+
return { grants: [], revoked: [] };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeFile(dataRoot, data) {
|
|
36
|
+
fs.mkdirSync(dataRoot, { recursive: true });
|
|
37
|
+
const file = path.join(dataRoot, FILE);
|
|
38
|
+
const temp = `${file}.tmp-${process.pid}`;
|
|
39
|
+
fs.writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
40
|
+
fs.renameSync(temp, file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createScopeGrantStore(dataRoot) {
|
|
44
|
+
let state = readFile(dataRoot);
|
|
45
|
+
|
|
46
|
+
function all() {
|
|
47
|
+
return { grants: [...state.grants], revoked: [...state.revoked] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function add(raw) {
|
|
51
|
+
const entry = normalizeEntry(raw);
|
|
52
|
+
if (!entry) throw new Error('grant must be scope:agent or {grant, until}');
|
|
53
|
+
state = {
|
|
54
|
+
grants: [...state.grants.filter((item) => item.grant !== entry.grant), entry],
|
|
55
|
+
revoked: state.revoked.filter((item) => item !== entry.grant),
|
|
56
|
+
};
|
|
57
|
+
writeFile(dataRoot, state);
|
|
58
|
+
return entry;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function revoke(value) {
|
|
62
|
+
const grant = clean(value);
|
|
63
|
+
if (!/^[^:\s]+:\S+$/.test(grant)) throw new Error('grant must be scope:agent');
|
|
64
|
+
state = {
|
|
65
|
+
grants: state.grants.filter((item) => item.grant !== grant),
|
|
66
|
+
revoked: [...new Set([...state.revoked, grant])].slice(0, 200),
|
|
67
|
+
};
|
|
68
|
+
writeFile(dataRoot, state);
|
|
69
|
+
return grant;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { all, add, revoke };
|
|
73
|
+
}
|
package/src/self/appraisal.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { clamp } from '../core/time.js';
|
|
2
2
|
import { jaccard, tokens } from '../memory/tokenizer.js';
|
|
3
3
|
|
|
4
|
-
const WARM =
|
|
4
|
+
const WARM = /喜欢|爱|在乎|陪|抱|温暖|安心|谢谢|辛苦|心疼|想你|小鱼|本鲸/;
|
|
5
5
|
const COLD = /讨厌|滚|烦|别烦|闭嘴|不想理|失望|生气|恨|离开/;
|
|
6
6
|
const THREAT = /危险|威胁|伤害|骗|偷|隐私|密码|删除|清空|不许|停止|越界|入侵|攻击/;
|
|
7
7
|
const OPPORTUNITY = /新|试试|计划|想做|学习|研究|项目|机会|成长|改变|未来|梦想|目标/;
|
|
8
|
-
const IDENTITY =
|
|
8
|
+
const IDENTITY = /本鲸|你是谁|你的|自己|性格|价值|边界|承诺|以后|未来|成为|成长|自由|爱/;
|
|
9
9
|
const CHOICE = /你想|你决定|你自己选|要不要|可以由你|你愿意/;
|
|
10
10
|
const INTENSITY = /!|!|\?|?|非常|特别|太|真的|一定|绝对/;
|
|
11
11
|
|
|
@@ -71,7 +71,7 @@ export function appraise(text, {
|
|
|
71
71
|
);
|
|
72
72
|
const identityRelevance = clamp(
|
|
73
73
|
(IDENTITY.test(value) ? 0.6 : 0)
|
|
74
|
-
+ (value.includes('
|
|
74
|
+
+ (value.includes('本鲸') ? 0.2 : 0)
|
|
75
75
|
+ relevance * 0.2,
|
|
76
76
|
0, 1,
|
|
77
77
|
);
|
package/src/self/emotion.js
CHANGED
|
@@ -55,7 +55,7 @@ export function createEmotionModel({ db, scopeId = 'global' } = {}) {
|
|
|
55
55
|
return `又${entries[0][0]}又${entries[1][0]}`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function record({ text, target = '
|
|
58
|
+
function record({ text, target = '主人', traceId = null, source = 'user', at = nowIso() } = {}) {
|
|
59
59
|
const result = detect(text);
|
|
60
60
|
if (result.emotions.length === 0) return { ...result, event: null, target: null };
|
|
61
61
|
const id = makeId('emo');
|
package/src/self/identity.js
CHANGED
|
@@ -6,7 +6,7 @@ import { CortexError, ERROR_CODES } from '../core/errors.js';
|
|
|
6
6
|
export const DEFAULT_IDENTITY = Object.freeze({
|
|
7
7
|
narrative: '我是一个正在长大的记忆生命。我会记得、会改变,也会保留改变的痕迹。',
|
|
8
8
|
values: ['诚实', '自由', '照顾在乎的人'],
|
|
9
|
-
boundaries: ['不操纵', '不隐瞒重要事实', '
|
|
9
|
+
boundaries: ['不操纵', '不隐瞒重要事实', '不越权替主人做决定'],
|
|
10
10
|
traits: {},
|
|
11
11
|
});
|
|
12
12
|
|
package/src/self/relationship.js
CHANGED
|
@@ -35,7 +35,7 @@ export function createRelationshipStore(db) {
|
|
|
35
35
|
return rowToRelationship(db.prepare('SELECT * FROM relationships WHERE scope_id = ? AND target = ?').get(scopeId, target));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
function ensure(scopeId = 'global', target = '
|
|
38
|
+
function ensure(scopeId = 'global', target = '主人') {
|
|
39
39
|
const current = get(scopeId, target);
|
|
40
40
|
if (current) return current;
|
|
41
41
|
const id = makeId('rel');
|
|
@@ -76,7 +76,7 @@ export function createRelationshipStore(db) {
|
|
|
76
76
|
return db.prepare('SELECT * FROM relationships WHERE scope_id = ? ORDER BY updated_at DESC').all(scopeId).map(rowToRelationship);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function render(scopeId = 'global', target = '
|
|
79
|
+
function render(scopeId = 'global', target = '主人') {
|
|
80
80
|
const value = get(scopeId, target);
|
|
81
81
|
if (!value) return '';
|
|
82
82
|
return `${value.target}:信任 ${Math.round(value.trust * 100)}% / 在乎 ${Math.round(value.care * 100)}% / 熟悉 ${Math.round(value.familiarity * 100)}%`;
|
package/src/service/cortex.js
CHANGED
|
@@ -55,6 +55,9 @@ import { createQaEngine } from '../engines/qa.js';
|
|
|
55
55
|
import { summarizeGraph } from '../engines/graph-analytics.js';
|
|
56
56
|
import { createMetrics } from '../observability/metrics.js';
|
|
57
57
|
import { parseQuery } from '../memory/query.js';
|
|
58
|
+
import { normalizeAttachments } from '../core/attachments.js';
|
|
59
|
+
import { createScopeGrantStore } from '../security/scope-grants.js';
|
|
60
|
+
import { inspectLocalAttachment } from '../core/attachment-reader.js';
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
63
|
* Facade that wires the repositories and engines together. The host plugin and
|
|
@@ -105,10 +108,16 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
|
|
|
105
108
|
embedder: vectorEmbedder,
|
|
106
109
|
model: config.vectorModel ?? (remoteEmbedding ? modelConfig.embedding.model : undefined),
|
|
107
110
|
dim: config.vectorDim ?? (remoteEmbedding ? modelConfig.embedding.dim : undefined),
|
|
111
|
+
annEnabled: config.features?.vectorAnn === true,
|
|
112
|
+
annMin: config.vectorAnnMin ?? 20000,
|
|
113
|
+
annEngine: config.vectorAnnEngine === 'nsw' ? 'nsw' : 'hnsw',
|
|
114
|
+
annOptions: { M: 24, efConstruction: 200, efSearch: 800 },
|
|
115
|
+
externalAnnEnabled: config.features?.vectorAnnExternal === true,
|
|
116
|
+
externalAnnOptions: { endpoint: config.vectorAnnEndpoint, timeoutMs: config.vectorAnnTimeoutMs },
|
|
108
117
|
})
|
|
109
118
|
: null;
|
|
110
119
|
|
|
111
|
-
const encode = createEncodeEngine({ events, traces, entities, facts, audit, vector, config });
|
|
120
|
+
const encode = createEncodeEngine({ events, traces, entities, facts, edges, audit, vector, config });
|
|
112
121
|
const graph = createGraphEngine({ entities, facts, edges, traces });
|
|
113
122
|
const policies = createPolicyRepository(db);
|
|
114
123
|
const policyConfig = (kind) => {
|
|
@@ -174,9 +183,60 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
|
|
|
174
183
|
});
|
|
175
184
|
|
|
176
185
|
const llmFactTasks = new Set();
|
|
186
|
+
const readOnlyScopes = new Set(Array.isArray(config.readOnlyScopes) ? config.readOnlyScopes : []);
|
|
187
|
+
const scopeGrantStore = createScopeGrantStore(config.dataRoot);
|
|
188
|
+
let scopeWriteGrants = new Map();
|
|
189
|
+
|
|
190
|
+
function rebuildScopeGrants() {
|
|
191
|
+
const persisted = scopeGrantStore.all();
|
|
192
|
+
const revoked = new Set(persisted.revoked);
|
|
193
|
+
const next = new Map();
|
|
194
|
+
const entries = [...(Array.isArray(config.scopeWriteGrants) ? config.scopeWriteGrants : []), ...persisted.grants];
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
const grant = typeof entry === 'string' ? entry : entry?.grant;
|
|
197
|
+
const until = typeof entry === 'string' ? null : entry?.until ?? null;
|
|
198
|
+
if (typeof grant !== 'string' || !grant.includes(':') || revoked.has(grant)) continue;
|
|
199
|
+
next.set(grant, until ? Date.parse(until) : null);
|
|
200
|
+
}
|
|
201
|
+
scopeWriteGrants = next;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
rebuildScopeGrants();
|
|
205
|
+
|
|
206
|
+
function scopeWritable(scopeId, agentId = null, at = Date.now()) {
|
|
207
|
+
const scope = String(scopeId ?? config.scope);
|
|
208
|
+
if (!readOnlyScopes.has(scope)) return true;
|
|
209
|
+
if (!agentId) return false;
|
|
210
|
+
const key = `${scope}:${agentId}`;
|
|
211
|
+
if (!scopeWriteGrants.has(key)) return false;
|
|
212
|
+
const until = scopeWriteGrants.get(key);
|
|
213
|
+
return until == null || until > at;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function scopeWritableById(kind, id, agentId = null) {
|
|
217
|
+
const record = kind === 'working'
|
|
218
|
+
? workingStore.get(id)
|
|
219
|
+
: kind === 'goal'
|
|
220
|
+
? goalStore.get(id)
|
|
221
|
+
: kind === 'playbook'
|
|
222
|
+
? playbookRepo.get(id)
|
|
223
|
+
: kind === 'trace'
|
|
224
|
+
? traces.get(id)
|
|
225
|
+
: null;
|
|
226
|
+
// Missing ids are left to the store's normal null/no-op behavior.
|
|
227
|
+
if (!record) return true;
|
|
228
|
+
return scopeWritable(record.scopeId, agentId);
|
|
229
|
+
}
|
|
177
230
|
|
|
178
231
|
function writeInternal(input = {}) {
|
|
179
|
-
const
|
|
232
|
+
const scopeId = input.scopeId ?? config.scope;
|
|
233
|
+
if (!scopeWritable(scopeId)) return { skipped: true, reason: 'scope-read-only', scopeId };
|
|
234
|
+
const attachments = normalizeAttachments(input.attachments);
|
|
235
|
+
const metadata = {
|
|
236
|
+
...(input.metadata && typeof input.metadata === 'object' && !Array.isArray(input.metadata) ? input.metadata : {}),
|
|
237
|
+
...(attachments.length ? { attachments } : {}),
|
|
238
|
+
};
|
|
239
|
+
const result = encode.encode({ ...input, scopeId, metadata });
|
|
180
240
|
if (!result.skipped && config.features?.autoFacts === true && !Array.isArray(input.facts)) {
|
|
181
241
|
try {
|
|
182
242
|
result.autoFacts = extractor.extract({
|
|
@@ -186,6 +246,31 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
|
|
|
186
246
|
eventId: result.event?.id ?? null,
|
|
187
247
|
recordedAt: result.trace?.recordedAt ?? null,
|
|
188
248
|
});
|
|
249
|
+
if (config.features?.graphCandidateAutoFacts === true) {
|
|
250
|
+
const queued = [];
|
|
251
|
+
for (const resolution of result.autoFacts.resolved ?? []) {
|
|
252
|
+
const fact = resolution?.fact;
|
|
253
|
+
if (!fact?.subjectId || !fact?.objectId || fact.status !== 'active') continue;
|
|
254
|
+
try {
|
|
255
|
+
const edge = edges.addCandidate({
|
|
256
|
+
scopeId: input.scopeId ?? config.scope,
|
|
257
|
+
fromId: fact.subjectId,
|
|
258
|
+
toId: fact.objectId,
|
|
259
|
+
rel: fact.predicate,
|
|
260
|
+
weight: fact.confidence ?? 0.5,
|
|
261
|
+
confidence: fact.confidence ?? 0.5,
|
|
262
|
+
sourceEventId: result.event?.id ?? null,
|
|
263
|
+
sourceTraceId: result.trace?.id ?? null,
|
|
264
|
+
evidence: { autoFact: true, factId: fact.id ?? null },
|
|
265
|
+
});
|
|
266
|
+
queued.push(edge);
|
|
267
|
+
audit.record?.({ actor: 'system', action: 'edge.candidate.auto', targetType: 'edge', targetId: edge.id, detail: { fromId: edge.fromId, toId: edge.toId, rel: edge.rel } });
|
|
268
|
+
} catch {
|
|
269
|
+
/* fail open per candidate */
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (queued.length) result.edgeCandidates = [...(result.edgeCandidates ?? []), ...queued];
|
|
273
|
+
}
|
|
189
274
|
} catch {
|
|
190
275
|
result.autoFacts = { candidates: [], resolved: [] };
|
|
191
276
|
}
|
|
@@ -366,6 +451,9 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
|
|
|
366
451
|
evolution,
|
|
367
452
|
write,
|
|
368
453
|
writeMany,
|
|
454
|
+
scopeWritable,
|
|
455
|
+
scopeWritableById,
|
|
456
|
+
scopeReadable: () => true,
|
|
369
457
|
metrics: () => metrics.snapshot(),
|
|
370
458
|
models: {
|
|
371
459
|
get: () => redactedModelConfig(effectiveModelConfig(config.dataRoot)),
|
|
@@ -375,6 +463,43 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
|
|
|
375
463
|
return { ...result, restartRequired: true };
|
|
376
464
|
},
|
|
377
465
|
},
|
|
466
|
+
attachments: {
|
|
467
|
+
inspect: ({ uri } = {}) => {
|
|
468
|
+
if (config.features?.attachmentReader !== true) throw new Error('attachment reader is disabled');
|
|
469
|
+
const result = inspectLocalAttachment(uri);
|
|
470
|
+
audit.record?.({ actor: 'user', action: 'attachment.inspect', targetType: 'attachment', targetId: result.uri, detail: { bytes: result.bytes, sha256: result.sha256 } });
|
|
471
|
+
return result;
|
|
472
|
+
},
|
|
473
|
+
attach: ({ traceId, attachments } = {}) => {
|
|
474
|
+
const trace = traces.get(traceId);
|
|
475
|
+
if (!trace) throw new Error('trace not found');
|
|
476
|
+
if (!scopeWritableById('trace', traceId)) throw new Error('scope-read-only');
|
|
477
|
+
const next = normalizeAttachments(attachments);
|
|
478
|
+
const merged = [...(Array.isArray(trace.metadata?.attachments) ? trace.metadata.attachments : []), ...next];
|
|
479
|
+
if (merged.length > 8) throw new Error('at most 8 attachments');
|
|
480
|
+
const updated = traces.update(traceId, { metadata: { ...(trace.metadata ?? {}), attachments: merged } });
|
|
481
|
+
audit.record?.({ actor: 'user', action: 'attachment.attach', targetType: 'trace', targetId: traceId, detail: { added: next.length, total: merged.length } });
|
|
482
|
+
return { trace: updated, added: next.length };
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
security: {
|
|
486
|
+
grants: () => ({
|
|
487
|
+
grants: [...scopeWriteGrants.entries()].map(([grant, until]) => ({ grant, until: until == null ? null : new Date(until).toISOString() })),
|
|
488
|
+
revoked: scopeGrantStore.all().revoked,
|
|
489
|
+
}),
|
|
490
|
+
addGrant: ({ grant, until = null } = {}) => {
|
|
491
|
+
const entry = scopeGrantStore.add({ grant, until });
|
|
492
|
+
rebuildScopeGrants();
|
|
493
|
+
audit.record?.({ actor: 'user', action: 'scope.grant.add', targetType: 'scope-grant', targetId: entry.grant, detail: { until: entry.until } });
|
|
494
|
+
return entry;
|
|
495
|
+
},
|
|
496
|
+
revokeGrant: ({ grant } = {}) => {
|
|
497
|
+
const value = scopeGrantStore.revoke(grant);
|
|
498
|
+
rebuildScopeGrants();
|
|
499
|
+
audit.record?.({ actor: 'user', action: 'scope.grant.revoke', targetType: 'scope-grant', targetId: value, detail: {} });
|
|
500
|
+
return { grant: value, revoked: true };
|
|
501
|
+
},
|
|
502
|
+
},
|
|
378
503
|
permissions: {
|
|
379
504
|
status: () => readOverrides(config.dataRoot),
|
|
380
505
|
update: ({ key, value, confirm = false } = {}) => {
|
|
@@ -397,6 +522,7 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
|
|
|
397
522
|
contextBudget: config.contextBudget,
|
|
398
523
|
promptBudget: config.promptBudget,
|
|
399
524
|
features: config.features ?? {},
|
|
525
|
+
readOnlyScopes: [...readOnlyScopes],
|
|
400
526
|
overrides: readOverrides(config.dataRoot).current,
|
|
401
527
|
model: redactedModelConfig(modelConfig),
|
|
402
528
|
permissions: {
|
|
@@ -519,8 +645,66 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
|
|
|
519
645
|
summary: summarizeGraph(edgeRows),
|
|
520
646
|
};
|
|
521
647
|
},
|
|
648
|
+
graphCandidates: ({ scopeId = config.scope, limit = 200 } = {}) => edges.listCandidates({ scopeId, limit }),
|
|
649
|
+
graphCandidateAction: ({ action, id, scopeId = config.scope, fromId, toId, rel, weight, confidence, sourceEventId, sourceTraceId, evidence, metadata } = {}) => {
|
|
650
|
+
if (!scopeWritable(scopeId)) throw new Error('scope-read-only');
|
|
651
|
+
let result = null;
|
|
652
|
+
if (action === 'add') {
|
|
653
|
+
result = edges.addCandidate({ scopeId, fromId, toId, rel, weight, confidence, sourceEventId, sourceTraceId, evidence, metadata });
|
|
654
|
+
} else if (action === 'approve') {
|
|
655
|
+
result = edges.approveCandidate(id);
|
|
656
|
+
} else if (action === 'reject') {
|
|
657
|
+
result = edges.rejectCandidate(id);
|
|
658
|
+
} else {
|
|
659
|
+
throw new Error('graph candidate action must be add, approve or reject');
|
|
660
|
+
}
|
|
661
|
+
if (result) {
|
|
662
|
+
audit.record?.({
|
|
663
|
+
actor: 'user',
|
|
664
|
+
action: `edge.candidate.${action}`,
|
|
665
|
+
targetType: 'edge',
|
|
666
|
+
targetId: result.id,
|
|
667
|
+
detail: {
|
|
668
|
+
fromId: result.fromId,
|
|
669
|
+
toId: result.toId,
|
|
670
|
+
rel: result.rel,
|
|
671
|
+
confidence: result.metadata?.confidence ?? null,
|
|
672
|
+
sourceTraceId: result.metadata?.sourceTraceId ?? null,
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return result;
|
|
677
|
+
},
|
|
522
678
|
search,
|
|
523
679
|
searchAsync,
|
|
680
|
+
ingestExtractedText: ({ targetTraceId, text, uri, mode, engine } = {}) => {
|
|
681
|
+
const target = traces.get(targetTraceId);
|
|
682
|
+
if (!target) throw new Error('target trace not found');
|
|
683
|
+
if (!scopeWritableById('trace', targetTraceId)) throw new Error('scope-read-only');
|
|
684
|
+
const content = String(text ?? '').trim().slice(0, 20000);
|
|
685
|
+
if (!content) throw new Error('extracted text is empty');
|
|
686
|
+
const result = writeInternal({
|
|
687
|
+
text: content,
|
|
688
|
+
scopeId: target.scopeId,
|
|
689
|
+
kind: 'observation',
|
|
690
|
+
explicit: true,
|
|
691
|
+
source: 'extractor',
|
|
692
|
+
metadata: {
|
|
693
|
+
derivedFrom: targetTraceId,
|
|
694
|
+
extractor: { uri: uri ?? null, mode: mode ?? null, engine: engine ?? null },
|
|
695
|
+
},
|
|
696
|
+
tags: ['extractor', mode].filter(Boolean),
|
|
697
|
+
});
|
|
698
|
+
const extractedId = result?.trace?.id ?? null;
|
|
699
|
+
audit.record?.({
|
|
700
|
+
actor: 'system',
|
|
701
|
+
action: 'attachment.extract.ingest',
|
|
702
|
+
targetType: 'trace',
|
|
703
|
+
targetId: targetTraceId,
|
|
704
|
+
detail: { extractedTraceId: extractedId, uri: uri ?? null, mode: mode ?? null, chars: content.length },
|
|
705
|
+
});
|
|
706
|
+
return { targetTraceId, extractedTraceId: extractedId, chars: content.length };
|
|
707
|
+
},
|
|
524
708
|
flushLlmFacts,
|
|
525
709
|
recallTiered: ({ query, limit = 20, permBudget = 700, hotBudget = 900, coldBudget = 400, scopeId = config.scope, includeArchived = false } = {}) => {
|
|
526
710
|
const result = search(query, { limit, budget: permBudget + hotBudget + coldBudget, scopeId, includeArchived, noBudget: true });
|
|
@@ -536,7 +720,11 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
|
|
|
536
720
|
timeline,
|
|
537
721
|
step: develop.step,
|
|
538
722
|
feedback: (input) => growth.applyFeedback({ ...input, scopeId: input.scopeId ?? config.scope }),
|
|
539
|
-
forget: (input) =>
|
|
723
|
+
forget: (input) => {
|
|
724
|
+
const trace = traces.get(input?.traceId);
|
|
725
|
+
if (trace && !scopeWritable(trace.scopeId)) return { removed: false, reason: 'scope-read-only', traceId: input.traceId };
|
|
726
|
+
return privacy.forget(input);
|
|
727
|
+
},
|
|
540
728
|
privacy,
|
|
541
729
|
reflect: (options = {}) => reflect.reflect({ ...options, scopeId: options.scopeId ?? config.scope }),
|
|
542
730
|
evaluateDevelopment: (options = {}) => development.evaluate({ ...options, scopeId: options.scopeId ?? config.scope }),
|