soulbridge 0.0.106 → 0.0.108
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 +23 -0
- package/dist/packages/core/src/index.d.ts +27 -0
- package/dist/packages/core/src/index.js +41 -16
- package/dist/packages/core/src/sqlite.d.ts +3 -1
- package/dist/packages/core/src/sqlite.js +56 -9
- package/dist/packages/core/src/store.d.ts +5 -0
- package/dist/packages/core/src/types.d.ts +8 -0
- package/package.json +1 -1
|
@@ -427,6 +427,28 @@ async function main() {
|
|
|
427
427
|
out({ db: dbPath, ...soul.stats() });
|
|
428
428
|
return;
|
|
429
429
|
}
|
|
430
|
+
case 'person': {
|
|
431
|
+
// The reading side of the person layer: who they are, what happened recently, and the
|
|
432
|
+
// questions about them that are still open. No model call.
|
|
433
|
+
const name = positional[0];
|
|
434
|
+
if (!name)
|
|
435
|
+
throw new Error('usage: soulbridge person <name>');
|
|
436
|
+
const { soul } = open(flags);
|
|
437
|
+
const card = soul.personCard(name);
|
|
438
|
+
out({
|
|
439
|
+
entity: card.entity
|
|
440
|
+
? { id: card.entity.id, name: card.entity.name, type: card.entity.type, aliases: card.entity.aliases }
|
|
441
|
+
: null,
|
|
442
|
+
recent: card.recent.map((fact) => ({
|
|
443
|
+
at: fact.atText ?? (fact.at ? new Date(fact.at).toISOString().slice(0, 10) : undefined),
|
|
444
|
+
role: fact.role,
|
|
445
|
+
source: fact.source,
|
|
446
|
+
statement: fact.statement,
|
|
447
|
+
})),
|
|
448
|
+
openGaps: card.openGaps.map((gap) => gap.topic),
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
430
452
|
case 'why': {
|
|
431
453
|
// The reader for the 依据 slot: a fact I said must be traceable to the turn I said it
|
|
432
454
|
// in, and a conclusion must be traceable to the facts that support it.
|
|
@@ -480,6 +502,7 @@ async function main() {
|
|
|
480
502
|
' export --out <file> 导出 JSONL',
|
|
481
503
|
' export --dir <directory> 导出可携带档案(archive/summary/injection)',
|
|
482
504
|
' why <factId> 这条事实的依据:来源轮次 / 证据',
|
|
505
|
+
' person <name> 某人的近况:最近事实 + 未闭合的问题',
|
|
483
506
|
' import --in <file> 导入 JSONL',
|
|
484
507
|
' import --from <steering.md> 导入 AGENTS.md / CLAUDE.md',
|
|
485
508
|
' ingest --file <transcript.jsonl> 会话转写 → 记忆与情绪采样',
|
|
@@ -327,6 +327,17 @@ export declare class Soul {
|
|
|
327
327
|
id: string;
|
|
328
328
|
reason: 'archived' | 'quiet-turns' | 'quiet-time';
|
|
329
329
|
}>;
|
|
330
|
+
/**
|
|
331
|
+
* Housekeeping that should run once per turn: close open questions nobody answered.
|
|
332
|
+
*
|
|
333
|
+
* A gap is only worth asking about while it is fresh; 14 days of silence means the moment
|
|
334
|
+
* passed. Closing is not deleting — the row keeps its topic, opened_at and the reason.
|
|
335
|
+
*/
|
|
336
|
+
sweep(now?: number, options?: {
|
|
337
|
+
gapMaxAgeDays?: number;
|
|
338
|
+
}): {
|
|
339
|
+
gapsClosed: number;
|
|
340
|
+
};
|
|
330
341
|
/** The thread the previous turn belonged to, for the attribution candidate list. */
|
|
331
342
|
threadBefore(sessionId: string, turnNo: number): string | null;
|
|
332
343
|
/**
|
|
@@ -411,6 +422,22 @@ export declare class Soul {
|
|
|
411
422
|
limit?: number;
|
|
412
423
|
order?: 'asc' | 'desc';
|
|
413
424
|
}): Fact[];
|
|
425
|
+
/**
|
|
426
|
+
* What we know about one person right now: their record, the recent events, and the
|
|
427
|
+
* questions about them that are still open.
|
|
428
|
+
*
|
|
429
|
+
* This is the reading side of the person layer. It deliberately does not summarise with a
|
|
430
|
+
* model — the caller asks "how is she" and the model decides how to use these lines.
|
|
431
|
+
*/
|
|
432
|
+
personCard(nameOrId: string, options?: {
|
|
433
|
+
limit?: number;
|
|
434
|
+
}): {
|
|
435
|
+
entity: Entity | null;
|
|
436
|
+
recent: Fact[];
|
|
437
|
+
openGaps: Gap[];
|
|
438
|
+
};
|
|
439
|
+
/** The open questions still attached to one person (the ones worth asking about). */
|
|
440
|
+
openGapsAbout(entityId: string): Gap[];
|
|
414
441
|
/** A fact plus the conversation behind it: hook first, detail on demand. */
|
|
415
442
|
factDetail(factId: string): FactDetail | null;
|
|
416
443
|
/** Open questions, highest tension first. */
|
|
@@ -535,22 +535,10 @@ export class Soul {
|
|
|
535
535
|
slots,
|
|
536
536
|
});
|
|
537
537
|
facts.push(written);
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
gaps.push(this.store.putGap({
|
|
543
|
-
soulId: this.soulId,
|
|
544
|
-
userId: this.userId,
|
|
545
|
-
aboutEntityId: subject?.value.kind === 'entity' ? subject.value.entityId : undefined,
|
|
546
|
-
aboutFactId: written.id,
|
|
547
|
-
// Whole statement: the gap asks "when did this happen", so the sentence it
|
|
548
|
-
// refers to cannot be a prefix of itself.
|
|
549
|
-
topic: `日期不明:${statement}`,
|
|
550
|
-
tension: 0.3,
|
|
551
|
-
openedAt: at,
|
|
552
|
-
}));
|
|
553
|
-
}
|
|
538
|
+
// A missing date is a property of the fact (`atPrecision: unknown`), not an open
|
|
539
|
+
// question. Turning every undated fact into a gap produced 223 open gaps in two days,
|
|
540
|
+
// of which 78% were '日期不明:…' — they can never be closed, and they bury the gaps
|
|
541
|
+
// that are worth asking about. The fact stays; the question is not created.
|
|
554
542
|
return written;
|
|
555
543
|
};
|
|
556
544
|
// Operations carry the merge decision (Mem0's ADD/UPDATE/DELETE); plain facts are
|
|
@@ -818,6 +806,17 @@ export class Soul {
|
|
|
818
806
|
closeIdleThreads(sessionId, options = {}) {
|
|
819
807
|
return this.store.closeIdleThreads(this.soulId, this.userId, sessionId, options.idleTurns ?? 6, options.idleMs ?? 24 * 60 * 60_000, options.archivedSessionIds ?? [], options.at ?? Date.now());
|
|
820
808
|
}
|
|
809
|
+
/**
|
|
810
|
+
* Housekeeping that should run once per turn: close open questions nobody answered.
|
|
811
|
+
*
|
|
812
|
+
* A gap is only worth asking about while it is fresh; 14 days of silence means the moment
|
|
813
|
+
* passed. Closing is not deleting — the row keeps its topic, opened_at and the reason.
|
|
814
|
+
*/
|
|
815
|
+
sweep(now = Date.now(), options = {}) {
|
|
816
|
+
const maxAgeDays = options.gapMaxAgeDays ?? 14;
|
|
817
|
+
const gapsClosed = this.store.closeStaleGaps(this.soulId, this.userId, now - maxAgeDays * 86_400_000, 'expired');
|
|
818
|
+
return { gapsClosed };
|
|
819
|
+
}
|
|
821
820
|
/** The thread the previous turn belonged to, for the attribution candidate list. */
|
|
822
821
|
threadBefore(sessionId, turnNo) {
|
|
823
822
|
return this.store.threadBefore(sessionId, turnNo);
|
|
@@ -961,6 +960,32 @@ export class Soul {
|
|
|
961
960
|
limit: options.limit,
|
|
962
961
|
});
|
|
963
962
|
}
|
|
963
|
+
/**
|
|
964
|
+
* What we know about one person right now: their record, the recent events, and the
|
|
965
|
+
* questions about them that are still open.
|
|
966
|
+
*
|
|
967
|
+
* This is the reading side of the person layer. It deliberately does not summarise with a
|
|
968
|
+
* model — the caller asks "how is she" and the model decides how to use these lines.
|
|
969
|
+
*/
|
|
970
|
+
personCard(nameOrId, options = {}) {
|
|
971
|
+
const entity = this.store.entity(nameOrId) ?? this.findEntities(nameOrId, 1)[0] ?? null;
|
|
972
|
+
if (!entity)
|
|
973
|
+
return { entity: null, recent: [], openGaps: [] };
|
|
974
|
+
const recent = this.store.facts({
|
|
975
|
+
soulId: this.soulId,
|
|
976
|
+
userId: this.userId,
|
|
977
|
+
entityId: entity.id,
|
|
978
|
+
limit: options.limit ?? 8,
|
|
979
|
+
});
|
|
980
|
+
const openGaps = this.openGapsAbout(entity.id);
|
|
981
|
+
return { entity, recent, openGaps };
|
|
982
|
+
}
|
|
983
|
+
/** The open questions still attached to one person (the ones worth asking about). */
|
|
984
|
+
openGapsAbout(entityId) {
|
|
985
|
+
return this.store
|
|
986
|
+
.openGaps(this.soulId, this.userId, 500)
|
|
987
|
+
.filter((gap) => gap.aboutEntityId === entityId);
|
|
988
|
+
}
|
|
964
989
|
/** A fact plus the conversation behind it: hook first, detail on demand. */
|
|
965
990
|
factDetail(factId) {
|
|
966
991
|
const fact = this.store.fact(factId);
|
|
@@ -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 = 13;
|
|
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,8 @@ 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 upgradeToV13(db: DatabaseSync): void;
|
|
17
|
+
export declare function upgradeToV12(db: DatabaseSync): void;
|
|
16
18
|
export declare function upgradeToV11(db: DatabaseSync): void;
|
|
17
19
|
export declare function upgradeToV10(db: DatabaseSync): void;
|
|
18
20
|
export declare function upgradeToV9(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 = 13;
|
|
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;
|
|
@@ -174,6 +174,8 @@ CREATE TABLE IF NOT EXISTS facts (
|
|
|
174
174
|
at_text TEXT,
|
|
175
175
|
at_end INTEGER,
|
|
176
176
|
at_precision TEXT NOT NULL DEFAULT 'unknown',
|
|
177
|
+
valid_from INTEGER,
|
|
178
|
+
valid_until INTEGER,
|
|
177
179
|
place_id TEXT,
|
|
178
180
|
status TEXT NOT NULL DEFAULT 'active',
|
|
179
181
|
confidence REAL NOT NULL DEFAULT 1,
|
|
@@ -212,7 +214,8 @@ CREATE TABLE IF NOT EXISTS gaps (
|
|
|
212
214
|
status TEXT NOT NULL DEFAULT 'open',
|
|
213
215
|
opened_at INTEGER NOT NULL,
|
|
214
216
|
closed_at INTEGER,
|
|
215
|
-
closed_by_fact_id TEXT
|
|
217
|
+
closed_by_fact_id TEXT,
|
|
218
|
+
close_reason TEXT
|
|
216
219
|
);
|
|
217
220
|
CREATE INDEX IF NOT EXISTS idx_gaps_open ON gaps(soul_id, user_id, status, tension DESC);
|
|
218
221
|
|
|
@@ -299,6 +302,31 @@ function upgradeToV3(db) {
|
|
|
299
302
|
* old table stays as a read-only legacy copy.
|
|
300
303
|
*/
|
|
301
304
|
/** v6 → v7: turn threads (a topic's turns need not be adjacent) plus turns.thread_id. */
|
|
305
|
+
export function upgradeToV13(db) {
|
|
306
|
+
// Two axes of time, like the legacy memory table had:
|
|
307
|
+
// created_at when we recorded it (never changes)
|
|
308
|
+
// at when the event happened (events only)
|
|
309
|
+
// valid_from when this claim became true
|
|
310
|
+
// valid_until when it stopped being true (NULL = still true)
|
|
311
|
+
// A state ("小林在 A 公司") ends when another fact replaces it; a historical event never
|
|
312
|
+
// stops being true, so its valid_until stays NULL.
|
|
313
|
+
ensureColumn(db, 'facts', 'valid_from', 'INTEGER');
|
|
314
|
+
ensureColumn(db, 'facts', 'valid_until', 'INTEGER');
|
|
315
|
+
db.exec("UPDATE facts SET valid_from = COALESCE(at, created_at) WHERE valid_from IS NULL");
|
|
316
|
+
// Retracted rows stopped holding when their successor was recorded (or when they were).
|
|
317
|
+
db.exec(`UPDATE facts SET valid_until = COALESCE(
|
|
318
|
+
(SELECT n.created_at FROM facts n WHERE n.id = facts.superseded_by),
|
|
319
|
+
created_at)
|
|
320
|
+
WHERE valid_until IS NULL AND status = 'retracted'`);
|
|
321
|
+
}
|
|
322
|
+
export function upgradeToV12(db) {
|
|
323
|
+
// A missing date is a property of the fact, not an open question. Existing gaps created by
|
|
324
|
+
// that old rule are closed with the reason, so the remaining open gaps are the ones that
|
|
325
|
+
// were worth asking about.
|
|
326
|
+
ensureColumn(db, 'gaps', 'close_reason', 'TEXT');
|
|
327
|
+
db.prepare(`UPDATE gaps SET status = 'closed', closed_at = ?, close_reason = 'time-unknown-not-a-gap'
|
|
328
|
+
WHERE status = 'open' AND topic LIKE '日期不明:%'`).run(Date.now());
|
|
329
|
+
}
|
|
302
330
|
export function upgradeToV11(db) {
|
|
303
331
|
// 原则候选 stored a decision as a slot; it is role+status now. The row becomes a lesson
|
|
304
332
|
// candidate — held and shown to the classifier, injected nowhere until it is placed.
|
|
@@ -463,8 +491,8 @@ export function openSqlite(file) {
|
|
|
463
491
|
upgradeToV7(db);
|
|
464
492
|
if (version < 8)
|
|
465
493
|
upgradeToV8(db);
|
|
466
|
-
if (version <
|
|
467
|
-
|
|
494
|
+
if (version < 13)
|
|
495
|
+
upgradeToV13(db);
|
|
468
496
|
// v6 adds tables only; SCHEMA creates them on every open, nothing to copy.
|
|
469
497
|
setVersion(db, SCHEMA_VERSION);
|
|
470
498
|
db.exec('COMMIT');
|
|
@@ -834,9 +862,10 @@ export function openSqlite(file) {
|
|
|
834
862
|
const inserted = db
|
|
835
863
|
.prepare(`INSERT OR IGNORE INTO facts
|
|
836
864
|
(id, soul_id, user_id, statement, type_name, role, holder, at, at_text, at_end, at_precision,
|
|
837
|
-
place_id, status, confidence, source, source_turn_id, superseded_by,
|
|
838
|
-
|
|
839
|
-
|
|
865
|
+
valid_from, valid_until, place_id, status, confidence, source, source_turn_id, superseded_by,
|
|
866
|
+
created_at)
|
|
867
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
868
|
+
.run(id, spec.soulId, spec.userId, spec.statement, spec.typeName ?? null, role, spec.holder ?? 'world', spec.at ?? null, spec.atText ?? null, spec.atEnd ?? null, spec.atPrecision ?? 'unknown', spec.validFrom ?? spec.at ?? createdAt, spec.validUntil ?? null, spec.placeId ?? null, spec.status ?? 'active', spec.confidence ?? 1, spec.source ?? 'experienced', spec.sourceTurnId ?? null, spec.supersededBy ?? null, createdAt);
|
|
840
869
|
if (Number(inserted.changes) > 0) {
|
|
841
870
|
const insertSlot = db.prepare(`INSERT INTO fact_slots (fact_id, slot, value_type, entity_id, value_num, value_text, ref_fact_id)
|
|
842
871
|
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
@@ -889,9 +918,14 @@ export function openSqlite(file) {
|
|
|
889
918
|
return rows.map((row) => rowToFact(row, slots));
|
|
890
919
|
},
|
|
891
920
|
supersedeFact(oldId, newId) {
|
|
921
|
+
// The old claim stopped holding when the new one was recorded. `valid_until` is the
|
|
922
|
+
// time axis; `status` is the lifecycle — both are written, and they answer different
|
|
923
|
+
// questions ("was it ever true?" vs "is it still the current state?").
|
|
924
|
+
const recordedAt = Number(db.prepare('SELECT created_at FROM facts WHERE id = ?').get(newId)?.created_at ??
|
|
925
|
+
Date.now());
|
|
892
926
|
withRetry(() => db
|
|
893
|
-
.prepare(`UPDATE facts SET status = '${FACT_STATUS.retracted}', superseded_by = ? WHERE id = ?`)
|
|
894
|
-
.run(newId, oldId));
|
|
927
|
+
.prepare(`UPDATE facts SET status = '${FACT_STATUS.retracted}', superseded_by = ?, valid_until = ? WHERE id = ?`)
|
|
928
|
+
.run(newId, recordedAt, oldId));
|
|
895
929
|
},
|
|
896
930
|
/* ---------------- gaps ---------------- */
|
|
897
931
|
putGap(spec) {
|
|
@@ -918,6 +952,17 @@ export function openSqlite(file) {
|
|
|
918
952
|
WHERE id = ?`)
|
|
919
953
|
.run(at, closedByFactId ?? null, id));
|
|
920
954
|
},
|
|
955
|
+
closeStaleGaps(soulId, userId, beforeAt, reason) {
|
|
956
|
+
let changed = 0;
|
|
957
|
+
withRetry(() => {
|
|
958
|
+
const result = db
|
|
959
|
+
.prepare(`UPDATE gaps SET status = 'closed', closed_at = ?, close_reason = ?
|
|
960
|
+
WHERE soul_id = ? AND user_id = ? AND status = 'open' AND opened_at < ?`)
|
|
961
|
+
.run(Date.now(), reason, soulId, userId, beforeAt);
|
|
962
|
+
changed = Number(result.changes);
|
|
963
|
+
});
|
|
964
|
+
return changed;
|
|
965
|
+
},
|
|
921
966
|
turn(id) {
|
|
922
967
|
const row = db.prepare('SELECT * FROM turns WHERE id = ?').get(id);
|
|
923
968
|
return row ? rowToTurnPair(row) : null;
|
|
@@ -1362,6 +1407,8 @@ function rowToFact(row, slots) {
|
|
|
1362
1407
|
atText: row.at_text === null ? undefined : String(row.at_text),
|
|
1363
1408
|
atEnd: row.at_end === null ? undefined : Number(row.at_end),
|
|
1364
1409
|
atPrecision: (row.at_precision ?? 'unknown'),
|
|
1410
|
+
validFrom: row.valid_from == null ? undefined : Number(row.valid_from),
|
|
1411
|
+
validUntil: row.valid_until == null ? undefined : Number(row.valid_until),
|
|
1365
1412
|
placeId: row.place_id === null ? undefined : String(row.place_id),
|
|
1366
1413
|
status: String(row.status),
|
|
1367
1414
|
confidence: Number(row.confidence),
|
|
@@ -103,6 +103,11 @@ export interface SoulStore {
|
|
|
103
103
|
putGap(spec: GapSpec): Gap;
|
|
104
104
|
openGaps(soulId: string, userId: string, limit?: number): Gap[];
|
|
105
105
|
closeGap(id: string, closedByFactId?: string, at?: number): void;
|
|
106
|
+
/**
|
|
107
|
+
* Close every open gap older than `beforeAt`, recording why. Returns how many were closed.
|
|
108
|
+
* An open question that nobody answered in weeks is not a question any more.
|
|
109
|
+
*/
|
|
110
|
+
closeStaleGaps(soulId: string, userId: string, beforeAt: number, reason: string): number;
|
|
106
111
|
/** Corrections the user made, newest first — the objective signal for behaviour changes. */
|
|
107
112
|
corrections(soulId: string, userId: string, limit?: number): Correction[];
|
|
108
113
|
/** Everything a portable bundle needs, scoped to one soul/user. */
|
|
@@ -385,6 +385,10 @@ export interface Fact {
|
|
|
385
385
|
atEnd?: number;
|
|
386
386
|
/** Locatability, not precision: unknown is a value (ADR 0032). */
|
|
387
387
|
atPrecision?: AtPrecision;
|
|
388
|
+
/** When this claim became true (defaults to `at` or the record time). */
|
|
389
|
+
validFrom?: number;
|
|
390
|
+
/** When it stopped being true; undefined means it still holds. */
|
|
391
|
+
validUntil?: number;
|
|
388
392
|
placeId?: string;
|
|
389
393
|
status: FactStatus;
|
|
390
394
|
confidence: number;
|
|
@@ -411,6 +415,10 @@ export interface FactSpec {
|
|
|
411
415
|
atText?: string;
|
|
412
416
|
atEnd?: number;
|
|
413
417
|
atPrecision?: AtPrecision;
|
|
418
|
+
/** When the claim became true; the writer defaults it to `at` or the record time. */
|
|
419
|
+
validFrom?: number;
|
|
420
|
+
/** When it stopped being true; omitted means it still holds. */
|
|
421
|
+
validUntil?: number;
|
|
414
422
|
placeId?: string;
|
|
415
423
|
status?: FactStatus;
|
|
416
424
|
confidence?: number;
|