@modusensus/dsh-mneme 0.3.6 → 0.3.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/README.md CHANGED
@@ -265,9 +265,6 @@ npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动
265
265
 
266
266
  > 设计文档位于仓库根 `docs/`,链接以 `../docs/` 相对路径指向(GitHub 上从本目录打开可正常跳转)。
267
267
 
268
- - [记忆库设计](../docs/superpowers/specs/2026-08-13-dsh-mneme-design.md)
269
- - [autoDream 设计](../docs/superpowers/specs/2026-08-13-dsh-mneme-autodream-design.md)
270
- - [实施计划](../docs/superpowers/plans/2026-08-13-dsh-memory-autodream.md)
271
268
  - [实体结构化记忆设计](docs/ENTITIES.md)
272
269
  - [语义增强架构](docs/SEMANTIC.md)
273
270
  - [本地模型部署指南](docs/LOCAL_MODEL.md)
package/lib/index.js CHANGED
@@ -80,11 +80,28 @@ export const apply = (ctx, config) => {
80
80
  const vectorIndex = createVectorIndex({ store, logger: ctx.logger });
81
81
  service.setVectorIndex(vectorIndex);
82
82
 
83
+ // Human edits in mirror files win on every sync; merge them back first.
84
+ // TYPE_FILE maps each memory type to its mirror filename. Read every type's
85
+ // edits up front: mergeHumanEdits re-renders ALL mirror files on success, so
86
+ // a per-type read-then-merge loop would overwrite edits in files not yet read
87
+ // (e.g. preferences.md merging would clobber unsynced projects.md edits).
88
+ const humanEdits = new Map();
89
+ for (const type of Object.keys(TYPE_FILE)) {
90
+ humanEdits.set(type, mirror.readHumanEdits(type));
91
+ }
92
+ const applyHumanEdits = () => {
93
+ for (const [type, edits] of humanEdits) {
94
+ if (edits.length) service.mergeHumanEdits(type, edits);
95
+ }
96
+ };
97
+
83
98
  let embedder = null;
84
99
  let reranker = null;
85
100
  if (cfg.embedProvider === "openai") {
86
101
  embedder = createEmbedder({ store, settings, logger: ctx.logger });
87
102
  service.setEmbedder(embedder);
103
+ // legacy OpenAI embedder is immediately usable
104
+ applyHumanEdits();
88
105
  } else {
89
106
  try {
90
107
  embedder = createEmbedderByProvider(cfg.embedProvider, {
@@ -97,12 +114,18 @@ export const apply = (ctx, config) => {
97
114
  logger: ctx.logger
98
115
  });
99
116
  service.setEmbedder(embedder);
100
- embedder.init().catch((error) => {
101
- ctx.logger?.warn?.(`[dsh-mneme] embedder init failed, search degrades to keyword: ${String(error)}`);
102
- service.setEmbedder(null);
103
- });
117
+ // issue #6: wait for extractor init before applying human edits, so
118
+ // scheduled embeddings see a ready embedder.
119
+ embedder.init()
120
+ .then(() => applyHumanEdits())
121
+ .catch((error) => {
122
+ ctx.logger?.warn?.(`[dsh-mneme] embedder init failed, search degrades to keyword: ${String(error)}`);
123
+ service.setEmbedder(null);
124
+ applyHumanEdits();
125
+ });
104
126
  } catch (error) {
105
127
  ctx.logger?.warn?.(`[dsh-mneme] embedder unavailable, search degrades to keyword: ${String(error)}`);
128
+ applyHumanEdits();
106
129
  }
107
130
  }
108
131
 
@@ -139,19 +162,6 @@ export const apply = (ctx, config) => {
139
162
  commands.sync();
140
163
  }
141
164
 
142
- // Human edits in mirror files win on every sync; merge them back first.
143
- // TYPE_FILE maps each memory type to its mirror filename. Read every type's
144
- // edits up front: mergeHumanEdits re-renders ALL mirror files on success, so
145
- // a per-type read-then-merge loop would overwrite edits in files not yet read
146
- // (e.g. preferences.md merging would clobber unsynced projects.md edits).
147
- const humanEdits = new Map();
148
- for (const type of Object.keys(TYPE_FILE)) {
149
- humanEdits.set(type, mirror.readHumanEdits(type));
150
- }
151
- for (const [type, edits] of humanEdits) {
152
- if (edits.length) service.mergeHumanEdits(type, edits);
153
- }
154
-
155
165
  // Dream scheduler: automatic consolidation + summary runs, triggered by
156
166
  // store growth. Writes through the service fire the dream hook, which asks
157
167
  // the scheduler to (re)schedule a run once absolute and since-last-run
@@ -58,16 +58,21 @@ export class LocalEmbedder {
58
58
  // Test hook: replace the pipeline factory without touching modules.
59
59
  this.engineFactory = opts.engineFactory || defaultPipelineLoader;
60
60
  this.extractor = null;
61
+ // issue #6: readiness flag for the service's scheduleEmbed gate. False until
62
+ // init() succeeds, so "ready" in embedder is observable even pre-init.
63
+ this.ready = false;
61
64
  }
62
65
 
63
- /** Load the model; throws when it cannot be loaded. */
66
+ /** Load the model; throws when it cannot be loaded. Idempotent. */
64
67
  async init() {
68
+ if (this.extractor) return this; // already initialized: no-op
65
69
  const options = {
66
70
  dtype: this.useDtype,
67
71
  device: this.device
68
72
  };
69
73
  if (this.cacheDir) options.cache_dir = this.cacheDir;
70
74
  this.extractor = await this.engineFactory("feature-extraction", this.model, options);
75
+ this.ready = true; // service reads this to flush queued re-embeds
71
76
  this.logger?.info?.(
72
77
  `[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device})`
73
78
  );
@@ -107,6 +112,7 @@ export class LocalEmbedder {
107
112
  // best-effort: some engines free resources on GC
108
113
  }
109
114
  this.extractor = null;
115
+ this.ready = false;
110
116
  }
111
117
  }
112
118
 
package/lib/service.js CHANGED
@@ -37,11 +37,69 @@ export function createService({ store, mirror, config, onWrite, logger }) {
37
37
  // replays them exactly once against the committed state.
38
38
  let txDepth = 0;
39
39
 
40
+ // issue #6 (part 2): startup race defense. A local embedder (LocalEmbedder /
41
+ // Ollama) exposes an async init(), so between `setEmbedder` and init()
42
+ // resolving there is a window where embedSingle would throw "not initialized"
43
+ // and the re-embed would be silently dropped. When the embedder carries a
44
+ // `ready` flag we queue writes in embedPending until init sets ready=true,
45
+ // then flush them through the embedder's real interface. Embedders without a
46
+ // `ready` flag (legacy OpenAI, instantly usable) keep their old behavior.
47
+ let embedPending = [];
48
+ let embedReadyTimer = null;
49
+ const EMBED_PENDING_MAX = 100; // bound the queue; drop oldest beyond this
50
+ const EMBED_READY_POLL_MS = 100;
51
+ const EMBED_READY_POLL_LIMIT = 30; // ~3s ceiling; never poll forever
52
+
53
+ /** Flush the queued re-embeds once the embedder is ready. Fail-safe. */
54
+ function flushEmbedPending() {
55
+ if (!embedder || embedPending.length === 0) return;
56
+ const batch = embedPending.splice(0, embedPending.length);
57
+ for (const memory of batch) {
58
+ try {
59
+ if (!memory?.id) continue;
60
+ if (typeof embedder.schedule === "function") {
61
+ embedder.schedule(memory);
62
+ } else if (typeof embedder.embedSingle === "function") {
63
+ const text = [memory.title, memory.content].filter(Boolean).join("\n");
64
+ if (!text) continue;
65
+ embedder
66
+ .embedSingle(text)
67
+ .then((vec) => {
68
+ if (Array.isArray(vec) && vec.length) {
69
+ store.setEmbedding(memory.id, vec);
70
+ }
71
+ })
72
+ .catch((err) => {
73
+ logger?.warn?.("flushEmbedPending embedSingle failed:", err);
74
+ });
75
+ }
76
+ } catch (err) {
77
+ logger?.warn?.("flushEmbedPending failed:", err);
78
+ }
79
+ }
80
+ }
81
+
82
+ function stopEmbedReadyPolling() {
83
+ if (embedReadyTimer) {
84
+ clearInterval(embedReadyTimer);
85
+ embedReadyTimer = null;
86
+ }
87
+ }
88
+
40
89
  function scheduleEmbed(memory) {
41
90
  try {
42
91
  if (txDepth > 0) return; // deferred to the transaction's commit
43
92
  if (!embedder || !memory?.id) return;
44
93
 
94
+ // Readiness gate: embedder exposes `ready` (async init) and is not ready
95
+ // yet — queue instead of firing embedSingle into a half-built extractor.
96
+ const hasReady = "ready" in embedder;
97
+ if (hasReady && embedder.ready !== true) {
98
+ if (embedPending.length >= EMBED_PENDING_MAX) embedPending.shift();
99
+ embedPending.push(memory);
100
+ return;
101
+ }
102
+
45
103
  if (typeof embedder.schedule === "function") {
46
104
  embedder.schedule(memory);
47
105
  return;
@@ -681,7 +739,32 @@ export function createService({ store, mirror, config, onWrite, logger }) {
681
739
  toApiList,
682
740
  transaction,
683
741
  setDreamHook(fn) { dreamHook = fn; },
684
- setEmbedder(emb) { embedder = emb; },
742
+ setEmbedder(emb) {
743
+ embedder = emb;
744
+ if (!emb) {
745
+ // embedder removed (init failed in index.js): stop polling and drop
746
+ // queued re-embeds — search just degrades to keyword.
747
+ stopEmbedReadyPolling();
748
+ embedPending = [];
749
+ return;
750
+ }
751
+ if (emb.ready === true) {
752
+ flushEmbedPending();
753
+ return;
754
+ }
755
+ // Async-initializing embedder: poll `ready` until it flips, then flush.
756
+ if ("ready" in emb && embedReadyTimer === null) {
757
+ let attempts = 0;
758
+ embedReadyTimer = setInterval(() => {
759
+ attempts++;
760
+ if (emb.ready === true || attempts >= EMBED_READY_POLL_LIMIT) {
761
+ stopEmbedReadyPolling();
762
+ if (emb.ready === true) flushEmbedPending();
763
+ else embedPending = []; // init never landed: drop the queue
764
+ }
765
+ }, EMBED_READY_POLL_MS);
766
+ }
767
+ },
685
768
  setEntityExtractor(fn) { entityExtractor = fn; },
686
769
  setVectorIndex(vi) { vectorIndex = vi; },
687
770
  setReranker(rn) { reranker = rn; },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 6 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.3.6",
4
+ "version": "0.3.7",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
package/src/index.js CHANGED
@@ -80,11 +80,28 @@ export const apply = (ctx, config) => {
80
80
  const vectorIndex = createVectorIndex({ store, logger: ctx.logger });
81
81
  service.setVectorIndex(vectorIndex);
82
82
 
83
+ // Human edits in mirror files win on every sync; merge them back first.
84
+ // TYPE_FILE maps each memory type to its mirror filename. Read every type's
85
+ // edits up front: mergeHumanEdits re-renders ALL mirror files on success, so
86
+ // a per-type read-then-merge loop would overwrite edits in files not yet read
87
+ // (e.g. preferences.md merging would clobber unsynced projects.md edits).
88
+ const humanEdits = new Map();
89
+ for (const type of Object.keys(TYPE_FILE)) {
90
+ humanEdits.set(type, mirror.readHumanEdits(type));
91
+ }
92
+ const applyHumanEdits = () => {
93
+ for (const [type, edits] of humanEdits) {
94
+ if (edits.length) service.mergeHumanEdits(type, edits);
95
+ }
96
+ };
97
+
83
98
  let embedder = null;
84
99
  let reranker = null;
85
100
  if (cfg.embedProvider === "openai") {
86
101
  embedder = createEmbedder({ store, settings, logger: ctx.logger });
87
102
  service.setEmbedder(embedder);
103
+ // legacy OpenAI embedder is immediately usable
104
+ applyHumanEdits();
88
105
  } else {
89
106
  try {
90
107
  embedder = createEmbedderByProvider(cfg.embedProvider, {
@@ -97,12 +114,18 @@ export const apply = (ctx, config) => {
97
114
  logger: ctx.logger
98
115
  });
99
116
  service.setEmbedder(embedder);
100
- embedder.init().catch((error) => {
101
- ctx.logger?.warn?.(`[dsh-mneme] embedder init failed, search degrades to keyword: ${String(error)}`);
102
- service.setEmbedder(null);
103
- });
117
+ // issue #6: wait for extractor init before applying human edits, so
118
+ // scheduled embeddings see a ready embedder.
119
+ embedder.init()
120
+ .then(() => applyHumanEdits())
121
+ .catch((error) => {
122
+ ctx.logger?.warn?.(`[dsh-mneme] embedder init failed, search degrades to keyword: ${String(error)}`);
123
+ service.setEmbedder(null);
124
+ applyHumanEdits();
125
+ });
104
126
  } catch (error) {
105
127
  ctx.logger?.warn?.(`[dsh-mneme] embedder unavailable, search degrades to keyword: ${String(error)}`);
128
+ applyHumanEdits();
106
129
  }
107
130
  }
108
131
 
@@ -139,19 +162,6 @@ export const apply = (ctx, config) => {
139
162
  commands.sync();
140
163
  }
141
164
 
142
- // Human edits in mirror files win on every sync; merge them back first.
143
- // TYPE_FILE maps each memory type to its mirror filename. Read every type's
144
- // edits up front: mergeHumanEdits re-renders ALL mirror files on success, so
145
- // a per-type read-then-merge loop would overwrite edits in files not yet read
146
- // (e.g. preferences.md merging would clobber unsynced projects.md edits).
147
- const humanEdits = new Map();
148
- for (const type of Object.keys(TYPE_FILE)) {
149
- humanEdits.set(type, mirror.readHumanEdits(type));
150
- }
151
- for (const [type, edits] of humanEdits) {
152
- if (edits.length) service.mergeHumanEdits(type, edits);
153
- }
154
-
155
165
  // Dream scheduler: automatic consolidation + summary runs, triggered by
156
166
  // store growth. Writes through the service fire the dream hook, which asks
157
167
  // the scheduler to (re)schedule a run once absolute and since-last-run
@@ -58,16 +58,21 @@ export class LocalEmbedder {
58
58
  // Test hook: replace the pipeline factory without touching modules.
59
59
  this.engineFactory = opts.engineFactory || defaultPipelineLoader;
60
60
  this.extractor = null;
61
+ // issue #6: readiness flag for the service's scheduleEmbed gate. False until
62
+ // init() succeeds, so "ready" in embedder is observable even pre-init.
63
+ this.ready = false;
61
64
  }
62
65
 
63
- /** Load the model; throws when it cannot be loaded. */
66
+ /** Load the model; throws when it cannot be loaded. Idempotent. */
64
67
  async init() {
68
+ if (this.extractor) return this; // already initialized: no-op
65
69
  const options = {
66
70
  dtype: this.useDtype,
67
71
  device: this.device
68
72
  };
69
73
  if (this.cacheDir) options.cache_dir = this.cacheDir;
70
74
  this.extractor = await this.engineFactory("feature-extraction", this.model, options);
75
+ this.ready = true; // service reads this to flush queued re-embeds
71
76
  this.logger?.info?.(
72
77
  `[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device})`
73
78
  );
@@ -107,6 +112,7 @@ export class LocalEmbedder {
107
112
  // best-effort: some engines free resources on GC
108
113
  }
109
114
  this.extractor = null;
115
+ this.ready = false;
110
116
  }
111
117
  }
112
118
 
package/src/service.js CHANGED
@@ -37,11 +37,69 @@ export function createService({ store, mirror, config, onWrite, logger }) {
37
37
  // replays them exactly once against the committed state.
38
38
  let txDepth = 0;
39
39
 
40
+ // issue #6 (part 2): startup race defense. A local embedder (LocalEmbedder /
41
+ // Ollama) exposes an async init(), so between `setEmbedder` and init()
42
+ // resolving there is a window where embedSingle would throw "not initialized"
43
+ // and the re-embed would be silently dropped. When the embedder carries a
44
+ // `ready` flag we queue writes in embedPending until init sets ready=true,
45
+ // then flush them through the embedder's real interface. Embedders without a
46
+ // `ready` flag (legacy OpenAI, instantly usable) keep their old behavior.
47
+ let embedPending = [];
48
+ let embedReadyTimer = null;
49
+ const EMBED_PENDING_MAX = 100; // bound the queue; drop oldest beyond this
50
+ const EMBED_READY_POLL_MS = 100;
51
+ const EMBED_READY_POLL_LIMIT = 30; // ~3s ceiling; never poll forever
52
+
53
+ /** Flush the queued re-embeds once the embedder is ready. Fail-safe. */
54
+ function flushEmbedPending() {
55
+ if (!embedder || embedPending.length === 0) return;
56
+ const batch = embedPending.splice(0, embedPending.length);
57
+ for (const memory of batch) {
58
+ try {
59
+ if (!memory?.id) continue;
60
+ if (typeof embedder.schedule === "function") {
61
+ embedder.schedule(memory);
62
+ } else if (typeof embedder.embedSingle === "function") {
63
+ const text = [memory.title, memory.content].filter(Boolean).join("\n");
64
+ if (!text) continue;
65
+ embedder
66
+ .embedSingle(text)
67
+ .then((vec) => {
68
+ if (Array.isArray(vec) && vec.length) {
69
+ store.setEmbedding(memory.id, vec);
70
+ }
71
+ })
72
+ .catch((err) => {
73
+ logger?.warn?.("flushEmbedPending embedSingle failed:", err);
74
+ });
75
+ }
76
+ } catch (err) {
77
+ logger?.warn?.("flushEmbedPending failed:", err);
78
+ }
79
+ }
80
+ }
81
+
82
+ function stopEmbedReadyPolling() {
83
+ if (embedReadyTimer) {
84
+ clearInterval(embedReadyTimer);
85
+ embedReadyTimer = null;
86
+ }
87
+ }
88
+
40
89
  function scheduleEmbed(memory) {
41
90
  try {
42
91
  if (txDepth > 0) return; // deferred to the transaction's commit
43
92
  if (!embedder || !memory?.id) return;
44
93
 
94
+ // Readiness gate: embedder exposes `ready` (async init) and is not ready
95
+ // yet — queue instead of firing embedSingle into a half-built extractor.
96
+ const hasReady = "ready" in embedder;
97
+ if (hasReady && embedder.ready !== true) {
98
+ if (embedPending.length >= EMBED_PENDING_MAX) embedPending.shift();
99
+ embedPending.push(memory);
100
+ return;
101
+ }
102
+
45
103
  if (typeof embedder.schedule === "function") {
46
104
  embedder.schedule(memory);
47
105
  return;
@@ -681,7 +739,32 @@ export function createService({ store, mirror, config, onWrite, logger }) {
681
739
  toApiList,
682
740
  transaction,
683
741
  setDreamHook(fn) { dreamHook = fn; },
684
- setEmbedder(emb) { embedder = emb; },
742
+ setEmbedder(emb) {
743
+ embedder = emb;
744
+ if (!emb) {
745
+ // embedder removed (init failed in index.js): stop polling and drop
746
+ // queued re-embeds — search just degrades to keyword.
747
+ stopEmbedReadyPolling();
748
+ embedPending = [];
749
+ return;
750
+ }
751
+ if (emb.ready === true) {
752
+ flushEmbedPending();
753
+ return;
754
+ }
755
+ // Async-initializing embedder: poll `ready` until it flips, then flush.
756
+ if ("ready" in emb && embedReadyTimer === null) {
757
+ let attempts = 0;
758
+ embedReadyTimer = setInterval(() => {
759
+ attempts++;
760
+ if (emb.ready === true || attempts >= EMBED_READY_POLL_LIMIT) {
761
+ stopEmbedReadyPolling();
762
+ if (emb.ready === true) flushEmbedPending();
763
+ else embedPending = []; // init never landed: drop the queue
764
+ }
765
+ }, EMBED_READY_POLL_MS);
766
+ }
767
+ },
685
768
  setEntityExtractor(fn) { entityExtractor = fn; },
686
769
  setVectorIndex(vi) { vectorIndex = vi; },
687
770
  setReranker(rn) { reranker = rn; },