@lmzhen/dsh-evolution-learning-graph 0.3.17 → 0.3.18

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
@@ -2,12 +2,20 @@
2
2
 
3
3
  Learning graph over skills and memory
4
4
 
5
+ ## Graph surface
6
+
7
+ `/graph` renders the learning graph. A skill node is its name; a memory node is `memory:<source>:<index>`, where `<source>` is `memory`|`user` and `<index>` is the position in that file's entries.
8
+
9
+ Memory node ids carry a trailing snapshot token (`memory:<source>:<index>:<snapshot>` — an 8-hex digest of the node label's first line). It exists so `graph edit`/`graph delete` can detect index drift: any memory write between the last render and the command shifts indices, and a stale id is rejected (requiring a re-run of `/graph`) instead of mutating a different entry.
10
+
11
+ Memory→skill edges are word-level, not substring: the entry is tokenized on non-letter/digit/hyphen runs and a skill name links only when it is a whole token. This prevents a skill named `run` from linking the words `running`/`grunt`.
12
+
13
+ `graph edit`/`graph delete` of a skill node route through the evolution approval seam when it is mounted (soft-probed; the write executes directly when it is absent). Each approved/executed edit bumps the skill's patch counter and a delete archives it, matching `skill_manage`.
14
+
5
15
  ## Model Experience
6
16
 
7
17
  ### Indirect model surface
8
18
 
9
- #### What the model sees
10
-
11
19
  `@deepseek-ai/dsh-evolution-learning-graph` registers no direct prompt or tool schema itself. Model-visible effects are owned by the packages that consume this service.
12
20
 
13
21
  #### Token effect
@@ -20,5 +28,5 @@ Independent of request-prefix construction. This package does not alter the asse
20
28
 
21
29
  ## Known Limitations and Deferred Work
22
30
 
23
-
24
- - No known durable consumer gaps at this time. Runtime contracts are covered by package and boundary tests.
31
+ - The memory drift snapshot covers only the node label (first line, first 80 chars): a change confined to a later line of the same entry is not detected, because the rendered node label is the comparison anchor.
32
+ - A hand-typed bare `memory:<source>:<index>` id has no snapshot and skips the drift check (legacy path; the rendered ids always carry the snapshot).
package/lib/index.js CHANGED
@@ -1,11 +1,12 @@
1
- import { SKILL_NAME_RE, SkillLibrary, evolutionIoAdapter, relatedSkillNames } from "@lmzhen/dsh-evolution-core";
1
+ import { SKILL_NAME_RE, SkillLibrary, evolutionIoAdapter, relatedSkillNames, resolveOrigins, resolveSkillsRoot } from "@lmzhen/dsh-evolution-core";
2
2
  //#region lib/types/index.js
3
3
  /**
4
4
  * Learning graph over skills and memory, plus node-level commands
5
5
  * (`/evolution graph [detail|edit|delete] <nodeId>`) aligned with the Hermes
6
6
  * journey surface: a skill node is its name, a memory node is
7
7
  * `memory:<source>:<index>` (source = memory|user, index = position in that
8
- * file's entries).
8
+ * file's entries). Memory node ids carry a trailing snapshot token so
9
+ * edit/delete can reject a stale index (E-21).
9
10
  * @module @lmzhen/dsh-evolution-learning-graph
10
11
  */
11
12
  function graphDensity(graph) {
@@ -64,14 +65,15 @@ function buildLearningGraph(usage, memoryEntries, userEntries = [], related) {
64
65
  }
65
66
  const appendMemory = (source, entries) => {
66
67
  entries.forEach((entry, index) => {
67
- const id = `memory:${source}:${index}`;
68
+ const label = (entry.split("\n")[0] ?? "").slice(0, 80);
69
+ const id = `memory:${source}:${index}:${memorySnapshotOf(label)}`;
68
70
  nodes.push({
69
71
  id,
70
72
  kind: "memory",
71
- label: entry.split("\n")[0]?.slice(0, 80) ?? id
73
+ label
72
74
  });
73
- const token = entry.toLowerCase();
74
- for (const name of sorted) if (name && token.includes(name.toLowerCase())) edges.push({
75
+ const words = new Set(entry.toLowerCase().split(/[^a-z0-9-]+/).filter(Boolean));
76
+ for (const name of sorted) if (name && words.has(name.toLowerCase())) edges.push({
75
77
  from: id,
76
78
  to: name,
77
79
  type: "memory_skill"
@@ -85,13 +87,61 @@ function buildLearningGraph(usage, memoryEntries, userEntries = [], related) {
85
87
  edges
86
88
  };
87
89
  }
88
- /** Parse a graph node id: skill names pass through; `memory:<source>:<index>` becomes a memory node. */
90
+ /**
91
+ * E-21 snapshot token: a deterministic 8-hex-char digest of a memory entry's
92
+ * rendered label (first line, first 80 chars). The graph builder embeds it in
93
+ * the memory node id so a later edit/delete can detect index drift — any
94
+ * memory write between the last render and the command shifts `memory:<source>:<index>`
95
+ * positions, and the token lets the command reject a stale index instead of
96
+ * mutating the wrong entry. FNV-1a (no imports, cross-platform).
97
+ */
98
+ function memorySnapshotOf(entry) {
99
+ const label = (entry.split("\n")[0] ?? "").slice(0, 80);
100
+ let hash = 2166136261;
101
+ for (let i = 0; i < label.length; i += 1) {
102
+ hash ^= label.charCodeAt(i);
103
+ hash = Math.imul(hash, 16777619);
104
+ }
105
+ return (hash >>> 0).toString(16).padStart(8, "0").slice(-8);
106
+ }
107
+ /**
108
+ * E-21 TOCTOU guard for a memory node operation. Re-reads the entry at
109
+ * `parsed.index` and, when the id carries a snapshot token, verifies the
110
+ * current entry's label still matches it. A mismatch means a memory write
111
+ * shifted the index between the last `/graph` render and this command, so the
112
+ * operation is refused (the caller surfaces "re-run /graph"). A missing
113
+ * snapshot (a hand-typed bare `memory:<source>:<index>` id) skips the check
114
+ * and falls back to operating on whatever currently sits at the index.
115
+ */
116
+ function readMemoryIndex(parsed, entries) {
117
+ const entry = entries[parsed.index];
118
+ if (entry === void 0) return {
119
+ ok: false,
120
+ message: `Memory ${parsed.source}[${parsed.index}] does not exist (${entries.length} entries).`
121
+ };
122
+ if (parsed.snapshot !== void 0 && memorySnapshotOf(entry) !== parsed.snapshot) return {
123
+ ok: false,
124
+ message: `Memory ${parsed.source}[${parsed.index}] changed since the graph was rendered; run /graph again and retry.`
125
+ };
126
+ return {
127
+ ok: true,
128
+ entry
129
+ };
130
+ }
131
+ /**
132
+ * Parse a graph node id: skill names pass through; `memory:<source>:<index>`
133
+ * becomes a memory node. An optional trailing `:<snapshot>` (the E-21
134
+ * `memorySnapshotOf` token the builder embeds) is carried so edit/delete can
135
+ * detect index drift; a bare index id (hand-typed) still parses with no
136
+ * snapshot and skips the drift check.
137
+ */
89
138
  function parseGraphNodeId(id) {
90
- const memory = /^memory:(memory|user):(\d+)$/.exec(id);
139
+ const memory = /^memory:(memory|user):(\d+)(?::([0-9a-f]{8}))?$/.exec(id);
91
140
  if (memory) return {
92
141
  kind: "memory",
93
142
  source: memory[1],
94
- index: Number(memory[2])
143
+ index: Number(memory[2]),
144
+ ...memory[3] !== void 0 ? { snapshot: memory[3] } : {}
95
145
  };
96
146
  if (SKILL_NAME_RE.test(id)) return {
97
147
  kind: "skill",
@@ -175,7 +225,7 @@ function apply(ctx) {
175
225
  return lines.join("\n") + "\n\n" + edges.join("\n") + densityLine;
176
226
  }
177
227
  function withSkills() {
178
- return new SkillLibrary(void 0, evolutionIoAdapter(() => io.provider()), void 0, (event) => {
228
+ return new SkillLibrary(resolveSkillsRoot(), evolutionIoAdapter(() => io.provider()), void 0, (event) => {
179
229
  ctx.emit("evolution/skill-mutated", event);
180
230
  });
181
231
  }
@@ -192,15 +242,34 @@ function apply(ctx) {
192
242
  const parsed = parseGraphNodeId(id);
193
243
  if (parsed === null) return err(`Invalid node id "${id}". Skill names or memory:<source>:<index> expected.`);
194
244
  if (parsed.kind === "skill") {
245
+ const approval = ctx.get("evolutionApproval");
246
+ if (approval) {
247
+ const origins = resolveOrigins(void 0);
248
+ const decision = await approval.request({
249
+ kind: "skill",
250
+ summary: `graph edit ${parsed.name}`,
251
+ args: {
252
+ operation: {
253
+ action: "update",
254
+ name: parsed.name,
255
+ content
256
+ },
257
+ origin: origins.approval,
258
+ libraryOrigin: origins.library
259
+ },
260
+ origin: origins.approval
261
+ });
262
+ if (decision.action === "staged") return ok(decision.message);
263
+ }
195
264
  const result = await withSkills().update(parsed.name, content, "foreground");
265
+ if (result.ok) await usageService.record?.(parsed.name, "patch");
196
266
  return result.ok ? ok(result.message) : err(result.message);
197
267
  }
198
- const entries = await memory.read(parsed.source);
199
- const entry = entries[parsed.index];
200
- if (entry === void 0) return err(`Memory ${parsed.source}[${parsed.index}] does not exist (${entries.length} entries).`);
268
+ const check = readMemoryIndex(parsed, await memory.read(parsed.source));
269
+ if (!check.ok) return err(check.message ?? "Memory index check failed.");
201
270
  const result = await memory.applyBatch(parsed.source, [{
202
271
  action: "replace",
203
- old_text: entry,
272
+ old_text: check.entry ?? "",
204
273
  facts: content
205
274
  }]);
206
275
  return result.ok ? ok(result.message) : err(result.message);
@@ -209,16 +278,33 @@ function apply(ctx) {
209
278
  const parsed = parseGraphNodeId(id);
210
279
  if (parsed === null) return err(`Invalid node id "${id}". Skill names or memory:<source>:<index> expected.`);
211
280
  if (parsed.kind === "skill") {
281
+ const approval = ctx.get("evolutionApproval");
282
+ if (approval) {
283
+ const origins = resolveOrigins(void 0);
284
+ const decision = await approval.request({
285
+ kind: "skill",
286
+ summary: `graph delete ${parsed.name}`,
287
+ args: {
288
+ operation: {
289
+ action: "delete",
290
+ name: parsed.name
291
+ },
292
+ origin: origins.approval,
293
+ libraryOrigin: origins.library
294
+ },
295
+ origin: origins.approval
296
+ });
297
+ if (decision.action === "staged") return ok(decision.message);
298
+ }
212
299
  const result = await withSkills().archive(parsed.name);
213
300
  if (result.ok) await usageService?.markArchived?.(parsed.name);
214
301
  return result.ok ? ok(result.message) : err(result.message);
215
302
  }
216
- const entries = await memory.read(parsed.source);
217
- const entry = entries[parsed.index];
218
- if (entry === void 0) return err(`Memory ${parsed.source}[${parsed.index}] does not exist (${entries.length} entries).`);
303
+ const check = readMemoryIndex(parsed, await memory.read(parsed.source));
304
+ if (!check.ok) return err(check.message ?? "Memory index check failed.");
219
305
  const result = await memory.applyBatch(parsed.source, [{
220
306
  action: "remove",
221
- old_text: entry
307
+ old_text: check.entry ?? ""
222
308
  }]);
223
309
  return result.ok ? ok(result.message) : err(result.message);
224
310
  }
@@ -227,4 +313,4 @@ function apply(ctx) {
227
313
  });
228
314
  }
229
315
  //#endregion
230
- export { apply, buildLearningGraph, graphDensity, name, parseGraphNodeId, resolveGraphNode };
316
+ export { apply, buildLearningGraph, graphDensity, memorySnapshotOf, name, parseGraphNodeId, readMemoryIndex, resolveGraphNode };
@@ -3,7 +3,8 @@
3
3
  * (`/evolution graph [detail|edit|delete] <nodeId>`) aligned with the Hermes
4
4
  * journey surface: a skill node is its name, a memory node is
5
5
  * `memory:<source>:<index>` (source = memory|user, index = position in that
6
- * file's entries).
6
+ * file's entries). Memory node ids carry a trailing snapshot token so
7
+ * edit/delete can reject a stale index (E-21).
7
8
  * @module @deepseek-ai/dsh-evolution-learning-graph
8
9
  */
9
10
  import type { Context } from '@deepseek-ai/cordis';
@@ -47,10 +48,7 @@ export declare function graphDensity(graph: LearningGraph): GraphDensity;
47
48
  * omitted `related` yields no skill-skill edges — the former alphabet-order
48
49
  * chain was a placeholder that connected unrelated neighbors.
49
50
  */
50
- export declare function buildLearningGraph(usage: ReadonlyMap<string, {
51
- use_count?: number;
52
- pinned?: boolean;
53
- }>, memoryEntries: readonly string[], userEntries?: readonly string[], related?: ReadonlyMap<string, readonly string[]>): LearningGraph;
51
+ export declare function buildLearningGraph(usage: ReadonlyMap<string, unknown>, memoryEntries: readonly string[], userEntries?: readonly string[], related?: ReadonlyMap<string, readonly string[]>): LearningGraph;
54
52
  export type GraphNodeId = {
55
53
  kind: 'skill';
56
54
  name: string;
@@ -58,8 +56,42 @@ export type GraphNodeId = {
58
56
  kind: 'memory';
59
57
  source: 'memory' | 'user';
60
58
  index: number;
59
+ snapshot?: string;
61
60
  };
62
- /** Parse a graph node id: skill names pass through; `memory:<source>:<index>` becomes a memory node. */
61
+ /**
62
+ * E-21 snapshot token: a deterministic 8-hex-char digest of a memory entry's
63
+ * rendered label (first line, first 80 chars). The graph builder embeds it in
64
+ * the memory node id so a later edit/delete can detect index drift — any
65
+ * memory write between the last render and the command shifts `memory:<source>:<index>`
66
+ * positions, and the token lets the command reject a stale index instead of
67
+ * mutating the wrong entry. FNV-1a (no imports, cross-platform).
68
+ */
69
+ export declare function memorySnapshotOf(entry: string): string;
70
+ export interface MemoryIndexCheck {
71
+ ok: boolean;
72
+ /** The current entry at the index, present when ok. */
73
+ entry?: string;
74
+ message?: string;
75
+ }
76
+ /**
77
+ * E-21 TOCTOU guard for a memory node operation. Re-reads the entry at
78
+ * `parsed.index` and, when the id carries a snapshot token, verifies the
79
+ * current entry's label still matches it. A mismatch means a memory write
80
+ * shifted the index between the last `/graph` render and this command, so the
81
+ * operation is refused (the caller surfaces "re-run /graph"). A missing
82
+ * snapshot (a hand-typed bare `memory:<source>:<index>` id) skips the check
83
+ * and falls back to operating on whatever currently sits at the index.
84
+ */
85
+ export declare function readMemoryIndex(parsed: Extract<GraphNodeId, {
86
+ kind: 'memory';
87
+ }>, entries: readonly string[]): MemoryIndexCheck;
88
+ /**
89
+ * Parse a graph node id: skill names pass through; `memory:<source>:<index>`
90
+ * becomes a memory node. An optional trailing `:<snapshot>` (the E-21
91
+ * `memorySnapshotOf` token the builder embeds) is carried so edit/delete can
92
+ * detect index drift; a bare index id (hand-typed) still parses with no
93
+ * snapshot and skips the drift check.
94
+ */
63
95
  export declare function parseGraphNodeId(id: string): GraphNodeId | null;
64
96
  /** Resolve a parsed node to its current content (read-only). */
65
97
  export declare function resolveGraphNode(parsed: GraphNodeId, repository: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-learning-graph",
3
3
  "description": "Learning graph over skills and memory (community build)",
4
- "version": "0.3.17",
4
+ "version": "0.3.18",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -33,7 +33,7 @@
33
33
  "license": "MIT",
34
34
  "dependencies": {
35
35
  "@deepseek-ai/schemastery": "^3.18.1",
36
- "@lmzhen/dsh-evolution-core": "^0.3.17"
36
+ "@lmzhen/dsh-evolution-core": "^0.3.18"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",