@modusensus/dsh-mneme 0.1.0 → 0.1.2
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 +178 -179
- package/cordis.patch.yml +15 -0
- package/lib/api.js +59 -59
- package/lib/client.js +187 -187
- package/lib/config.js +15 -15
- package/lib/dream/decisions.js +121 -121
- package/lib/dream.js +205 -205
- package/lib/index.js +86 -86
- package/lib/inject.js +22 -22
- package/lib/mirror.js +131 -131
- package/lib/service.js +157 -157
- package/lib/store.js +230 -230
- package/lib/summarize.js +171 -171
- package/lib/tools.js +221 -221
- package/package.json +58 -32
- package/src/api.js +59 -59
- package/src/config.js +15 -15
- package/src/dream/decisions.js +121 -121
- package/src/dream.js +205 -205
- package/src/index.js +86 -86
- package/src/inject.js +22 -22
- package/src/mirror.js +131 -131
- package/src/service.js +157 -157
- package/src/store.js +230 -230
- package/src/summarize.js +171 -171
- package/src/tools.js +221 -221
package/src/mirror.js
CHANGED
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
export const TYPE_FILE = {
|
|
5
|
-
preference: "preferences.md",
|
|
6
|
-
project: "projects.md",
|
|
7
|
-
decision: "decisions.md",
|
|
8
|
-
history: "history.md",
|
|
9
|
-
summary: "summary.md"
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
13
|
-
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
14
|
-
|
|
15
|
-
function esc(text) {
|
|
16
|
-
return String(text).replace(ESCAPE, "\\$1");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function unescape(text) {
|
|
20
|
-
return String(text).replace(UNESCAPE, "$1");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function renderMemory(m) {
|
|
24
|
-
const lines = [];
|
|
25
|
-
lines.push(`## ${esc(m.title)}`);
|
|
26
|
-
lines.push("");
|
|
27
|
-
lines.push(`- **ID**: \`${m.id}\``);
|
|
28
|
-
lines.push(`- **类型**: ${m.type}`);
|
|
29
|
-
lines.push(`- **重要性**: ${m.importance}`);
|
|
30
|
-
lines.push(`- **标签**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
31
|
-
lines.push(`- **更新时间**: ${m.updated_at}`);
|
|
32
|
-
if (m.source) lines.push(`- **来源**: ${esc(m.source)}`);
|
|
33
|
-
lines.push("");
|
|
34
|
-
lines.push(m.content);
|
|
35
|
-
lines.push("");
|
|
36
|
-
lines.push("---");
|
|
37
|
-
lines.push("");
|
|
38
|
-
return lines.join("\n");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function createMirror(dir) {
|
|
42
|
-
mkdirSync(dir, { recursive: true });
|
|
43
|
-
|
|
44
|
-
function filePath(type) {
|
|
45
|
-
const name = TYPE_FILE[type];
|
|
46
|
-
return name ? join(dir, name) : undefined;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Parse a mirror file back into {id, title, content} entries for human edits.
|
|
51
|
-
* Entries are anchored on "- **ID**: `...`" lines that are followed by the
|
|
52
|
-
* "- **类型**:" metadata line (structural entry head): each entry's block
|
|
53
|
-
* spans from its ID line up to the next ID line (or end of file). The block
|
|
54
|
-
* head (the ID line plus the generated metadata run) and the trailing
|
|
55
|
-
* structural "---" separator are stripped; everything in between is the entry
|
|
56
|
-
* body, so user content containing "---", metadata-like lines, or even a
|
|
57
|
-
* machine-format "- **ID**: `x`" line is preserved. The title is the "## "
|
|
58
|
-
* heading preceding the ID line.
|
|
59
|
-
*/
|
|
60
|
-
function readHumanEdits(type = undefined) {
|
|
61
|
-
const types = type ? [type] : Object.keys(TYPE_FILE);
|
|
62
|
-
const edits = [];
|
|
63
|
-
for (const t of types) {
|
|
64
|
-
const file = filePath(t);
|
|
65
|
-
if (!file || !existsSync(file)) continue;
|
|
66
|
-
const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
67
|
-
// Anchor on the ID line only when it is a structural entry head: the
|
|
68
|
-
// machine-rendered ID line is always followed by the "- **类型**:" line.
|
|
69
|
-
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
70
|
-
// or produces a ghost entry.
|
|
71
|
-
const anchors = [...text.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*类型\*\*:/gm)];
|
|
72
|
-
let prevEnd = 0;
|
|
73
|
-
for (let i = 0; i < anchors.length; i++) {
|
|
74
|
-
const anchor = anchors[i];
|
|
75
|
-
const blockStart = anchor.index;
|
|
76
|
-
const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : text.length;
|
|
77
|
-
|
|
78
|
-
// Title: last "## " heading before this ID line (file header region /
|
|
79
|
-
// previous block tail). Body headings of earlier entries come before
|
|
80
|
-
// the structural "---" + "## " of this entry, so the last match wins.
|
|
81
|
-
const titleMatches = [...text.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
|
|
82
|
-
const titleMatch = titleMatches[titleMatches.length - 1];
|
|
83
|
-
|
|
84
|
-
// Body: the ID line and the generated metadata run are structural head;
|
|
85
|
-
// everything after them up to the trailing "---" separator is the body.
|
|
86
|
-
let body = text
|
|
87
|
-
.slice(blockStart, blockEnd)
|
|
88
|
-
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
89
|
-
.replace(/^(- \*\*(类型|重要性|标签|更新时间|来源)\*\*:.*\n?)+/, "");
|
|
90
|
-
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
91
|
-
const lastSep = separators[separators.length - 1];
|
|
92
|
-
if (lastSep) body = body.slice(0, lastSep.index);
|
|
93
|
-
body = body.trim();
|
|
94
|
-
|
|
95
|
-
edits.push({
|
|
96
|
-
id: anchor[1],
|
|
97
|
-
title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
|
|
98
|
-
content: body
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const lineEnd = text.indexOf("\n", blockStart);
|
|
102
|
-
prevEnd = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return edits;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function sync(memories) {
|
|
109
|
-
const byType = {};
|
|
110
|
-
for (const m of memories) {
|
|
111
|
-
(byType[m.type] ??= []).push(m);
|
|
112
|
-
}
|
|
113
|
-
for (const type of Object.keys(TYPE_FILE)) {
|
|
114
|
-
const file = filePath(type);
|
|
115
|
-
const items = (byType[type] ?? [])
|
|
116
|
-
.slice()
|
|
117
|
-
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
118
|
-
if (items.length === 0) {
|
|
119
|
-
// no memories of this type: drop any stale mirror file so deleted
|
|
120
|
-
// memories do not "resurrect" via readHumanEdits
|
|
121
|
-
rmSync(file, { force: true });
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
125
|
-
const body = items.map(renderMemory).join("\n");
|
|
126
|
-
writeFileSync(file, header + body, "utf8");
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return { filePath, sync, readHumanEdits };
|
|
131
|
-
}
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const TYPE_FILE = {
|
|
5
|
+
preference: "preferences.md",
|
|
6
|
+
project: "projects.md",
|
|
7
|
+
decision: "decisions.md",
|
|
8
|
+
history: "history.md",
|
|
9
|
+
summary: "summary.md"
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
13
|
+
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
14
|
+
|
|
15
|
+
function esc(text) {
|
|
16
|
+
return String(text).replace(ESCAPE, "\\$1");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function unescape(text) {
|
|
20
|
+
return String(text).replace(UNESCAPE, "$1");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderMemory(m) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
lines.push(`## ${esc(m.title)}`);
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push(`- **ID**: \`${m.id}\``);
|
|
28
|
+
lines.push(`- **类型**: ${m.type}`);
|
|
29
|
+
lines.push(`- **重要性**: ${m.importance}`);
|
|
30
|
+
lines.push(`- **标签**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
31
|
+
lines.push(`- **更新时间**: ${m.updated_at}`);
|
|
32
|
+
if (m.source) lines.push(`- **来源**: ${esc(m.source)}`);
|
|
33
|
+
lines.push("");
|
|
34
|
+
lines.push(m.content);
|
|
35
|
+
lines.push("");
|
|
36
|
+
lines.push("---");
|
|
37
|
+
lines.push("");
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createMirror(dir) {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
|
|
44
|
+
function filePath(type) {
|
|
45
|
+
const name = TYPE_FILE[type];
|
|
46
|
+
return name ? join(dir, name) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse a mirror file back into {id, title, content} entries for human edits.
|
|
51
|
+
* Entries are anchored on "- **ID**: `...`" lines that are followed by the
|
|
52
|
+
* "- **类型**:" metadata line (structural entry head): each entry's block
|
|
53
|
+
* spans from its ID line up to the next ID line (or end of file). The block
|
|
54
|
+
* head (the ID line plus the generated metadata run) and the trailing
|
|
55
|
+
* structural "---" separator are stripped; everything in between is the entry
|
|
56
|
+
* body, so user content containing "---", metadata-like lines, or even a
|
|
57
|
+
* machine-format "- **ID**: `x`" line is preserved. The title is the "## "
|
|
58
|
+
* heading preceding the ID line.
|
|
59
|
+
*/
|
|
60
|
+
function readHumanEdits(type = undefined) {
|
|
61
|
+
const types = type ? [type] : Object.keys(TYPE_FILE);
|
|
62
|
+
const edits = [];
|
|
63
|
+
for (const t of types) {
|
|
64
|
+
const file = filePath(t);
|
|
65
|
+
if (!file || !existsSync(file)) continue;
|
|
66
|
+
const text = readFileSync(file, "utf8").replace(/\r\n/g, "\n");
|
|
67
|
+
// Anchor on the ID line only when it is a structural entry head: the
|
|
68
|
+
// machine-rendered ID line is always followed by the "- **类型**:" line.
|
|
69
|
+
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
70
|
+
// or produces a ghost entry.
|
|
71
|
+
const anchors = [...text.matchAll(/^- \*\*ID\*\*: `([^`]+)`\n- \*\*类型\*\*:/gm)];
|
|
72
|
+
let prevEnd = 0;
|
|
73
|
+
for (let i = 0; i < anchors.length; i++) {
|
|
74
|
+
const anchor = anchors[i];
|
|
75
|
+
const blockStart = anchor.index;
|
|
76
|
+
const blockEnd = i + 1 < anchors.length ? anchors[i + 1].index : text.length;
|
|
77
|
+
|
|
78
|
+
// Title: last "## " heading before this ID line (file header region /
|
|
79
|
+
// previous block tail). Body headings of earlier entries come before
|
|
80
|
+
// the structural "---" + "## " of this entry, so the last match wins.
|
|
81
|
+
const titleMatches = [...text.slice(prevEnd, blockStart).matchAll(/^## (.+)$/gm)];
|
|
82
|
+
const titleMatch = titleMatches[titleMatches.length - 1];
|
|
83
|
+
|
|
84
|
+
// Body: the ID line and the generated metadata run are structural head;
|
|
85
|
+
// everything after them up to the trailing "---" separator is the body.
|
|
86
|
+
let body = text
|
|
87
|
+
.slice(blockStart, blockEnd)
|
|
88
|
+
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
89
|
+
.replace(/^(- \*\*(类型|重要性|标签|更新时间|来源)\*\*:.*\n?)+/, "");
|
|
90
|
+
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
91
|
+
const lastSep = separators[separators.length - 1];
|
|
92
|
+
if (lastSep) body = body.slice(0, lastSep.index);
|
|
93
|
+
body = body.trim();
|
|
94
|
+
|
|
95
|
+
edits.push({
|
|
96
|
+
id: anchor[1],
|
|
97
|
+
title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
|
|
98
|
+
content: body
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const lineEnd = text.indexOf("\n", blockStart);
|
|
102
|
+
prevEnd = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return edits;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sync(memories) {
|
|
109
|
+
const byType = {};
|
|
110
|
+
for (const m of memories) {
|
|
111
|
+
(byType[m.type] ??= []).push(m);
|
|
112
|
+
}
|
|
113
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
114
|
+
const file = filePath(type);
|
|
115
|
+
const items = (byType[type] ?? [])
|
|
116
|
+
.slice()
|
|
117
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
118
|
+
if (items.length === 0) {
|
|
119
|
+
// no memories of this type: drop any stale mirror file so deleted
|
|
120
|
+
// memories do not "resurrect" via readHumanEdits
|
|
121
|
+
rmSync(file, { force: true });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
125
|
+
const body = items.map(renderMemory).join("\n");
|
|
126
|
+
writeFileSync(file, header + body, "utf8");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { filePath, sync, readHumanEdits };
|
|
131
|
+
}
|
package/src/service.js
CHANGED
|
@@ -1,157 +1,157 @@
|
|
|
1
|
-
const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
|
|
2
|
-
|
|
3
|
-
export function createService({ store, mirror, config, onWrite }) {
|
|
4
|
-
// Optional dream scheduler hook, installed via setDreamHook after creation
|
|
5
|
-
// (the scheduler holds a reference back to the service, so it cannot be
|
|
6
|
-
// passed in the constructor). Fired on the same write events as onWrite.
|
|
7
|
-
let dreamHook = null;
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Fire-and-forget write notification; errors are swallowed to keep write
|
|
11
|
-
* paths clean. The store mutation has already committed, so a throwing
|
|
12
|
-
* subscriber must not surface as a write failure. Archive/forget flags are
|
|
13
|
-
* state toggles, not content writes, so they never notify.
|
|
14
|
-
*/
|
|
15
|
-
function notifyWrite() {
|
|
16
|
-
if (onWrite) {
|
|
17
|
-
try { onWrite(); } catch { /* ignore */ }
|
|
18
|
-
}
|
|
19
|
-
if (dreamHook) {
|
|
20
|
-
try { dreamHook(); } catch { /* ignore */ }
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Save a memory, merging into an existing one when title matches within the same type.
|
|
26
|
-
* @returns {{action: "created"|"merged", memory: object}}
|
|
27
|
-
*/
|
|
28
|
-
function saveWithDedupe(memory) {
|
|
29
|
-
const existing = store
|
|
30
|
-
.list({ type: memory.type, limit: 100 })
|
|
31
|
-
.find((m) => m.title.trim() === String(memory.title).trim());
|
|
32
|
-
if (existing) {
|
|
33
|
-
const merged = store.update(existing.id, {
|
|
34
|
-
content: memory.content ?? existing.content,
|
|
35
|
-
importance: memory.importance ?? existing.importance,
|
|
36
|
-
tags: memory.tags ?? existing.tags,
|
|
37
|
-
title: memory.title ?? existing.title
|
|
38
|
-
});
|
|
39
|
-
syncMirror();
|
|
40
|
-
notifyWrite();
|
|
41
|
-
return { action: "merged", memory: merged };
|
|
42
|
-
}
|
|
43
|
-
const created = store.save({
|
|
44
|
-
type: memory.type,
|
|
45
|
-
title: memory.title,
|
|
46
|
-
content: memory.content,
|
|
47
|
-
tags: memory.tags ?? [],
|
|
48
|
-
importance: memory.importance ?? 3,
|
|
49
|
-
source: memory.source ?? "manual"
|
|
50
|
-
});
|
|
51
|
-
syncMirror();
|
|
52
|
-
notifyWrite();
|
|
53
|
-
return { action: "created", memory: created };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Candidate memories for automatic context injection:
|
|
58
|
-
* summaries first, then all preferences, then non-forgotten items with
|
|
59
|
-
* importance >= threshold. History is never auto-injected. Archived entries
|
|
60
|
-
* are excluded (store.list already filters them by default; the extra
|
|
61
|
-
* !m.archived check is kept as double insurance).
|
|
62
|
-
*/
|
|
63
|
-
function injectCandidates({ maxItems = 5, threshold = 3 } = {}) {
|
|
64
|
-
const items = store.list({ limit: 200, includeForgotten: false })
|
|
65
|
-
.filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
|
|
66
|
-
(m.type === "summary" || m.type === "preference" || m.importance >= threshold))
|
|
67
|
-
.sort((a, b) => {
|
|
68
|
-
const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
|
|
69
|
-
const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
|
|
70
|
-
return pa - pb || b.importance - a.importance;
|
|
71
|
-
});
|
|
72
|
-
return items.slice(0, maxItems);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Merge human edits parsed from a mirror file back into the store.
|
|
77
|
-
* Only content/title are taken; structure fields stay machine-owned.
|
|
78
|
-
*/
|
|
79
|
-
function mergeHumanEdits(type, edits) {
|
|
80
|
-
let applied = 0;
|
|
81
|
-
for (const edit of edits) {
|
|
82
|
-
if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
|
|
83
|
-
const existing = store.getById(edit.id);
|
|
84
|
-
if (!existing || existing.type !== type) continue;
|
|
85
|
-
const patch = {};
|
|
86
|
-
if (typeof edit.title === "string" && edit.title.trim()) patch.title = edit.title.trim();
|
|
87
|
-
if (typeof edit.content === "string" && edit.content.trim()) patch.content = edit.content.trim();
|
|
88
|
-
if (Object.keys(patch).length) {
|
|
89
|
-
store.update(edit.id, patch);
|
|
90
|
-
applied++;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (applied) {
|
|
94
|
-
syncMirror();
|
|
95
|
-
notifyWrite();
|
|
96
|
-
}
|
|
97
|
-
return applied;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function toApiList(rows) {
|
|
101
|
-
return rows.map((m) => ({
|
|
102
|
-
id: m.id,
|
|
103
|
-
type: m.type,
|
|
104
|
-
title: m.title,
|
|
105
|
-
content: m.content,
|
|
106
|
-
tags: m.tags,
|
|
107
|
-
importance: m.importance,
|
|
108
|
-
source: m.source,
|
|
109
|
-
created_at: m.created_at,
|
|
110
|
-
updated_at: m.updated_at
|
|
111
|
-
}));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Re-render the human-editable mirror after any store mutation. Only
|
|
116
|
-
* non-forgotten memories are mirrored: forgotten entries must not reach the
|
|
117
|
-
* human-editable file (a human "edit" could otherwise resurrect them).
|
|
118
|
-
*/
|
|
119
|
-
function syncMirror() {
|
|
120
|
-
if (mirror) mirror.sync(store.list({ limit: 500, includeForgotten: false }));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return {
|
|
124
|
-
saveWithDedupe,
|
|
125
|
-
injectCandidates,
|
|
126
|
-
mergeHumanEdits,
|
|
127
|
-
toApiList,
|
|
128
|
-
setDreamHook(fn) { dreamHook = fn; },
|
|
129
|
-
// passthroughs used by tools and api layers; mutations keep the mirror in sync
|
|
130
|
-
search: (q, o) => store.search(q, o),
|
|
131
|
-
list: (o) => store.list(o),
|
|
132
|
-
all: () => store.all(),
|
|
133
|
-
count: (type) => store.count(type),
|
|
134
|
-
getById: (id) => store.getById(id),
|
|
135
|
-
remove: (id) => {
|
|
136
|
-
store.remove(id);
|
|
137
|
-
syncMirror();
|
|
138
|
-
notifyWrite();
|
|
139
|
-
},
|
|
140
|
-
update: (id, p) => {
|
|
141
|
-
const updated = store.update(id, p);
|
|
142
|
-
syncMirror();
|
|
143
|
-
notifyWrite();
|
|
144
|
-
return updated;
|
|
145
|
-
},
|
|
146
|
-
setForget: (id, f) => {
|
|
147
|
-
const updated = store.setForget(id, f);
|
|
148
|
-
syncMirror();
|
|
149
|
-
return updated;
|
|
150
|
-
},
|
|
151
|
-
setArchived: (id, f) => {
|
|
152
|
-
const updated = store.setArchived(id, f);
|
|
153
|
-
syncMirror();
|
|
154
|
-
return updated;
|
|
155
|
-
}
|
|
156
|
-
};
|
|
157
|
-
}
|
|
1
|
+
const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
|
|
2
|
+
|
|
3
|
+
export function createService({ store, mirror, config, onWrite }) {
|
|
4
|
+
// Optional dream scheduler hook, installed via setDreamHook after creation
|
|
5
|
+
// (the scheduler holds a reference back to the service, so it cannot be
|
|
6
|
+
// passed in the constructor). Fired on the same write events as onWrite.
|
|
7
|
+
let dreamHook = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fire-and-forget write notification; errors are swallowed to keep write
|
|
11
|
+
* paths clean. The store mutation has already committed, so a throwing
|
|
12
|
+
* subscriber must not surface as a write failure. Archive/forget flags are
|
|
13
|
+
* state toggles, not content writes, so they never notify.
|
|
14
|
+
*/
|
|
15
|
+
function notifyWrite() {
|
|
16
|
+
if (onWrite) {
|
|
17
|
+
try { onWrite(); } catch { /* ignore */ }
|
|
18
|
+
}
|
|
19
|
+
if (dreamHook) {
|
|
20
|
+
try { dreamHook(); } catch { /* ignore */ }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Save a memory, merging into an existing one when title matches within the same type.
|
|
26
|
+
* @returns {{action: "created"|"merged", memory: object}}
|
|
27
|
+
*/
|
|
28
|
+
function saveWithDedupe(memory) {
|
|
29
|
+
const existing = store
|
|
30
|
+
.list({ type: memory.type, limit: 100 })
|
|
31
|
+
.find((m) => m.title.trim() === String(memory.title).trim());
|
|
32
|
+
if (existing) {
|
|
33
|
+
const merged = store.update(existing.id, {
|
|
34
|
+
content: memory.content ?? existing.content,
|
|
35
|
+
importance: memory.importance ?? existing.importance,
|
|
36
|
+
tags: memory.tags ?? existing.tags,
|
|
37
|
+
title: memory.title ?? existing.title
|
|
38
|
+
});
|
|
39
|
+
syncMirror();
|
|
40
|
+
notifyWrite();
|
|
41
|
+
return { action: "merged", memory: merged };
|
|
42
|
+
}
|
|
43
|
+
const created = store.save({
|
|
44
|
+
type: memory.type,
|
|
45
|
+
title: memory.title,
|
|
46
|
+
content: memory.content,
|
|
47
|
+
tags: memory.tags ?? [],
|
|
48
|
+
importance: memory.importance ?? 3,
|
|
49
|
+
source: memory.source ?? "manual"
|
|
50
|
+
});
|
|
51
|
+
syncMirror();
|
|
52
|
+
notifyWrite();
|
|
53
|
+
return { action: "created", memory: created };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Candidate memories for automatic context injection:
|
|
58
|
+
* summaries first, then all preferences, then non-forgotten items with
|
|
59
|
+
* importance >= threshold. History is never auto-injected. Archived entries
|
|
60
|
+
* are excluded (store.list already filters them by default; the extra
|
|
61
|
+
* !m.archived check is kept as double insurance).
|
|
62
|
+
*/
|
|
63
|
+
function injectCandidates({ maxItems = 5, threshold = 3 } = {}) {
|
|
64
|
+
const items = store.list({ limit: 200, includeForgotten: false })
|
|
65
|
+
.filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
|
|
66
|
+
(m.type === "summary" || m.type === "preference" || m.importance >= threshold))
|
|
67
|
+
.sort((a, b) => {
|
|
68
|
+
const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
|
|
69
|
+
const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
|
|
70
|
+
return pa - pb || b.importance - a.importance;
|
|
71
|
+
});
|
|
72
|
+
return items.slice(0, maxItems);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Merge human edits parsed from a mirror file back into the store.
|
|
77
|
+
* Only content/title are taken; structure fields stay machine-owned.
|
|
78
|
+
*/
|
|
79
|
+
function mergeHumanEdits(type, edits) {
|
|
80
|
+
let applied = 0;
|
|
81
|
+
for (const edit of edits) {
|
|
82
|
+
if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
|
|
83
|
+
const existing = store.getById(edit.id);
|
|
84
|
+
if (!existing || existing.type !== type) continue;
|
|
85
|
+
const patch = {};
|
|
86
|
+
if (typeof edit.title === "string" && edit.title.trim()) patch.title = edit.title.trim();
|
|
87
|
+
if (typeof edit.content === "string" && edit.content.trim()) patch.content = edit.content.trim();
|
|
88
|
+
if (Object.keys(patch).length) {
|
|
89
|
+
store.update(edit.id, patch);
|
|
90
|
+
applied++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (applied) {
|
|
94
|
+
syncMirror();
|
|
95
|
+
notifyWrite();
|
|
96
|
+
}
|
|
97
|
+
return applied;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toApiList(rows) {
|
|
101
|
+
return rows.map((m) => ({
|
|
102
|
+
id: m.id,
|
|
103
|
+
type: m.type,
|
|
104
|
+
title: m.title,
|
|
105
|
+
content: m.content,
|
|
106
|
+
tags: m.tags,
|
|
107
|
+
importance: m.importance,
|
|
108
|
+
source: m.source,
|
|
109
|
+
created_at: m.created_at,
|
|
110
|
+
updated_at: m.updated_at
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Re-render the human-editable mirror after any store mutation. Only
|
|
116
|
+
* non-forgotten memories are mirrored: forgotten entries must not reach the
|
|
117
|
+
* human-editable file (a human "edit" could otherwise resurrect them).
|
|
118
|
+
*/
|
|
119
|
+
function syncMirror() {
|
|
120
|
+
if (mirror) mirror.sync(store.list({ limit: 500, includeForgotten: false }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
saveWithDedupe,
|
|
125
|
+
injectCandidates,
|
|
126
|
+
mergeHumanEdits,
|
|
127
|
+
toApiList,
|
|
128
|
+
setDreamHook(fn) { dreamHook = fn; },
|
|
129
|
+
// passthroughs used by tools and api layers; mutations keep the mirror in sync
|
|
130
|
+
search: (q, o) => store.search(q, o),
|
|
131
|
+
list: (o) => store.list(o),
|
|
132
|
+
all: () => store.all(),
|
|
133
|
+
count: (type) => store.count(type),
|
|
134
|
+
getById: (id) => store.getById(id),
|
|
135
|
+
remove: (id) => {
|
|
136
|
+
store.remove(id);
|
|
137
|
+
syncMirror();
|
|
138
|
+
notifyWrite();
|
|
139
|
+
},
|
|
140
|
+
update: (id, p) => {
|
|
141
|
+
const updated = store.update(id, p);
|
|
142
|
+
syncMirror();
|
|
143
|
+
notifyWrite();
|
|
144
|
+
return updated;
|
|
145
|
+
},
|
|
146
|
+
setForget: (id, f) => {
|
|
147
|
+
const updated = store.setForget(id, f);
|
|
148
|
+
syncMirror();
|
|
149
|
+
return updated;
|
|
150
|
+
},
|
|
151
|
+
setArchived: (id, f) => {
|
|
152
|
+
const updated = store.setArchived(id, f);
|
|
153
|
+
syncMirror();
|
|
154
|
+
return updated;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|