soulbridge 0.0.102 → 0.0.104
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/dist/packages/cli/src/cli.js +21 -2
- package/dist/packages/core/src/consolidate.js +7 -3
- package/dist/packages/core/src/index.d.ts +6 -1
- package/dist/packages/core/src/index.js +21 -8
- package/dist/packages/core/src/portable.d.ts +17 -0
- package/dist/packages/core/src/portable.js +93 -0
- package/dist/packages/core/src/profile-prompt.js +3 -3
- package/dist/packages/core/src/sqlite.d.ts +2 -1
- package/dist/packages/core/src/sqlite.js +66 -5
- package/dist/packages/core/src/store.d.ts +12 -3
- package/dist/packages/core/src/types.d.ts +27 -0
- package/package.json +1 -1
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* soulbridge demo
|
|
15
15
|
*/
|
|
16
16
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
-
import { dirname, resolve } from 'node:path';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
18
|
// node:sqlite prints an ExperimentalWarning the moment it loads. Replace the default
|
|
19
19
|
// printer with a filter: the CLI's own output stays clean, other warnings still show.
|
|
20
20
|
// Must run before the dynamic import below, which is why that import is dynamic.
|
|
@@ -221,9 +221,27 @@ async function main() {
|
|
|
221
221
|
return;
|
|
222
222
|
}
|
|
223
223
|
case 'export': {
|
|
224
|
+
// Two shapes: a legacy JSONL of memories (--out), and the portable bundle (--dir)
|
|
225
|
+
// that carries the whole record, a readable summary and the current injection.
|
|
226
|
+
if (flags.dir) {
|
|
227
|
+
const { soul } = open(flags);
|
|
228
|
+
const dir = resolve(String(flags.dir));
|
|
229
|
+
mkdirSync(dir, { recursive: true });
|
|
230
|
+
const bundle = soul.portableBundle();
|
|
231
|
+
writeFileSync(join(dir, 'archive.jsonl'), `${bundle.archive.join('\n')}\n`, 'utf8');
|
|
232
|
+
writeFileSync(join(dir, 'summary.md'), bundle.summary, 'utf8');
|
|
233
|
+
writeFileSync(join(dir, 'injection.txt'), bundle.injection, 'utf8');
|
|
234
|
+
out({
|
|
235
|
+
ok: true,
|
|
236
|
+
dir,
|
|
237
|
+
records: bundle.archive.length,
|
|
238
|
+
files: ['archive.jsonl', 'summary.md', 'injection.txt'],
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
224
242
|
const file = flags.out;
|
|
225
243
|
if (!file)
|
|
226
|
-
throw new Error('usage: soulbridge export --out
|
|
244
|
+
throw new Error('usage: soulbridge export --out <file> | --dir <directory>');
|
|
227
245
|
const { soul, deviceId } = open(flags);
|
|
228
246
|
const body = exportJsonl(soul, deviceId);
|
|
229
247
|
const target = resolve(file);
|
|
@@ -425,6 +443,7 @@ async function main() {
|
|
|
425
443
|
' recall <q> [--limit N] 召回',
|
|
426
444
|
' reflect --text|--file 会话反思',
|
|
427
445
|
' export --out <file> 导出 JSONL',
|
|
446
|
+
' export --dir <directory> 导出可携带档案(archive/summary/injection)',
|
|
428
447
|
' import --in <file> 导入 JSONL',
|
|
429
448
|
' import --from <steering.md> 导入 AGENTS.md / CLAUDE.md',
|
|
430
449
|
' ingest --file <transcript.jsonl> 会话转写 → 记忆与情绪采样',
|
|
@@ -245,8 +245,10 @@ export function parseProfileMerge(raw, allowed, expected) {
|
|
|
245
245
|
// model mixed two lenses back together, which is the welding the split prevents.
|
|
246
246
|
if (expected && dimension !== expected)
|
|
247
247
|
continue;
|
|
248
|
-
|
|
249
|
-
|
|
248
|
+
// One name for the field now; 来源 is accepted only so an older prompt in flight
|
|
249
|
+
// still parses.
|
|
250
|
+
const sourceField = fields.find((field) => /^(证据|来源)\s*[==]/.test(field));
|
|
251
|
+
const sources = (sourceField?.replace(/^(证据|来源)\s*[==]\s*/, '') ?? '')
|
|
250
252
|
.split(/[,,、;;\s]+/)
|
|
251
253
|
.map((id) => id.trim())
|
|
252
254
|
.filter((id) => allowed.has(id));
|
|
@@ -277,7 +279,9 @@ export function parseProfileMerge(raw, allowed, expected) {
|
|
|
277
279
|
* so both are read — a line that names its sources can never be merge input.
|
|
278
280
|
*/
|
|
279
281
|
export function isDerivedProfileLine(fact) {
|
|
280
|
-
|
|
282
|
+
// 证据 is the one name for "this line cites its sources"; 来源 was the old spelling and 合并
|
|
283
|
+
// marks a merged line that has no sources of its own.
|
|
284
|
+
return (fact.slots ?? []).some((slot) => slot.slot === '证据' || slot.slot === '合并');
|
|
281
285
|
}
|
|
282
286
|
/**
|
|
283
287
|
* Cut the merge input along dimensions before any model sees it.
|
|
@@ -30,7 +30,7 @@ export * from './storage/config.ts';
|
|
|
30
30
|
export * from './storage/merge.ts';
|
|
31
31
|
export * from './storage/steering.ts';
|
|
32
32
|
export * from './storage/transcript.ts';
|
|
33
|
-
import type { AffectSignal, ContextRequest, Correction, Memory, ContextResult, Emotion, LlmFn, MemoryType, ObserveResult, ReflectResult, ScoredMemory, Turn, CommitResult, Entity, ExtractDraft, ConclusionDraft, Fact, ThreadAttribution, TurnThread, FactDetail, FactSpec, Gap, GapSpec, SessionHandoff, TurnFindResult, SessionSummary, TurnPair, TurnStep, Visibility } from './types.ts';
|
|
33
|
+
import type { AffectSignal, ContextRequest, Correction, Memory, ContextResult, Emotion, LlmFn, MemoryType, ObserveResult, ReflectResult, ScoredMemory, Turn, CommitResult, Entity, ExtractDraft, ConclusionDraft, Fact, ThreadAttribution, TurnThread, FactDetail, FactSpec, Gap, GapSpec, SessionHandoff, TurnFindResult, SessionSummary, TurnPair, TurnStep, PortableBundle, Visibility } from './types.ts';
|
|
34
34
|
import type { SoulStore } from './store.ts';
|
|
35
35
|
import type { AffectAnalyzer } from './affect/port.ts';
|
|
36
36
|
import type { Rule } from './rules.ts';
|
|
@@ -80,6 +80,11 @@ export declare class Soul {
|
|
|
80
80
|
readonly dedupeWindowMs: number;
|
|
81
81
|
readonly onLlmRaw?: (raw: string) => void;
|
|
82
82
|
constructor(options: SoulOptions);
|
|
83
|
+
/**
|
|
84
|
+
* Everything a host needs to carry this soul to another machine or tool:
|
|
85
|
+
* the full archive, a readable summary, and the injection the model would see.
|
|
86
|
+
*/
|
|
87
|
+
portableBundle(now?: number): PortableBundle;
|
|
83
88
|
/** Verb 1: session start. */
|
|
84
89
|
context(request?: Partial<ContextRequest>): ContextResult;
|
|
85
90
|
/**
|
|
@@ -21,6 +21,7 @@ export * from './storage/config.js';
|
|
|
21
21
|
export * from './storage/merge.js';
|
|
22
22
|
export * from './storage/steering.js';
|
|
23
23
|
export * from './storage/transcript.js';
|
|
24
|
+
import { buildPortableBundle } from './portable.js';
|
|
24
25
|
import { FACT_STATUS, MEMORY_STATUS } from './config/status.js';
|
|
25
26
|
import { HOLDER_WORDS } from './config/domains.js';
|
|
26
27
|
import { isActive, isRetracted, roleOf } from './roles.js';
|
|
@@ -100,6 +101,15 @@ export class Soul {
|
|
|
100
101
|
});
|
|
101
102
|
}
|
|
102
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Everything a host needs to carry this soul to another machine or tool:
|
|
106
|
+
* the full archive, a readable summary, and the injection the model would see.
|
|
107
|
+
*/
|
|
108
|
+
portableBundle(now = Date.now()) {
|
|
109
|
+
const dump = this.store.dump(this.soulId, this.userId);
|
|
110
|
+
const injection = this.context({}).text;
|
|
111
|
+
return buildPortableBundle(dump, this.soulId, this.userId, injection, now);
|
|
112
|
+
}
|
|
103
113
|
/** Verb 1: session start. */
|
|
104
114
|
context(request = {}) {
|
|
105
115
|
// Spread, not a field list: an explicit list silently dropped every option
|
|
@@ -181,7 +191,9 @@ export class Soul {
|
|
|
181
191
|
* code would be the guess ADR 0027 forbids.
|
|
182
192
|
*/
|
|
183
193
|
promotionCandidates(turnId) {
|
|
184
|
-
return this.store
|
|
194
|
+
return this.store
|
|
195
|
+
.factsByTurns([turnId], { includeCandidates: true })
|
|
196
|
+
.filter((fact) => fact.role === 'lesson' && fact.status === 'candidate');
|
|
185
197
|
}
|
|
186
198
|
/**
|
|
187
199
|
* Store one exchange (RFC 0007). Idempotent on `(sessionId, turn)`: a repeat is
|
|
@@ -494,11 +506,11 @@ export class Soul {
|
|
|
494
506
|
skipped.push({ what: statement, reason: 'duplicate' });
|
|
495
507
|
return null;
|
|
496
508
|
}
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
509
|
+
// An unclassified lesson is a candidate: a lesson the model flagged but did not
|
|
510
|
+
// place in a role. It is held and shown to the classifier, injected nowhere.
|
|
511
|
+
// This replaced the old `原则候选` slot, which was the same decision stored as text.
|
|
512
|
+
const role = factDraft.principle && !factDraft.role ? 'lesson' : factDraft.role;
|
|
513
|
+
const status = factDraft.principle && !factDraft.role ? 'candidate' : undefined;
|
|
502
514
|
// ADR 0035: a fact from my side of the exchange is `inferred`, and inferred
|
|
503
515
|
// claims must carry their evidence. The turn id is the evidence available here —
|
|
504
516
|
// claims that need a tool or test result are kept out by the prompt instead.
|
|
@@ -510,7 +522,8 @@ export class Soul {
|
|
|
510
522
|
userId: this.userId,
|
|
511
523
|
statement,
|
|
512
524
|
holder,
|
|
513
|
-
role
|
|
525
|
+
role,
|
|
526
|
+
status,
|
|
514
527
|
typeName: factDraft.typeName,
|
|
515
528
|
at: factDraft.at,
|
|
516
529
|
atText: factDraft.atText,
|
|
@@ -747,7 +760,7 @@ export class Soul {
|
|
|
747
760
|
// More sources means more observations behind the line, so it ranks first.
|
|
748
761
|
confidence: Math.min(0.95, 0.6 + 0.05 * line.sources.length),
|
|
749
762
|
slots: [
|
|
750
|
-
...line.sources.map((id) => ({ slot: '
|
|
763
|
+
...line.sources.map((id) => ({ slot: '证据', value: { kind: 'fact', factId: id } })),
|
|
751
764
|
// A merged line is not an original: re-merging merged lines loses detail.
|
|
752
765
|
{ slot: '合并', value: { kind: 'text', value: '1' } },
|
|
753
766
|
{ slot: '维度', value: { kind: 'text', value: line.dimension } },
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable bundle: the whole record, a human-readable summary, and the injection text.
|
|
3
|
+
*
|
|
4
|
+
* Three layers, because they answer different questions (spec/vocabulary.v1 §7):
|
|
5
|
+
* archive — everything, including raw exchanges; the recipient can rebuild the store.
|
|
6
|
+
* summary — what a person can read in a minute: rules, profile, conclusions, promises,
|
|
7
|
+
* entities, open gaps. No ids unless they help.
|
|
8
|
+
* injection — what the model would see right now; the smallest useful transfer.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here filters by importance. An export that silently drops "unimportant" rows
|
|
11
|
+
* cannot be the source of truth, and importance is a semantic judgement we do not store.
|
|
12
|
+
*/
|
|
13
|
+
import type { PortableDump, PortableBundle } from './types.ts';
|
|
14
|
+
export declare function buildPortableSummary(dump: PortableDump, soulId: string, userId: string, now: number): string;
|
|
15
|
+
/** One JSON object per line, tagged so a reader can stream it without a schema. */
|
|
16
|
+
export declare function buildArchiveLines(dump: PortableDump): string[];
|
|
17
|
+
export declare function buildPortableBundle(dump: PortableDump, soulId: string, userId: string, injection: string, now: number): PortableBundle;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export function buildPortableSummary(dump, soulId, userId, now) {
|
|
2
|
+
const lines = [];
|
|
3
|
+
lines.push(`# soulbridge 档案 · ${userId}`);
|
|
4
|
+
lines.push('');
|
|
5
|
+
lines.push(`- soul: ${soulId}`);
|
|
6
|
+
lines.push(`- 导出时间: ${new Date(now).toISOString()}`);
|
|
7
|
+
lines.push(`- 规模: 事实 ${dump.facts.length} · 实体 ${dump.entities.length} · 线索 ${dump.threads.length} · 轮次 ${dump.turns.length} · 缺口 ${dump.gaps.length}`);
|
|
8
|
+
lines.push('');
|
|
9
|
+
const byRole = (role) => dump.facts.filter((fact) => fact.role === role && fact.status === 'active');
|
|
10
|
+
const slotText = (fact, name) => {
|
|
11
|
+
const slot = fact.slots.find((item) => item.slot === name);
|
|
12
|
+
return slot?.value.kind === 'text' ? slot.value.value : undefined;
|
|
13
|
+
};
|
|
14
|
+
const rules = byRole('rule');
|
|
15
|
+
if (rules.length > 0) {
|
|
16
|
+
lines.push(`## 规则(${rules.length})`);
|
|
17
|
+
for (const rule of rules) {
|
|
18
|
+
const trigger = slotText(rule, '触发');
|
|
19
|
+
const action = slotText(rule, '动作') ?? rule.statement;
|
|
20
|
+
lines.push(trigger ? `- 当 ${trigger}:${action}` : `- ${action}`);
|
|
21
|
+
}
|
|
22
|
+
lines.push('');
|
|
23
|
+
}
|
|
24
|
+
const profile = byRole('profile');
|
|
25
|
+
if (profile.length > 0) {
|
|
26
|
+
lines.push(`## 画像(${profile.length})`);
|
|
27
|
+
for (const line of profile)
|
|
28
|
+
lines.push(`- ${line.statement}`);
|
|
29
|
+
lines.push('');
|
|
30
|
+
}
|
|
31
|
+
const judgments = byRole('judgment');
|
|
32
|
+
if (judgments.length > 0) {
|
|
33
|
+
lines.push(`## 结论(${judgments.length})`);
|
|
34
|
+
for (const line of judgments.slice(0, 30))
|
|
35
|
+
lines.push(`- ${line.statement}`);
|
|
36
|
+
lines.push('');
|
|
37
|
+
}
|
|
38
|
+
const promises = byRole('promise');
|
|
39
|
+
if (promises.length > 0) {
|
|
40
|
+
lines.push(`## 承诺(${promises.length})`);
|
|
41
|
+
for (const line of promises)
|
|
42
|
+
lines.push(`- ${line.statement}`);
|
|
43
|
+
lines.push('');
|
|
44
|
+
}
|
|
45
|
+
if (dump.entities.length > 0) {
|
|
46
|
+
lines.push(`## 实体(${dump.entities.length})`);
|
|
47
|
+
for (const entity of dump.entities.slice(0, 50)) {
|
|
48
|
+
const aliases = entity.aliases.length > 0 ? `(别名:${entity.aliases.join('、')})` : '';
|
|
49
|
+
lines.push(`- ${entity.name} · ${entity.type}${aliases}`);
|
|
50
|
+
}
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
const openGaps = dump.gaps.filter((gap) => gap.status === 'open');
|
|
54
|
+
if (openGaps.length > 0) {
|
|
55
|
+
lines.push(`## 未闭合的缺口(${openGaps.length})`);
|
|
56
|
+
for (const gap of openGaps.slice(0, 20))
|
|
57
|
+
lines.push(`- ${gap.topic}`);
|
|
58
|
+
lines.push('');
|
|
59
|
+
}
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|
|
62
|
+
/** One JSON object per line, tagged so a reader can stream it without a schema. */
|
|
63
|
+
export function buildArchiveLines(dump) {
|
|
64
|
+
const lines = [];
|
|
65
|
+
// `record` is the discriminator, not `type`: Entity has its own `type` field, and a
|
|
66
|
+
// spread after the tag would silently overwrite it.
|
|
67
|
+
for (const entity of dump.entities)
|
|
68
|
+
lines.push(JSON.stringify({ record: 'entity', ...entity }));
|
|
69
|
+
for (const fact of dump.facts)
|
|
70
|
+
lines.push(JSON.stringify({ record: 'fact', ...fact }));
|
|
71
|
+
for (const thread of dump.threads)
|
|
72
|
+
lines.push(JSON.stringify({ record: 'thread', ...thread }));
|
|
73
|
+
for (const gap of dump.gaps)
|
|
74
|
+
lines.push(JSON.stringify({ record: 'gap', ...gap }));
|
|
75
|
+
for (const turn of dump.turns)
|
|
76
|
+
lines.push(JSON.stringify({ record: 'turn', ...turn }));
|
|
77
|
+
for (const step of dump.steps)
|
|
78
|
+
lines.push(JSON.stringify({ record: 'step', ...step }));
|
|
79
|
+
for (const correction of dump.corrections)
|
|
80
|
+
lines.push(JSON.stringify({ record: 'correction', ...correction }));
|
|
81
|
+
return lines;
|
|
82
|
+
}
|
|
83
|
+
export function buildPortableBundle(dump, soulId, userId, injection, now) {
|
|
84
|
+
return {
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
exportedAt: now,
|
|
87
|
+
soulId,
|
|
88
|
+
userId,
|
|
89
|
+
summary: buildPortableSummary(dump, soulId, userId, now),
|
|
90
|
+
injection,
|
|
91
|
+
archive: buildArchiveLines(dump),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -41,7 +41,7 @@ const PROFILE_RULES = `<rules>
|
|
|
41
41
|
- 不许出现"一切/所有/总是/从不",除非来源原文里就有。
|
|
42
42
|
- 每行给一个维度:${dimensionChoices()}
|
|
43
43
|
- 一行只讲一个维度。不同维度必须分行——跨维度拼成一句就是没拆。
|
|
44
|
-
-
|
|
44
|
+
- 每条必须给证据:证据=fc_xxx,fc_yyy,只能引用 <input> 里出现过的 id;没有证据的整行不要。
|
|
45
45
|
- 合并后 5–8 条;宁少不凑。
|
|
46
46
|
- 输入里没有的新判断,不许出现。
|
|
47
47
|
</rules>`;
|
|
@@ -68,8 +68,8 @@ const PROFILE_EXAMPLE = `<example>
|
|
|
68
68
|
- fc_c | 用户很讨厌一件事反复讲,换工具又要重新交代
|
|
69
69
|
</input>
|
|
70
70
|
<output>
|
|
71
|
-
画像: 要机制,不要靠 prompt 的自觉 | 维度=${PROFILE_DIMENSIONS[1]} |
|
|
72
|
-
画像: 讨厌重复交代,工具换了也不该重讲 | 维度=${PROFILE_DIMENSIONS[2]} |
|
|
71
|
+
画像: 要机制,不要靠 prompt 的自觉 | 维度=${PROFILE_DIMENSIONS[1]} | 证据=fc_a,fc_b
|
|
72
|
+
画像: 讨厌重复交代,工具换了也不该重讲 | 维度=${PROFILE_DIMENSIONS[2]} | 证据=fc_c
|
|
73
73
|
</output>
|
|
74
74
|
</example>`;
|
|
75
75
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import type { SoulStore } from './store.ts';
|
|
3
|
-
export declare const SCHEMA_VERSION =
|
|
3
|
+
export declare const SCHEMA_VERSION = 11;
|
|
4
4
|
export declare const AFFECT_RETENTION_MS: number;
|
|
5
5
|
export declare class DbNewerThanBinaryError extends Error {
|
|
6
6
|
readonly code = "DB_NEWER_THAN_BINARY";
|
|
@@ -13,6 +13,7 @@ export declare class DbNewerThanBinaryError extends Error {
|
|
|
13
13
|
* old table stays as a read-only legacy copy.
|
|
14
14
|
*/
|
|
15
15
|
/** v6 → v7: turn threads (a topic's turns need not be adjacent) plus turns.thread_id. */
|
|
16
|
+
export declare function upgradeToV11(db: DatabaseSync): void;
|
|
16
17
|
export declare function upgradeToV10(db: DatabaseSync): void;
|
|
17
18
|
export declare function upgradeToV9(db: DatabaseSync): void;
|
|
18
19
|
export declare function upgradeToV8(db: DatabaseSync): void;
|
|
@@ -12,7 +12,7 @@ import { DEFAULT_EMOTION } from './types.js';
|
|
|
12
12
|
import { FACT_STATUS, MEMORY_STATUS } from './config/status.js';
|
|
13
13
|
import { FACT_ROLES } from './config/domains.js';
|
|
14
14
|
import { bm25, buildCorpusStats, tokenize } from './scoring.js';
|
|
15
|
-
export const SCHEMA_VERSION =
|
|
15
|
+
export const SCHEMA_VERSION = 11;
|
|
16
16
|
/** How many recent pairs are considered before scoring. Bounded so recall stays O(candidates). */
|
|
17
17
|
const RECALL_CANDIDATES = 500;
|
|
18
18
|
export const AFFECT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -299,6 +299,21 @@ function upgradeToV3(db) {
|
|
|
299
299
|
* old table stays as a read-only legacy copy.
|
|
300
300
|
*/
|
|
301
301
|
/** v6 → v7: turn threads (a topic's turns need not be adjacent) plus turns.thread_id. */
|
|
302
|
+
export function upgradeToV11(db) {
|
|
303
|
+
// 原则候选 stored a decision as a slot; it is role+status now. The row becomes a lesson
|
|
304
|
+
// candidate — held and shown to the classifier, injected nowhere until it is placed.
|
|
305
|
+
db.exec(`UPDATE facts SET role = 'lesson', status = 'candidate'
|
|
306
|
+
WHERE id IN (SELECT fact_id FROM fact_slots WHERE slot = '原则候选' AND value_text = '是')`);
|
|
307
|
+
db.exec("DELETE FROM fact_slots WHERE slot = '原则候选'");
|
|
308
|
+
// One name for "this cites its sources": 来源 → 证据. Skip where the fact already cites
|
|
309
|
+
// the same row under the new name, then drop what is left of the old spelling.
|
|
310
|
+
db.exec(`UPDATE fact_slots SET slot = '证据'
|
|
311
|
+
WHERE slot = '来源'
|
|
312
|
+
AND NOT EXISTS (SELECT 1 FROM fact_slots x
|
|
313
|
+
WHERE x.fact_id = fact_slots.fact_id AND x.slot = '证据'
|
|
314
|
+
AND x.ref_fact_id = fact_slots.ref_fact_id)`);
|
|
315
|
+
db.exec("DELETE FROM fact_slots WHERE slot = '来源'");
|
|
316
|
+
}
|
|
302
317
|
export function upgradeToV10(db) {
|
|
303
318
|
// The four-state vocabulary (config/status.ts). Everything written under the old names
|
|
304
319
|
// becomes one of the two states the code now reads; after this the fallbacks are gone.
|
|
@@ -448,8 +463,8 @@ export function openSqlite(file) {
|
|
|
448
463
|
upgradeToV7(db);
|
|
449
464
|
if (version < 8)
|
|
450
465
|
upgradeToV8(db);
|
|
451
|
-
if (version <
|
|
452
|
-
|
|
466
|
+
if (version < 11)
|
|
467
|
+
upgradeToV11(db);
|
|
453
468
|
// v6 adds tables only; SCHEMA creates them on every open, nothing to copy.
|
|
454
469
|
setVersion(db, SCHEMA_VERSION);
|
|
455
470
|
db.exec('COMMIT');
|
|
@@ -631,6 +646,45 @@ export function openSqlite(file) {
|
|
|
631
646
|
deviceId: row.device_id == null ? undefined : String(row.device_id),
|
|
632
647
|
}));
|
|
633
648
|
},
|
|
649
|
+
corrections(soulId, userId, limit = 100_000) {
|
|
650
|
+
return this.recentCorrections(soulId, userId, limit);
|
|
651
|
+
},
|
|
652
|
+
dump(soulId, userId) {
|
|
653
|
+
const facts = this.facts({ soulId, userId, limit: 100_000 });
|
|
654
|
+
const entities = this.findEntities({ soulId, userId, limit: 100_000 });
|
|
655
|
+
const threads = db
|
|
656
|
+
.prepare('SELECT * FROM threads WHERE soul_id = ? AND user_id = ? ORDER BY opened_at ASC')
|
|
657
|
+
.all(soulId, userId).map(rowToThread);
|
|
658
|
+
const gaps = db
|
|
659
|
+
.prepare('SELECT * FROM gaps WHERE soul_id = ? AND user_id = ? ORDER BY opened_at ASC')
|
|
660
|
+
.all(soulId, userId).map(rowToGap);
|
|
661
|
+
const turns = db
|
|
662
|
+
.prepare('SELECT * FROM turns WHERE soul_id = ? AND user_id = ? ORDER BY created_at ASC')
|
|
663
|
+
.all(soulId, userId).map(rowToTurnPair);
|
|
664
|
+
const steps = db
|
|
665
|
+
.prepare(`SELECT s.* FROM steps s JOIN turns t ON t.id = s.turn_id
|
|
666
|
+
WHERE t.soul_id = ? AND t.user_id = ? ORDER BY s.turn_id, s.idx`)
|
|
667
|
+
.all(soulId, userId);
|
|
668
|
+
return {
|
|
669
|
+
entities,
|
|
670
|
+
facts,
|
|
671
|
+
threads,
|
|
672
|
+
gaps,
|
|
673
|
+
turns,
|
|
674
|
+
steps: steps.map((row) => ({
|
|
675
|
+
turnId: String(row.turn_id),
|
|
676
|
+
idx: Number(row.idx),
|
|
677
|
+
kind: row.kind,
|
|
678
|
+
text: row.text == null ? undefined : String(row.text),
|
|
679
|
+
toolName: row.tool_name == null ? undefined : String(row.tool_name),
|
|
680
|
+
toolArgsChars: row.tool_args_chars == null ? undefined : Number(row.tool_args_chars),
|
|
681
|
+
toolResultChars: row.tool_result_chars == null ? undefined : Number(row.tool_result_chars),
|
|
682
|
+
ok: row.ok == null ? undefined : Boolean(row.ok),
|
|
683
|
+
at: Number(row.at),
|
|
684
|
+
})),
|
|
685
|
+
corrections: this.recentCorrections(soulId, userId, 100_000),
|
|
686
|
+
};
|
|
687
|
+
},
|
|
634
688
|
countCorrections(soulId, userId) {
|
|
635
689
|
const row = db
|
|
636
690
|
.prepare('SELECT COUNT(*) AS n FROM corrections WHERE soul_id = ? AND user_id = ?')
|
|
@@ -1006,12 +1060,19 @@ export function openSqlite(file) {
|
|
|
1006
1060
|
const text = row ? String(row.user_text ?? '').trim() : '';
|
|
1007
1061
|
return text === '' ? null : text;
|
|
1008
1062
|
},
|
|
1009
|
-
factsByTurns(turnIds) {
|
|
1063
|
+
factsByTurns(turnIds, options = {}) {
|
|
1010
1064
|
if (turnIds.length === 0)
|
|
1011
1065
|
return [];
|
|
1012
1066
|
const placeholders = turnIds.map(() => '?').join(',');
|
|
1067
|
+
// Only live facts. A retracted statement must not become evidence for the next
|
|
1068
|
+
// consolidation; a candidate is held but unconfirmed, so it is evidence for nothing
|
|
1069
|
+
// except the promotion step that decides its role.
|
|
1070
|
+
const live = options.includeCandidates
|
|
1071
|
+
? `f.status IN ('${FACT_STATUS.active}','${FACT_STATUS.candidate}')`
|
|
1072
|
+
: `f.status = '${FACT_STATUS.active}'`;
|
|
1013
1073
|
const rows = db
|
|
1014
|
-
.prepare(`SELECT f.* FROM facts f WHERE f.source_turn_id IN (${placeholders})
|
|
1074
|
+
.prepare(`SELECT f.* FROM facts f WHERE f.source_turn_id IN (${placeholders})
|
|
1075
|
+
AND ${live} ORDER BY f.created_at ASC`)
|
|
1015
1076
|
.all(...turnIds);
|
|
1016
1077
|
const slots = slotsOfRows(db, rows.map((row) => String(row.id)));
|
|
1017
1078
|
return rows.map((row) => rowToFact(row, slots));
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The only port. Every added method is a storage engine's opinion leaking into
|
|
3
3
|
* the domain, so the surface stays deliberately small.
|
|
4
4
|
*/
|
|
5
|
-
import type { AffectSignal, Correction, Emotion, Entity, EntityQuery, EntitySpec, Fact, FactQuery, FactRole, FactSpec, FactStatus, Gap, GapSpec, Identity, Memory, QuerySpec, SessionSummary, SlotValue, TurnPair, TurnPairSpec, TurnFindResult, TurnFindSpec, TurnSpec, TurnStep, TurnThread, ThreadSpec } from './types.ts';
|
|
5
|
+
import type { AffectSignal, Correction, Emotion, Entity, EntityQuery, EntitySpec, Fact, FactQuery, FactRole, FactSpec, FactStatus, Gap, GapSpec, Identity, Memory, PortableDump, QuerySpec, SessionSummary, SlotValue, TurnPair, TurnPairSpec, TurnFindResult, TurnFindSpec, TurnSpec, TurnStep, TurnThread, ThreadSpec } from './types.ts';
|
|
6
6
|
export interface SoulStore {
|
|
7
7
|
ensureSoul(identity: Identity): void;
|
|
8
8
|
identity(soulId: string): Identity | null;
|
|
@@ -88,8 +88,13 @@ export interface SoulStore {
|
|
|
88
88
|
lastClosedThread(soulId: string, userId: string): TurnThread | null;
|
|
89
89
|
/** Every turn of a thread, in order — the chapter, not one page. */
|
|
90
90
|
threadTurns(threadId: string): TurnPair[];
|
|
91
|
-
/**
|
|
92
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Facts whose source turn belongs to the given turns. Live facts only; candidates are
|
|
93
|
+
* included when asked, because promotion needs to see lessons not yet classified.
|
|
94
|
+
*/
|
|
95
|
+
factsByTurns(turnIds: string[], options?: {
|
|
96
|
+
includeCandidates?: boolean;
|
|
97
|
+
}): Fact[];
|
|
93
98
|
/** One turn by id, for expanding a fact back into its conversation. */
|
|
94
99
|
turn(id: string): TurnPair | null;
|
|
95
100
|
/** Retire a fact: the new one replaces it, the old row stays for the record. */
|
|
@@ -98,6 +103,10 @@ export interface SoulStore {
|
|
|
98
103
|
putGap(spec: GapSpec): Gap;
|
|
99
104
|
openGaps(soulId: string, userId: string, limit?: number): Gap[];
|
|
100
105
|
closeGap(id: string, closedByFactId?: string, at?: number): void;
|
|
106
|
+
/** Corrections the user made, newest first — the objective signal for behaviour changes. */
|
|
107
|
+
corrections(soulId: string, userId: string, limit?: number): Correction[];
|
|
108
|
+
/** Everything a portable bundle needs, scoped to one soul/user. */
|
|
109
|
+
dump(soulId: string, userId: string): PortableDump;
|
|
101
110
|
/** Exchanges visible to `userId`; scored against `spec.query` when given. */
|
|
102
111
|
recallTurnPairs(spec: TurnPairSpec): TurnPair[];
|
|
103
112
|
countTurnPairs(soulId: string, userId: string, sessionId?: string): number;
|
|
@@ -61,6 +61,33 @@ export interface TurnPair {
|
|
|
61
61
|
metadata: Record<string, unknown>;
|
|
62
62
|
createdAt: number;
|
|
63
63
|
}
|
|
64
|
+
/** One step of a turn, flattened for the archive. */
|
|
65
|
+
export interface PortableStep extends TurnStep {
|
|
66
|
+
turnId: string;
|
|
67
|
+
}
|
|
68
|
+
/** Everything the portable bundle needs, scoped to one soul/user. */
|
|
69
|
+
export interface PortableDump {
|
|
70
|
+
entities: Entity[];
|
|
71
|
+
facts: Fact[];
|
|
72
|
+
threads: TurnThread[];
|
|
73
|
+
gaps: Gap[];
|
|
74
|
+
turns: TurnPair[];
|
|
75
|
+
steps: PortableStep[];
|
|
76
|
+
corrections: Correction[];
|
|
77
|
+
}
|
|
78
|
+
/** The three layers of the portability contract (spec/vocabulary.v1 §7). */
|
|
79
|
+
export interface PortableBundle {
|
|
80
|
+
schemaVersion: number;
|
|
81
|
+
exportedAt: number;
|
|
82
|
+
soulId: string;
|
|
83
|
+
userId: string;
|
|
84
|
+
/** Human-readable: rules, profile, conclusions, promises, entities, open gaps. */
|
|
85
|
+
summary: string;
|
|
86
|
+
/** What the model would see right now. */
|
|
87
|
+
injection: string;
|
|
88
|
+
/** One JSON object per line, including raw turns — the source of truth. */
|
|
89
|
+
archive: string[];
|
|
90
|
+
}
|
|
64
91
|
/** What a recall call may narrow by. Everything is optional. */
|
|
65
92
|
export interface TurnPairSpec {
|
|
66
93
|
soulId: string;
|