soulbridge 0.0.107 → 0.0.109

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.
@@ -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> 会话转写 → 记忆与情绪采样',
@@ -422,6 +422,22 @@ export declare class Soul {
422
422
  limit?: number;
423
423
  order?: 'asc' | 'desc';
424
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[];
425
441
  /** A fact plus the conversation behind it: hook first, detail on demand. */
426
442
  factDetail(factId: string): FactDetail | null;
427
443
  /** Open questions, highest tension first. */
@@ -960,6 +960,32 @@ export class Soul {
960
960
  limit: options.limit,
961
961
  });
962
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
+ }
963
989
  /** A fact plus the conversation behind it: hook first, detail on demand. */
964
990
  factDetail(factId) {
965
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 = 12;
3
+ export declare const SCHEMA_VERSION = 14;
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 upgradeToV14(db: DatabaseSync): void;
17
+ export declare function upgradeToV13(db: DatabaseSync): void;
16
18
  export declare function upgradeToV12(db: DatabaseSync): void;
17
19
  export declare function upgradeToV11(db: DatabaseSync): void;
18
20
  export declare function upgradeToV10(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 = 12;
15
+ export const SCHEMA_VERSION = 14;
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,
@@ -300,6 +302,30 @@ function upgradeToV3(db) {
300
302
  * old table stays as a read-only legacy copy.
301
303
  */
302
304
  /** v6 → v7: turn threads (a topic's turns need not be adjacent) plus turns.thread_id. */
305
+ export function upgradeToV14(db) {
306
+ // Correct the semantics v13 used: a dated statement stops describing the present when the
307
+ // event is over. "小林在 10-24 被裁员" is valid on 10-24, not forever — otherwise every
308
+ // historical event would look like a current state.
309
+ db.exec(`UPDATE facts SET valid_until = COALESCE(at_end, at)
310
+ WHERE valid_until IS NULL AND at IS NOT NULL AND status <> 'retracted'`);
311
+ }
312
+ export function upgradeToV13(db) {
313
+ // Two axes of time, like the legacy memory table had:
314
+ // created_at when we recorded it (never changes)
315
+ // at when the event happened (events only)
316
+ // valid_from when this claim became true
317
+ // valid_until when it stopped being true (NULL = still true)
318
+ // A state ("小林在 A 公司") ends when another fact replaces it; a historical event never
319
+ // stops being true, so its valid_until stays NULL.
320
+ ensureColumn(db, 'facts', 'valid_from', 'INTEGER');
321
+ ensureColumn(db, 'facts', 'valid_until', 'INTEGER');
322
+ db.exec("UPDATE facts SET valid_from = COALESCE(at, created_at) WHERE valid_from IS NULL");
323
+ // Retracted rows stopped holding when their successor was recorded (or when they were).
324
+ db.exec(`UPDATE facts SET valid_until = COALESCE(
325
+ (SELECT n.created_at FROM facts n WHERE n.id = facts.superseded_by),
326
+ created_at)
327
+ WHERE valid_until IS NULL AND status = 'retracted'`);
328
+ }
303
329
  export function upgradeToV12(db) {
304
330
  // A missing date is a property of the fact, not an open question. Existing gaps created by
305
331
  // that old rule are closed with the reason, so the remaining open gaps are the ones that
@@ -472,8 +498,8 @@ export function openSqlite(file) {
472
498
  upgradeToV7(db);
473
499
  if (version < 8)
474
500
  upgradeToV8(db);
475
- if (version < 12)
476
- upgradeToV12(db);
501
+ if (version < 14)
502
+ upgradeToV14(db);
477
503
  // v6 adds tables only; SCHEMA creates them on every open, nothing to copy.
478
504
  setVersion(db, SCHEMA_VERSION);
479
505
  db.exec('COMMIT');
@@ -843,9 +869,15 @@ export function openSqlite(file) {
843
869
  const inserted = db
844
870
  .prepare(`INSERT OR IGNORE INTO facts
845
871
  (id, soul_id, user_id, statement, type_name, role, holder, at, at_text, at_end, at_precision,
846
- place_id, status, confidence, source, source_turn_id, superseded_by, created_at)
847
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
848
- .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.placeId ?? null, spec.status ?? 'active', spec.confidence ?? 1, spec.source ?? 'experienced', spec.sourceTurnId ?? null, spec.supersededBy ?? null, createdAt);
872
+ valid_from, valid_until, place_id, status, confidence, source, source_turn_id, superseded_by,
873
+ created_at)
874
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
875
+ .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,
876
+ // A dated statement describes the world at that moment. For an event, validity
877
+ // ends when it is over (at, or at_end for a range); a standing state has no date
878
+ // and stays open until something replaces it. "Current" is therefore one query:
879
+ // valid_until IS NULL OR valid_until > now.
880
+ spec.validUntil ?? spec.atEnd ?? spec.at ?? null, spec.placeId ?? null, spec.status ?? 'active', spec.confidence ?? 1, spec.source ?? 'experienced', spec.sourceTurnId ?? null, spec.supersededBy ?? null, createdAt);
849
881
  if (Number(inserted.changes) > 0) {
850
882
  const insertSlot = db.prepare(`INSERT INTO fact_slots (fact_id, slot, value_type, entity_id, value_num, value_text, ref_fact_id)
851
883
  VALUES (?, ?, ?, ?, ?, ?, ?)`);
@@ -898,9 +930,14 @@ export function openSqlite(file) {
898
930
  return rows.map((row) => rowToFact(row, slots));
899
931
  },
900
932
  supersedeFact(oldId, newId) {
933
+ // The old claim stopped holding when the new one was recorded. `valid_until` is the
934
+ // time axis; `status` is the lifecycle — both are written, and they answer different
935
+ // questions ("was it ever true?" vs "is it still the current state?").
936
+ const recordedAt = Number(db.prepare('SELECT created_at FROM facts WHERE id = ?').get(newId)?.created_at ??
937
+ Date.now());
901
938
  withRetry(() => db
902
- .prepare(`UPDATE facts SET status = '${FACT_STATUS.retracted}', superseded_by = ? WHERE id = ?`)
903
- .run(newId, oldId));
939
+ .prepare(`UPDATE facts SET status = '${FACT_STATUS.retracted}', superseded_by = ?, valid_until = ? WHERE id = ?`)
940
+ .run(newId, recordedAt, oldId));
904
941
  },
905
942
  /* ---------------- gaps ---------------- */
906
943
  putGap(spec) {
@@ -1382,6 +1419,8 @@ function rowToFact(row, slots) {
1382
1419
  atText: row.at_text === null ? undefined : String(row.at_text),
1383
1420
  atEnd: row.at_end === null ? undefined : Number(row.at_end),
1384
1421
  atPrecision: (row.at_precision ?? 'unknown'),
1422
+ validFrom: row.valid_from == null ? undefined : Number(row.valid_from),
1423
+ validUntil: row.valid_until == null ? undefined : Number(row.valid_until),
1385
1424
  placeId: row.place_id === null ? undefined : String(row.place_id),
1386
1425
  status: String(row.status),
1387
1426
  confidence: Number(row.confidence),
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulbridge",
3
- "version": "0.0.107",
3
+ "version": "0.0.109",
4
4
  "description": "A personality layer for any agent: continuity, forgetting, and growth across tools.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",