@modusensus/dsh-mneme 0.7.5 → 0.7.7
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/.github/workflows/publish.yml +58 -0
- package/CHANGELOG.md +2 -0
- package/README.md +18 -13
- package/SECURITY.md +3 -3
- package/dsh-mneme/CHANGELOG.md +28 -0
- package/dsh-mneme/README.md +11 -8
- package/dsh-mneme/lib/config.js +9 -0
- package/dsh-mneme/lib/dream/sleep.js +121 -0
- package/dsh-mneme/lib/service.js +41 -0
- package/dsh-mneme/lib/store.js +79 -0
- package/dsh-mneme/lib/tools.js +24 -18
- package/dsh-mneme/package-lock.json +2 -2
- package/dsh-mneme/package.json +1 -1
- package/dsh-mneme/scripts/sync-lib.js +3 -3
- package/dsh-mneme/src/client.js +2050 -0
- package/dsh-mneme/src/config.js +9 -0
- package/dsh-mneme/src/dream/sleep.js +121 -0
- package/dsh-mneme/src/service.js +41 -0
- package/dsh-mneme/src/store.js +79 -0
- package/dsh-mneme/src/tools.js +24 -18
- package/dsh-mneme/test/client.test.js +10 -5
- package/dsh-mneme/test/sleep.test.js +159 -0
- package/dsh-mneme/test/tools.test.js +133 -0
- package/package.json +1 -1
package/dsh-mneme/src/config.js
CHANGED
|
@@ -274,6 +274,15 @@ export const Config = z.object({
|
|
|
274
274
|
z.const("high"),
|
|
275
275
|
z.const("none")
|
|
276
276
|
]).default("none"),
|
|
277
|
+
// Batch entity extraction during sleep (issue #23). The write-path extractor
|
|
278
|
+
// only fires when entityExtractionEnabled is on (an LLM call per write);
|
|
279
|
+
// this additive phase backfills entities/attrs/relations for memories that
|
|
280
|
+
// never went through it, so stores that leave the write-path extractor off
|
|
281
|
+
// still accumulate an ego graph as long as sleep runs. On by default (it is
|
|
282
|
+
// a no-op until a sleep cycle fires), capped per run to bound token spend.
|
|
283
|
+
sleepEntityExtractionEnabled: z.boolean().default(true),
|
|
284
|
+
// Max memories entity-extracted per sleep run (oldest un-extracted first).
|
|
285
|
+
sleepEntityExtractionMaxPerRun: z.natural().min(1).max(100).default(20),
|
|
277
286
|
|
|
278
287
|
// --- epistemic trust: memory source credibility (v0.4.5) -----------------
|
|
279
288
|
// Distinguish memories by source: observation (measured / witnessed),
|
|
@@ -19,6 +19,7 @@ import { validateDecisions, applyDecisions } from "./decisions.js";
|
|
|
19
19
|
import { findPotentialConflicts } from "./clustering.js";
|
|
20
20
|
import { buildReceipt } from "../dream.js";
|
|
21
21
|
import { computeHeat } from "../heat.js";
|
|
22
|
+
import { extractEntities } from "../entities/extractor.js";
|
|
22
23
|
|
|
23
24
|
const SUMMARY_MAX = 120;
|
|
24
25
|
// Conflict similarity threshold per strictness level (v0.4.0):
|
|
@@ -396,6 +397,123 @@ function phaseRelations(service, config, logger, runId, signal = null) {
|
|
|
396
397
|
};
|
|
397
398
|
}
|
|
398
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Phase 4.5 — batch entity extraction. The write-path extractor (index.js)
|
|
402
|
+
* only fires when entityExtractionEnabled is on — one LLM call per write, a
|
|
403
|
+
* deliberate cost decision — so default installs never accumulate entities,
|
|
404
|
+
* which is exactly why the ego-graph panel (issue #23) renders blank for users
|
|
405
|
+
* with plenty of memories. This phase backfills the entity graph in bulk:
|
|
406
|
+
* memories that carry no entity attrs yet are passed through extractEntities
|
|
407
|
+
* one at a time (oldest first, capped per run). Write-path stays untouched —
|
|
408
|
+
* this is an additive channel for sleep users. Fail-safe per memory: one bad
|
|
409
|
+
* extract never aborts the phase or the cycle.
|
|
410
|
+
*/
|
|
411
|
+
async function phaseEntityExtraction(ctx, service, config, logger, runId, signal = null) {
|
|
412
|
+
if (config.sleepEntityExtractionEnabled !== true) {
|
|
413
|
+
return { status: "skipped", reason: "disabled" };
|
|
414
|
+
}
|
|
415
|
+
const route = resolveSleepRoute(ctx, config, logger);
|
|
416
|
+
if (!route) return { status: "skipped", reason: "no llm route" };
|
|
417
|
+
const maxPerRun = config.sleepEntityExtractionMaxPerRun ?? 20;
|
|
418
|
+
|
|
419
|
+
// Oldest un-extracted first. The store returns bounded pages via SQL (no
|
|
420
|
+
// O(N) full-table scan); JS pages through offsets and only pays the
|
|
421
|
+
// getAttrsByMemory cross-table check on each page, stopping once it has
|
|
422
|
+
// maxPerRun un-extracted memories or the store is exhausted. Every memory
|
|
423
|
+
// this phase touches gets stamped entity_extracted_at (successful or
|
|
424
|
+
// entity-less extracts alike) so a text with no extractable entities is not
|
|
425
|
+
// re-queued forever; failures are NOT stamped, so a transient LLM hiccup
|
|
426
|
+
// retries next cycle.
|
|
427
|
+
const candidates = [];
|
|
428
|
+
for (let offset = 0; candidates.length < maxPerRun; offset += 100) {
|
|
429
|
+
const page = service.listForEntityExtraction({ limit: 100, offset });
|
|
430
|
+
if (page.length === 0) break;
|
|
431
|
+
candidates.push(...page.filter((m) => (service.getAttrsByMemory?.(m.id) ?? []).length === 0));
|
|
432
|
+
}
|
|
433
|
+
candidates.length = Math.min(candidates.length, maxPerRun);
|
|
434
|
+
if (candidates.length === 0) return { status: "skipped", reason: "no memories to extract" };
|
|
435
|
+
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
436
|
+
|
|
437
|
+
// extractEntities expects callLLM(messages, options) → text; adapt sleep's
|
|
438
|
+
// streamText + route resolution (same precedence as the other phases).
|
|
439
|
+
const callLLM = async (messages) => {
|
|
440
|
+
const r = resolveSleepRoute(ctx, config, logger);
|
|
441
|
+
if (!r) return undefined;
|
|
442
|
+
return streamText(ctx, {
|
|
443
|
+
...r,
|
|
444
|
+
purpose: "sleep-entity-extract",
|
|
445
|
+
maxTokens: 4096,
|
|
446
|
+
...(config.sleepReasoningEffort && config.sleepReasoningEffort !== "none"
|
|
447
|
+
? { reasoningEffort: config.sleepReasoningEffort }
|
|
448
|
+
: {}),
|
|
449
|
+
messages
|
|
450
|
+
});
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
let extracted = 0;
|
|
454
|
+
let failed = 0;
|
|
455
|
+
let aborted = false;
|
|
456
|
+
const extractedIds = [];
|
|
457
|
+
for (const m of candidates) {
|
|
458
|
+
if (signal?.aborted) {
|
|
459
|
+
aborted = true;
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
const now = new Date().toISOString();
|
|
463
|
+
try {
|
|
464
|
+
// Stamp a pending marker BEFORE the LLM call so a crash mid-extract (the
|
|
465
|
+
// stamp and the entity writes are not one transaction) cannot re-queue
|
|
466
|
+
// this memory while we are already working it. Cleared on outcome.
|
|
467
|
+
service.setMemoryMetadata?.(m.id, { pending_extracted_at: now });
|
|
468
|
+
} catch (error) {
|
|
469
|
+
logger?.warn?.(`dsh-mneme sleep: failed to stamp pending_extracted_at for ${m.id}: ${String(error)}`);
|
|
470
|
+
failed++;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const result = await extractEntities(m, { store: service, config, callLLM, logger });
|
|
475
|
+
if (result?.ok) {
|
|
476
|
+
extracted++;
|
|
477
|
+
extractedIds.push(m.id);
|
|
478
|
+
// Stamp done regardless of whether the LLM found entities — an empty
|
|
479
|
+
// extract is still a definitive answer for this memory.
|
|
480
|
+
try {
|
|
481
|
+
service.setMemoryMetadata?.(m.id, { entity_extracted_at: now, pending_extracted_at: null });
|
|
482
|
+
} catch (error) {
|
|
483
|
+
logger?.warn?.(`dsh-mneme sleep: failed to stamp entity_extracted_at for ${m.id}: ${String(error)}`);
|
|
484
|
+
}
|
|
485
|
+
} else {
|
|
486
|
+
failed++;
|
|
487
|
+
logger?.warn?.(`dsh-mneme sleep: entity extraction failed for ${m.id}: ${result?.error ?? "unknown"}`);
|
|
488
|
+
try {
|
|
489
|
+
service.setMemoryMetadata?.(m.id, { pending_extracted_at: null });
|
|
490
|
+
} catch (error) {
|
|
491
|
+
logger?.warn?.(`dsh-mneme sleep: failed to clear pending_extracted_at for ${m.id}: ${String(error)}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
failed++;
|
|
496
|
+
logger?.warn?.(`dsh-mneme sleep: entity extraction threw for ${m.id}: ${String(error)}`);
|
|
497
|
+
try {
|
|
498
|
+
service.setMemoryMetadata?.(m.id, { pending_extracted_at: null });
|
|
499
|
+
} catch (err) {
|
|
500
|
+
logger?.warn?.(`dsh-mneme sleep: failed to clear pending_extracted_at for ${m.id}: ${String(err)}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
// Any success counts as ok (failures are surfaced via detail.failed + warn,
|
|
506
|
+
// matching phaseConflicts); all-failed reports failed so deriveStatus can
|
|
507
|
+
// reflect a broken route without aborting the other phases.
|
|
508
|
+
status: aborted ? "aborted" : extracted > 0 ? "ok" : failed > 0 ? "failed" : "noop",
|
|
509
|
+
scanned: candidates.length,
|
|
510
|
+
extracted,
|
|
511
|
+
failed,
|
|
512
|
+
aborted,
|
|
513
|
+
extractedIds
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
399
517
|
// ---------------------------------------------------------------- run
|
|
400
518
|
|
|
401
519
|
function deriveStatus(phases) {
|
|
@@ -429,6 +547,9 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
|
|
|
429
547
|
await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic, signal));
|
|
430
548
|
await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
|
|
431
549
|
await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
|
|
550
|
+
// entity-extraction runs before relation completion so freshly minted
|
|
551
|
+
// entities get their orphan relations completed in the same cycle.
|
|
552
|
+
await attempt("entity-extraction", () => phaseEntityExtraction(ctx, service, config, logger, runId, signal));
|
|
432
553
|
await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
|
|
433
554
|
|
|
434
555
|
const status = deriveStatus(phases);
|
package/dsh-mneme/src/service.js
CHANGED
|
@@ -1608,9 +1608,48 @@ export function createService({ store, mirror, config, onWrite, logger, settings
|
|
|
1608
1608
|
embeddedCount: () => store.embeddedCount(),
|
|
1609
1609
|
list: (o) => store.list(o),
|
|
1610
1610
|
all: () => store.all(),
|
|
1611
|
+
listForEntityExtraction: (opts) => store.listForEntityExtraction(opts),
|
|
1611
1612
|
count: (type, opts) => store.count(type, opts),
|
|
1612
1613
|
stats: (opts) => store.stats(opts),
|
|
1613
1614
|
getById: (id) => store.getById(id),
|
|
1615
|
+
// issue #48: resolve a possibly-truncated id to its canonical full id.
|
|
1616
|
+
// Exact hit wins; otherwise the input is treated as a prefix of the id
|
|
1617
|
+
// PRIMARY KEY. Never guesses on ambiguity — returns the candidates and the
|
|
1618
|
+
// caller must pass a full id. Outcome is {ok:true,id} or {ok:false,reason,
|
|
1619
|
+
// message} with reason ∈ invalid | not-found | ambiguous. warnMiss logs the
|
|
1620
|
+
// silent-miss case the delete tool previously swallowed (no return channel
|
|
1621
|
+
// for it, so observability has to live here in the service layer).
|
|
1622
|
+
resolveMemoryId: (input, { warnMiss = false } = {}) => {
|
|
1623
|
+
const bad = (reason, message) => ({ ok: false, reason, message });
|
|
1624
|
+
if (typeof input !== "string") return bad("invalid", "memory id is required");
|
|
1625
|
+
// 手抄/上下文压缩来的 id 可能带首尾空白,统一 trim 后再做精确与前缀解析。
|
|
1626
|
+
const id = input.trim();
|
|
1627
|
+
if (!id) return bad("invalid", "memory id is required");
|
|
1628
|
+
const exact = store.getById(id);
|
|
1629
|
+
if (exact) return { ok: true, id: exact.id };
|
|
1630
|
+
const matches = store.listByIdPrefix(id);
|
|
1631
|
+
if (matches.length === 0) {
|
|
1632
|
+
if (warnMiss) {
|
|
1633
|
+
logger?.warn?.(
|
|
1634
|
+
`[dsh-mneme] memory id "${id}" matched nothing (exact or prefix) — ` +
|
|
1635
|
+
"no entry was deleted; ids are full-length, pass one from memory_list/memory_search output or delete by query=…"
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
return bad(
|
|
1639
|
+
"not-found",
|
|
1640
|
+
`memory not found: ${id} (checked exact id and prefix; use a full id from memory_list/memory_search output)`
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
if (matches.length > 1) {
|
|
1644
|
+
const sample = matches.slice(0, 5).map((m) => m.id);
|
|
1645
|
+
const tail = matches.length > sample.length ? ` …(+${matches.length - sample.length})` : "";
|
|
1646
|
+
return bad(
|
|
1647
|
+
"ambiguous",
|
|
1648
|
+
`memory id prefix "${id}" matches ${matches.length} entries (${sample.join(", ")}${tail}); refusing to guess — pass a full id`
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
return { ok: true, id: matches[0].id };
|
|
1652
|
+
},
|
|
1614
1653
|
remove: (id) => {
|
|
1615
1654
|
store.remove(id);
|
|
1616
1655
|
afterSync("write");
|
|
@@ -1824,9 +1863,11 @@ export function createService({ store, mirror, config, onWrite, logger, settings
|
|
|
1824
1863
|
applyMemoryTags: (memoryId, tags) => store.setMemoryTags(memoryId, tags),
|
|
1825
1864
|
saveAttr: (r) => store.saveAttr(r),
|
|
1826
1865
|
createEntity: (r) => store.createEntity(r),
|
|
1866
|
+
updateEntity: (id, patch) => store.updateEntity(id, patch),
|
|
1827
1867
|
findEntityByName: (n) => store.findEntityByName(n),
|
|
1828
1868
|
findEntityById: (id) => store.findEntityById(id),
|
|
1829
1869
|
getAttrsByMemory: (id) => store.getAttrsByMemory(id),
|
|
1870
|
+
setMemoryMetadata: (id, metadata) => store.setMemoryMetadata(id, metadata),
|
|
1830
1871
|
getCurrentAttrs: (id) => store.getCurrentAttrs(id),
|
|
1831
1872
|
migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
|
|
1832
1873
|
};
|
package/dsh-mneme/src/store.js
CHANGED
|
@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
20
20
|
epistemic_status TEXT NOT NULL DEFAULT 'subjective',
|
|
21
21
|
last_accessed_at TEXT,
|
|
22
22
|
_full_content TEXT,
|
|
23
|
+
metadata TEXT, -- JSON: free-form extras (e.g. sleep entity_extracted_at)
|
|
23
24
|
created_at TEXT NOT NULL,
|
|
24
25
|
updated_at TEXT NOT NULL
|
|
25
26
|
);
|
|
@@ -314,6 +315,14 @@ function parseTags(raw) {
|
|
|
314
315
|
|
|
315
316
|
function toRow(row) {
|
|
316
317
|
if (!row) return undefined;
|
|
318
|
+
let metadata;
|
|
319
|
+
if (row.metadata != null) {
|
|
320
|
+
try {
|
|
321
|
+
metadata = JSON.parse(row.metadata);
|
|
322
|
+
} catch {
|
|
323
|
+
metadata = row.metadata;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
317
326
|
return {
|
|
318
327
|
id: row.id,
|
|
319
328
|
type: row.type,
|
|
@@ -329,6 +338,7 @@ function toRow(row) {
|
|
|
329
338
|
content_history: parseJsonArray(row.content_history),
|
|
330
339
|
quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
|
|
331
340
|
epistemic_status: row.epistemic_status ?? "subjective",
|
|
341
|
+
metadata,
|
|
332
342
|
created_at: row.created_at,
|
|
333
343
|
updated_at: row.updated_at,
|
|
334
344
|
last_accessed_at: row.last_accessed_at ?? undefined,
|
|
@@ -589,6 +599,7 @@ export function createStore(path) {
|
|
|
589
599
|
addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
|
|
590
600
|
addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
|
|
591
601
|
addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
|
|
602
|
+
addColumn("memories", "metadata", "ALTER TABLE memories ADD COLUMN metadata TEXT");
|
|
592
603
|
|
|
593
604
|
// Composite index for session-lifecycle queries (dispose/restore/listBySession).
|
|
594
605
|
// Created post-migration, NOT in SCHEMA: on legacy DBs both columns arrive via
|
|
@@ -697,6 +708,27 @@ export function createStore(path) {
|
|
|
697
708
|
return toRow(row);
|
|
698
709
|
}
|
|
699
710
|
|
|
711
|
+
/**
|
|
712
|
+
* issue #48: resolve a truncated id — as can leak through an agent's context
|
|
713
|
+
* window when list/search output is shortened — by matching it as a prefix of
|
|
714
|
+
* the id PRIMARY KEY. Exact lookups keep using getById; this only serves
|
|
715
|
+
* resolving a *candidate* id. Returns up to 51 rows so the caller can tell
|
|
716
|
+
* "unique" from "ambiguous" without a second query. LIKE wildcards are
|
|
717
|
+
* stripped from the input (a valid id fragment is hex/UUID text, never % or
|
|
718
|
+
* _). Prefix over a PK stays an index scan, so this is cheap even at scale.
|
|
719
|
+
*/
|
|
720
|
+
function listByIdPrefix(idPrefix) {
|
|
721
|
+
if (typeof idPrefix !== "string" || !idPrefix.trim()) return [];
|
|
722
|
+
// LIKE 通配符不参与 id 匹配,一律剥掉。若剥完为空(如 id="%"),
|
|
723
|
+
// 不能让 SQL 退化成 `LIKE '%'` 全表命中——那会让单条记忆被误删,
|
|
724
|
+
// 一律视为无匹配返回。
|
|
725
|
+
const safe = idPrefix.replace(/[\\%_]/g, "");
|
|
726
|
+
if (!safe) return [];
|
|
727
|
+
const rows = db.prepare("SELECT * FROM memories WHERE id LIKE ? LIMIT 51")
|
|
728
|
+
.all(`${safe}%`);
|
|
729
|
+
return rows.map(toRow);
|
|
730
|
+
}
|
|
731
|
+
|
|
700
732
|
/**
|
|
701
733
|
* Case-insensitive exact title lookup (v0.6.1 wiki-link). COLLATE NOCASE
|
|
702
734
|
* folds ASCII case (CJK titles are inherently case-free, so they match
|
|
@@ -813,6 +845,50 @@ export function createStore(path) {
|
|
|
813
845
|
});
|
|
814
846
|
}
|
|
815
847
|
|
|
848
|
+
/**
|
|
849
|
+
* Bounded scan for sleep's entity-extraction phase: oldest memories without
|
|
850
|
+
* an entity_extracted_at stamp (row-level filters only — attr presence is a
|
|
851
|
+
* cross-table check, so the caller filters the returned page via
|
|
852
|
+
* getAttrsByMemory). Caller pages with {limit, offset}; a full-table scan +
|
|
853
|
+
* JS filter would be O(N) per sleep cycle.
|
|
854
|
+
*/
|
|
855
|
+
function listForEntityExtraction({ limit = 50, offset = 0 } = {}) {
|
|
856
|
+
// node:sqlite has no StatementSync#pluck; all() returns row objects.
|
|
857
|
+
return db
|
|
858
|
+
.prepare(`
|
|
859
|
+
SELECT id FROM memories
|
|
860
|
+
WHERE (archived = 0 OR archived IS NULL)
|
|
861
|
+
AND session_disposed_at IS NULL
|
|
862
|
+
AND (forgotten = 0 OR forgotten IS NULL)
|
|
863
|
+
AND type != 'summary'
|
|
864
|
+
AND content IS NOT NULL AND content != ''
|
|
865
|
+
AND (metadata IS NULL OR json_extract(metadata, '$.entity_extracted_at') IS NULL)
|
|
866
|
+
ORDER BY created_at ASC
|
|
867
|
+
LIMIT ? OFFSET ?
|
|
868
|
+
`)
|
|
869
|
+
.all(limit, offset)
|
|
870
|
+
.map((row) => getById(row.id));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Lightweight metadata-only update (used by sleep's batch entity extraction
|
|
875
|
+
* to stamp `entity_extracted_at` without disturbing title/content/embedding
|
|
876
|
+
* or the normal update semantics). Does not bump updated_at — metadata
|
|
877
|
+
* stamps are bookkeeping, not content, so they must not fake freshness.
|
|
878
|
+
* Incoming fields are MERGED into the existing metadata object so a stamp
|
|
879
|
+
* never clobbers metadata another path just wrote.
|
|
880
|
+
*/
|
|
881
|
+
function setMemoryMetadata(id, metadata) {
|
|
882
|
+
const existing = getById(id);
|
|
883
|
+
if (!existing) throw new Error(`memory not found: ${id}`);
|
|
884
|
+
const patch = typeof metadata === "string"
|
|
885
|
+
? JSON.parse(metadata)
|
|
886
|
+
: (metadata ?? {});
|
|
887
|
+
const merged = { ...(existing.metadata ?? {}), ...patch };
|
|
888
|
+
db.prepare("UPDATE memories SET metadata = ? WHERE id = ?").run(JSON.stringify(merged), id);
|
|
889
|
+
return getById(id);
|
|
890
|
+
}
|
|
891
|
+
|
|
816
892
|
/**
|
|
817
893
|
* Atomic compare-and-set update: applies `patch` only when the row still
|
|
818
894
|
* carries `expectedUpdatedAt` (the version token read by the caller). Returns
|
|
@@ -2210,8 +2286,11 @@ export function createStore(path) {
|
|
|
2210
2286
|
count,
|
|
2211
2287
|
stats,
|
|
2212
2288
|
getById,
|
|
2289
|
+
listByIdPrefix,
|
|
2213
2290
|
save,
|
|
2214
2291
|
update,
|
|
2292
|
+
listForEntityExtraction,
|
|
2293
|
+
setMemoryMetadata,
|
|
2215
2294
|
compareAndUpdate,
|
|
2216
2295
|
remove,
|
|
2217
2296
|
setForget,
|
package/dsh-mneme/src/tools.js
CHANGED
|
@@ -302,7 +302,7 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
302
302
|
name: "memory_update",
|
|
303
303
|
description: "Modify an existing memory entry (title, content, type, tags, importance).",
|
|
304
304
|
parameters: {
|
|
305
|
-
id: { type: "string", required: true, description: "Memory id" },
|
|
305
|
+
id: { type: "string", required: true, description: "Memory id (full id from memory_list/memory_search output, or a unique prefix of it)" },
|
|
306
306
|
title: { type: "string" },
|
|
307
307
|
content: { type: "string" },
|
|
308
308
|
type: { type: "string", enum: ["preference", "project", "decision", "history", "user", "fact"] },
|
|
@@ -329,7 +329,9 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
329
329
|
render: (_args, value) => TEXT_OUTPUT(`Updated memory ${value.memory.id}: ${value.memory.title}`)
|
|
330
330
|
},
|
|
331
331
|
async execute(args) {
|
|
332
|
-
const
|
|
332
|
+
const resolved = service.resolveMemoryId(args.id);
|
|
333
|
+
if (!resolved.ok) throw new Error(resolved.message);
|
|
334
|
+
const memory = service.update(resolved.id, {
|
|
333
335
|
title: args.title,
|
|
334
336
|
content: args.content,
|
|
335
337
|
type: args.type,
|
|
@@ -342,9 +344,9 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
342
344
|
|
|
343
345
|
defineTool({
|
|
344
346
|
name: "memory_delete",
|
|
345
|
-
description: "Permanently delete a memory entry. Pass id for exact delete, or query to delete the single best-matching entry by text — lets the agent honor 'delete the memory about X' without a prior list/search round trip.",
|
|
347
|
+
description: "Permanently delete a memory entry. Pass id for exact delete (full id, or a unique prefix of it — ambiguous prefixes are rejected), or query to delete the single best-matching entry by text — lets the agent honor 'delete the memory about X' without a prior list/search round trip. An id that matches nothing is logged as a warning instead of failing silently.",
|
|
346
348
|
parameters: {
|
|
347
|
-
id: { type: "string", description: "
|
|
349
|
+
id: { type: "string", description: "Memory id to delete: full id (from memory_list/memory_search output) or a unique prefix of it; a miss is logged (warn) and returns deleted:false" },
|
|
348
350
|
query: { type: "string", description: "Delete the best-matching entry for this text (searches title/content/tags; uses hybrid recall when an embedder is configured)" }
|
|
349
351
|
},
|
|
350
352
|
output: {
|
|
@@ -353,13 +355,19 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
353
355
|
additionalProperties: false,
|
|
354
356
|
properties: { deleted: { type: "boolean", required: true } }
|
|
355
357
|
},
|
|
356
|
-
render: (_args, value) => TEXT_OUTPUT(value.deleted
|
|
358
|
+
render: (_args, value) => TEXT_OUTPUT(value.deleted
|
|
359
|
+
? "Memory deleted."
|
|
360
|
+
: "Memory not found — nothing was deleted. Pass a full id (or a unique prefix) from memory_list/memory_search output, or delete by query=… instead.")
|
|
357
361
|
},
|
|
358
362
|
async execute(args) {
|
|
359
363
|
if (args.id) {
|
|
360
|
-
const
|
|
361
|
-
if (
|
|
362
|
-
|
|
364
|
+
const resolved = service.resolveMemoryId(args.id, { warnMiss: true });
|
|
365
|
+
if (!resolved.ok) {
|
|
366
|
+
if (resolved.reason === "ambiguous") throw new Error(resolved.message);
|
|
367
|
+
return { deleted: false };
|
|
368
|
+
}
|
|
369
|
+
service.remove(resolved.id);
|
|
370
|
+
return { deleted: true };
|
|
363
371
|
}
|
|
364
372
|
if (args.query) {
|
|
365
373
|
const [best] = await service.searchMemories(args.query, { mode: "auto", topK: 1, useRerank: true });
|
|
@@ -378,7 +386,7 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
378
386
|
"Stop a memory from being auto-injected and from appearing in searches and lists without deleting it. " +
|
|
379
387
|
"The entry stays in storage; pass forgotten: false to restore it.",
|
|
380
388
|
parameters: {
|
|
381
|
-
id: { type: "string", required: true },
|
|
389
|
+
id: { type: "string", required: true, description: "Memory id: full id (from memory_list/memory_search output) or a unique prefix of it; ambiguous prefixes are rejected" },
|
|
382
390
|
forgotten: { type: "boolean", description: "Suppress (true, default) or restore (false) the entry's visibility" }
|
|
383
391
|
},
|
|
384
392
|
output: {
|
|
@@ -399,10 +407,9 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
399
407
|
render: (_args, value) => TEXT_OUTPUT(`Memory ${value.memory.id} injection ${value.memory.forgotten ? "suppressed" : "restored"}.`)
|
|
400
408
|
},
|
|
401
409
|
async execute(args) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const memory = service.setForget(args.id, args.forgotten ?? true);
|
|
410
|
+
const resolved = service.resolveMemoryId(args.id);
|
|
411
|
+
if (!resolved.ok) throw new Error(resolved.message);
|
|
412
|
+
const memory = service.setForget(resolved.id, args.forgotten ?? true);
|
|
406
413
|
return { memory: { id: memory.id, forgotten: memory.forgotten } };
|
|
407
414
|
}
|
|
408
415
|
}),
|
|
@@ -414,7 +421,7 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
414
421
|
"Archived entries stay in storage and are recoverable: pass archived=false to restore, and use memory_list with " +
|
|
415
422
|
"include_archived=true to find archived entries.",
|
|
416
423
|
parameters: {
|
|
417
|
-
id: { type: "string", required: true, description: "Memory id" },
|
|
424
|
+
id: { type: "string", required: true, description: "Memory id: full id (from memory_list/memory_search output) or a unique prefix of it; ambiguous prefixes are rejected" },
|
|
418
425
|
archived: { type: "boolean", description: "Archive (true, default) or restore (false) the entry" }
|
|
419
426
|
},
|
|
420
427
|
output: {
|
|
@@ -435,10 +442,9 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
435
442
|
render: (_args, value) => TEXT_OUTPUT(`Memory ${value.memory.id} ${value.memory.archived ? "archived" : "restored"}.`)
|
|
436
443
|
},
|
|
437
444
|
async execute(args) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const memory = service.setArchived(args.id, args.archived ?? true);
|
|
445
|
+
const resolved = service.resolveMemoryId(args.id);
|
|
446
|
+
if (!resolved.ok) throw new Error(resolved.message);
|
|
447
|
+
const memory = service.setArchived(resolved.id, args.archived ?? true);
|
|
442
448
|
return { memory: { id: memory.id, archived: memory.archived } };
|
|
443
449
|
}
|
|
444
450
|
})
|
|
@@ -5,7 +5,9 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
7
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
-
|
|
8
|
+
// src/ is the single source of truth for every module (including the Web
|
|
9
|
+
// client bundle); lib/ is build output produced by `npm run sync`.
|
|
10
|
+
const clientSource = readFileSync(join(root, "src/client.js"), "utf8");
|
|
9
11
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
10
12
|
|
|
11
13
|
// The Web client bundle registers itself via __ModuleLoader__.load. DSH
|
|
@@ -18,11 +20,14 @@ test("client bundle registers under the package name", () => {
|
|
|
18
20
|
assert.equal(match[1], pkg.name, "registered id must equal package.json name");
|
|
19
21
|
});
|
|
20
22
|
|
|
21
|
-
// client.js is
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// client.js is authored under src/ like every other module; lib/client.js is
|
|
24
|
+
// build output generated by `npm run sync` (prepack), so it must stay a
|
|
25
|
+
// byte-identical copy — drift here means the sync wasn't run.
|
|
26
|
+
test("client bundle is src-authored and synced into lib", () => {
|
|
27
|
+
assert.equal(existsSync(join(root, "src/client.js")), true, "src/client.js must exist");
|
|
25
28
|
assert.equal(existsSync(join(root, "lib/client.js")), true, "lib/client.js must exist");
|
|
29
|
+
const libClient = readFileSync(join(root, "lib/client.js"), "utf8");
|
|
30
|
+
assert.equal(libClient, clientSource, "lib/client.js must equal src/client.js (run `npm run sync`)");
|
|
26
31
|
});
|
|
27
32
|
|
|
28
33
|
// The memory entry lives at the sidebar foot, not in the settings modal: the
|
|
@@ -371,3 +371,162 @@ test("sleep: runSleep is abortable via signal between phases", async () => {
|
|
|
371
371
|
assert.equal(result.phases.conflicts, undefined, "aborted before any phase ran");
|
|
372
372
|
store.close();
|
|
373
373
|
});
|
|
374
|
+
|
|
375
|
+
// ------------------------------------------------ entity extraction (issue #23)
|
|
376
|
+
|
|
377
|
+
// Write-path extractor is gated on entityExtractionEnabled (default off), so
|
|
378
|
+
// default installs never accumulate entities → ego-graph panel blank. This
|
|
379
|
+
// sleep phase backfills it; these tests cover gating, idempotence and
|
|
380
|
+
// fail-safe batching.
|
|
381
|
+
const ENTITY_LLM_JSON = JSON.stringify({
|
|
382
|
+
entities: [{ name: "X", type: "concept", attrs: [{ key: "k", value: "v" }] }],
|
|
383
|
+
relations: []
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("sleep: entity extraction skips when disabled", async () => {
|
|
387
|
+
const { service, store } = setup();
|
|
388
|
+
makeMemory(service, "记忆A", "内容A", "history");
|
|
389
|
+
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
390
|
+
const result = await runSleep(
|
|
391
|
+
ctx, service,
|
|
392
|
+
baseConfig({ sleepEntityExtractionEnabled: false }),
|
|
393
|
+
ctx.logger, null, null
|
|
394
|
+
);
|
|
395
|
+
assert.equal(result.phases["entity-extraction"].status, "skipped", "disabled → skipped");
|
|
396
|
+
store.close();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("sleep: extracted memories are stamped and not re-extracted", async () => {
|
|
400
|
+
const { service, store } = setup();
|
|
401
|
+
const mem = makeMemory(service, "记忆B", "内容B", "history");
|
|
402
|
+
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
403
|
+
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
404
|
+
// First cycle backfills → memory gets entity_extracted_at stamped.
|
|
405
|
+
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
406
|
+
assert.equal(first.phases["entity-extraction"].status, "ok", "first cycle extracts");
|
|
407
|
+
assert.ok(service.getById(mem.id).metadata?.entity_extracted_at, "stamp written");
|
|
408
|
+
// Second cycle has nothing un-stamped left → skipped.
|
|
409
|
+
const second = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
410
|
+
assert.equal(second.phases["entity-extraction"].status, "skipped", "nothing left to extract");
|
|
411
|
+
store.close();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("sleep: entity-less extracts are stamped so they are not re-queued forever", async () => {
|
|
415
|
+
const { service, store } = setup();
|
|
416
|
+
const mem = makeMemory(service, "无实体记忆", "这里没有任何实体", "history");
|
|
417
|
+
// LLM legitimately returns no entities — must still be stamped, else every
|
|
418
|
+
// sleep cycle re-extracts the same text forever (issue #23 regression).
|
|
419
|
+
const ctx = mockCtx(() => JSON.stringify({ entities: [], relations: [] }), { provider: "p", model: "m" });
|
|
420
|
+
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
421
|
+
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
422
|
+
assert.equal(first.phases["entity-extraction"].status, "ok", "empty extract counts as handled");
|
|
423
|
+
assert.equal(first.phases["entity-extraction"].extracted, 1);
|
|
424
|
+
assert.ok(service.getById(mem.id).metadata?.entity_extracted_at, "stamp written even with no entities");
|
|
425
|
+
const second = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
426
|
+
assert.equal(second.phases["entity-extraction"].status, "skipped", "stamped → not re-queued");
|
|
427
|
+
store.close();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("sleep: entity extraction backfills entities for un-extracted memories", async () => {
|
|
431
|
+
const { service, store } = setup();
|
|
432
|
+
const mem = makeMemory(service, "记忆C", "内容C", "history");
|
|
433
|
+
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
434
|
+
const result = await runSleep(
|
|
435
|
+
ctx, service,
|
|
436
|
+
baseConfig({ sleepEntityExtractionEnabled: true }),
|
|
437
|
+
ctx.logger, null, null
|
|
438
|
+
);
|
|
439
|
+
const phase = result.phases["entity-extraction"];
|
|
440
|
+
assert.equal(phase.status, "ok");
|
|
441
|
+
assert.equal(phase.extracted, 1);
|
|
442
|
+
assert.ok(store.listEntities().length > 0, "entity minted");
|
|
443
|
+
assert.ok(service.getAttrsByMemory(mem.id).length > 0, "memory now carries entity attrs");
|
|
444
|
+
store.close();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("sleep: a failing extraction does not abort the batch and retries next cycle", async () => {
|
|
448
|
+
const { service, store } = setup();
|
|
449
|
+
const bad = makeMemory(service, "坏记忆", "bad 内容", "history");
|
|
450
|
+
const good = makeMemory(service, "好记忆", "good 内容", "history");
|
|
451
|
+
const ctx = mockCtx(
|
|
452
|
+
(user) => (String(user).includes("bad") ? "这不是合法JSON{{{" : ENTITY_LLM_JSON),
|
|
453
|
+
{ provider: "p", model: "m" }
|
|
454
|
+
);
|
|
455
|
+
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
456
|
+
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
457
|
+
const phase = first.phases["entity-extraction"];
|
|
458
|
+
assert.equal(phase.extracted, 1, "good memory extracted");
|
|
459
|
+
assert.equal(phase.failed, 1, "bad memory counted as failed");
|
|
460
|
+
assert.equal(phase.status, "ok", "partial success is ok");
|
|
461
|
+
assert.ok(store.listEntities().length > 0, "at least one entity minted");
|
|
462
|
+
// Good memory stamped; bad one not → second cycle retries only the bad one.
|
|
463
|
+
assert.ok(service.getById(good.id).metadata?.entity_extracted_at, "good memory stamped");
|
|
464
|
+
assert.equal(service.getById(bad.id).metadata?.entity_extracted_at, undefined, "failed memory not stamped");
|
|
465
|
+
const second = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
466
|
+
assert.equal(second.phases["entity-extraction"].extracted, 0, "bad memory still fails");
|
|
467
|
+
assert.equal(second.phases["entity-extraction"].failed, 1, "failed memory retried");
|
|
468
|
+
store.close();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("sleep: metadata merge keeps unrelated fields when stamping (review #4)", async () => {
|
|
472
|
+
const { service, store } = setup();
|
|
473
|
+
const mem = makeMemory(service, "记忆M", "内容M", "history");
|
|
474
|
+
service.setMemoryMetadata(mem.id, { custom_flag: "keep-me" });
|
|
475
|
+
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
476
|
+
await runSleep(ctx, service, baseConfig({ sleepEntityExtractionEnabled: true }), ctx.logger, null, null);
|
|
477
|
+
const meta = service.getById(mem.id).metadata;
|
|
478
|
+
assert.equal(meta.custom_flag, "keep-me", "pre-existing metadata not clobbered");
|
|
479
|
+
assert.ok(meta.entity_extracted_at, "stamp added alongside");
|
|
480
|
+
store.close();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("sleep: pending_extracted_at is cleared on success and on failure (review #3)", async () => {
|
|
484
|
+
const { service, store } = setup();
|
|
485
|
+
const good = makeMemory(service, "好记忆", "内容G", "history");
|
|
486
|
+
const bad = makeMemory(service, "坏记忆", "bad 内容", "history");
|
|
487
|
+
const ctx = mockCtx(
|
|
488
|
+
(user) => (String(user).includes("bad") ? "不是JSON{{{" : ENTITY_LLM_JSON),
|
|
489
|
+
{ provider: "p", model: "m" }
|
|
490
|
+
);
|
|
491
|
+
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
492
|
+
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
493
|
+
const phase = first.phases["entity-extraction"];
|
|
494
|
+
assert.equal(phase.extracted, 1);
|
|
495
|
+
assert.equal(phase.failed, 1);
|
|
496
|
+
assert.equal(service.getById(good.id).metadata?.pending_extracted_at, null, "success clears pending");
|
|
497
|
+
assert.equal(service.getById(bad.id).metadata?.pending_extracted_at, null, "failure clears pending");
|
|
498
|
+
store.close();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("sleep: all-failed extraction surfaces failed status through deriveStatus (review #5)", async () => {
|
|
502
|
+
const { service, store } = setup();
|
|
503
|
+
makeMemory(service, "坏记忆1", "bad 一", "history");
|
|
504
|
+
makeMemory(service, "坏记忆2", "bad 二", "history");
|
|
505
|
+
const ctx = mockCtx(() => "不是JSON{{{", { provider: "p", model: "m" });
|
|
506
|
+
const result = await runSleep(ctx, service, baseConfig({ sleepEntityExtractionEnabled: true }), ctx.logger, null, null);
|
|
507
|
+
const phase = result.phases["entity-extraction"];
|
|
508
|
+
assert.equal(phase.status, "failed", "all-failed extraction reports failed");
|
|
509
|
+
assert.equal(phase.failed, 2);
|
|
510
|
+
assert.ok(["failed", "degraded"].includes(result.status), "deriveStatus folds failed into run status");
|
|
511
|
+
store.close();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("store: listForEntityExtraction pages oldest un-stamped candidates (review #2)", async () => {
|
|
515
|
+
const { service, store } = setup();
|
|
516
|
+
const a = makeMemory(service, "甲", "内容甲", "history");
|
|
517
|
+
makeMemory(service, "乙", "内容乙", "history");
|
|
518
|
+
const archived = makeMemory(service, "归档", "内容归档", "history");
|
|
519
|
+
store.setArchived(archived.id, true);
|
|
520
|
+
makeMemory(service, "摘要", "内容摘要", "summary");
|
|
521
|
+
makeMemory(service, "丙", "内容丙", "history"); // second un-stamped candidate so oldest-first has two to compare
|
|
522
|
+
service.setMemoryMetadata(a.id, { entity_extracted_at: new Date().toISOString() });
|
|
523
|
+
const list = store.listForEntityExtraction({ limit: 10, offset: 0 });
|
|
524
|
+
const titles = list.map((m) => m.title);
|
|
525
|
+
assert.ok(!titles.includes("归档"), "archived excluded");
|
|
526
|
+
assert.ok(!titles.includes("摘要"), "summary excluded");
|
|
527
|
+
assert.ok(!titles.includes("甲"), "already-stamped excluded");
|
|
528
|
+
assert.ok(titles.includes("乙"), "un-stamped included");
|
|
529
|
+
assert.ok(titles.includes("丙"), "un-stamped included");
|
|
530
|
+
assert.equal(list[0].created_at <= list[1].created_at, true, "oldest first");
|
|
531
|
+
store.close();
|
|
532
|
+
});
|