@jungjaehoon/mama-os 0.63.0 → 0.65.0

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.
Files changed (115) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +13 -12
  3. package/dist/api/attachment-actions.d.ts.map +1 -1
  4. package/dist/api/attachment-actions.js +6 -2
  5. package/dist/api/attachment-actions.js.map +1 -1
  6. package/dist/api/drive-actions.d.ts +2 -1
  7. package/dist/api/drive-actions.d.ts.map +1 -1
  8. package/dist/api/drive-actions.js +1 -0
  9. package/dist/api/drive-actions.js.map +1 -1
  10. package/dist/api/drive-delivery.d.ts +16 -0
  11. package/dist/api/drive-delivery.d.ts.map +1 -0
  12. package/dist/api/drive-delivery.js +190 -0
  13. package/dist/api/drive-delivery.js.map +1 -0
  14. package/dist/api/file-delivery.d.ts +2 -2
  15. package/dist/api/file-delivery.d.ts.map +1 -1
  16. package/dist/api/file-delivery.js +5 -5
  17. package/dist/api/file-delivery.js.map +1 -1
  18. package/dist/api/help-actions.d.ts +2 -0
  19. package/dist/api/help-actions.d.ts.map +1 -1
  20. package/dist/api/help-actions.js +4 -1
  21. package/dist/api/help-actions.js.map +1 -1
  22. package/dist/api/security-events.d.ts +5 -4
  23. package/dist/api/security-events.d.ts.map +1 -1
  24. package/dist/api/security-events.js +25 -3
  25. package/dist/api/security-events.js.map +1 -1
  26. package/dist/api/viewer-data.d.ts +5 -1
  27. package/dist/api/viewer-data.d.ts.map +1 -1
  28. package/dist/api/viewer-server.d.ts +1 -1
  29. package/dist/api/viewer-server.d.ts.map +1 -1
  30. package/dist/api/viewer-server.js +82 -17
  31. package/dist/api/viewer-server.js.map +1 -1
  32. package/dist/api/wiki-actions.d.ts.map +1 -1
  33. package/dist/api/wiki-actions.js +44 -0
  34. package/dist/api/wiki-actions.js.map +1 -1
  35. package/dist/api/work-actions.d.ts.map +1 -1
  36. package/dist/api/work-actions.js +9 -2
  37. package/dist/api/work-actions.js.map +1 -1
  38. package/dist/cli/commands/daemon.d.ts.map +1 -1
  39. package/dist/cli/commands/daemon.js +14 -7
  40. package/dist/cli/commands/daemon.js.map +1 -1
  41. package/dist/gateways/discord.d.ts.map +1 -1
  42. package/dist/gateways/discord.js +1 -1
  43. package/dist/gateways/discord.js.map +1 -1
  44. package/dist/gateways/slack.d.ts.map +1 -1
  45. package/dist/gateways/slack.js +1 -1
  46. package/dist/gateways/slack.js.map +1 -1
  47. package/dist/gateways/telegram.d.ts.map +1 -1
  48. package/dist/gateways/telegram.js +1 -1
  49. package/dist/gateways/telegram.js.map +1 -1
  50. package/dist/replay/replay-source-catalog.js +1 -1
  51. package/dist/replay/replay-source-catalog.js.map +1 -1
  52. package/dist/runtime/action-surface.d.ts +6 -1
  53. package/dist/runtime/action-surface.d.ts.map +1 -1
  54. package/dist/runtime/action-surface.js +17 -1
  55. package/dist/runtime/action-surface.js.map +1 -1
  56. package/dist/runtime/config.d.ts +20 -0
  57. package/dist/runtime/config.d.ts.map +1 -1
  58. package/dist/runtime/config.js +48 -3
  59. package/dist/runtime/config.js.map +1 -1
  60. package/dist/runtime/outbound-attempts.d.ts +4 -2
  61. package/dist/runtime/outbound-attempts.d.ts.map +1 -1
  62. package/dist/runtime/outbound-attempts.js +17 -1
  63. package/dist/runtime/outbound-attempts.js.map +1 -1
  64. package/dist/runtime/owner-runtime.d.ts +14 -1
  65. package/dist/runtime/owner-runtime.d.ts.map +1 -1
  66. package/dist/runtime/owner-runtime.js +29 -1
  67. package/dist/runtime/owner-runtime.js.map +1 -1
  68. package/dist/runtime/owner-system-prompt.d.ts +1 -1
  69. package/dist/runtime/owner-system-prompt.d.ts.map +1 -1
  70. package/dist/runtime/owner-system-prompt.js +15 -9
  71. package/dist/runtime/owner-system-prompt.js.map +1 -1
  72. package/dist/runtime/report-scheduler.d.ts +2 -1
  73. package/dist/runtime/report-scheduler.d.ts.map +1 -1
  74. package/dist/runtime/report-scheduler.js +1 -1
  75. package/dist/runtime/report-scheduler.js.map +1 -1
  76. package/dist/runtime/stimulus-delivery.d.ts +3 -1
  77. package/dist/runtime/stimulus-delivery.d.ts.map +1 -1
  78. package/dist/runtime/stimulus-delivery.js +14 -6
  79. package/dist/runtime/stimulus-delivery.js.map +1 -1
  80. package/dist/runtime/timezone.d.ts +6 -0
  81. package/dist/runtime/timezone.d.ts.map +1 -1
  82. package/dist/runtime/timezone.js +11 -0
  83. package/dist/runtime/timezone.js.map +1 -1
  84. package/dist/runtime/turn-orders.d.ts +23 -0
  85. package/dist/runtime/turn-orders.d.ts.map +1 -1
  86. package/dist/runtime/turn-orders.js +34 -11
  87. package/dist/runtime/turn-orders.js.map +1 -1
  88. package/dist/wiki/obsidian-writer.d.ts +0 -3
  89. package/dist/wiki/obsidian-writer.d.ts.map +1 -1
  90. package/dist/wiki/obsidian-writer.js +8 -108
  91. package/dist/wiki/obsidian-writer.js.map +1 -1
  92. package/dist/wiki/path-safety.d.ts +1 -1
  93. package/dist/wiki/path-safety.d.ts.map +1 -1
  94. package/dist/wiki/path-safety.js +2 -2
  95. package/dist/wiki/path-safety.js.map +1 -1
  96. package/dist/wiki/wiki-move.d.ts +12 -0
  97. package/dist/wiki/wiki-move.d.ts.map +1 -0
  98. package/dist/wiki/wiki-move.js +95 -0
  99. package/dist/wiki/wiki-move.js.map +1 -0
  100. package/dist/wiki/wiki-read.d.ts +7 -0
  101. package/dist/wiki/wiki-read.d.ts.map +1 -1
  102. package/dist/wiki/wiki-read.js +38 -17
  103. package/dist/wiki/wiki-read.js.map +1 -1
  104. package/package.json +2 -2
  105. package/public/viewer/js/modules/graph.js +177 -115
  106. package/public/viewer/js/modules/memory-timeline.js +6 -5
  107. package/public/viewer/js/modules/wiki.js +4 -4
  108. package/public/viewer/js/utils/api.js +3 -3
  109. package/public/viewer/src/modules/graph.ts +205 -136
  110. package/public/viewer/src/modules/memory-timeline.ts +11 -3
  111. package/public/viewer/src/modules/wiki.ts +4 -4
  112. package/public/viewer/src/types/global.d.ts +1 -1
  113. package/public/viewer/src/utils/api.ts +8 -3
  114. package/public/viewer/viewer.css +2 -2
  115. package/public/viewer/viewer.html +12 -12
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Graph Module - one memory record and what it links to
2
+ * Graph Module - the filtered memory as a graph
3
3
  * @module modules/graph
4
4
  *
5
- * The memory view picks a record from the saved timeline. This draws that record with its direct
6
- * links (the source messages it rests on, what it builds on, what it replaced), names every colour
7
- * and line in plain words, and shows the detail of whichever dot is clicked.
5
+ * The memory view's list and graph show the same records: whatever the period, kind and search
6
+ * select. One dot per record, one per work item for its updates in the period, and faded dots for
7
+ * the records they link to outside the filter. Lines are the links between records; the source
8
+ * messages a record rests on are listed in its detail.
8
9
  */
9
10
 
10
11
  /* eslint-env browser */
@@ -12,20 +13,9 @@
12
13
 
13
14
  import { escapeHtml, getElementByIdOrNull } from '../utils/dom.js';
14
15
  import { DebugLogger } from '../utils/debug-logger.js';
15
- import { API, type GraphNode, type GraphEdge } from '../utils/api.js';
16
+ import { API, type MemoryLinks, type MemoryTimelineResponse } from '../utils/api.js';
16
17
  import { renderSafeMarkdown } from '../utils/markdown.js';
17
-
18
- type GraphNodeRecord = GraphNode & {
19
- topic?: string;
20
- decision?: string;
21
- reasoning?: string;
22
- created_at?: string | number;
23
- };
24
-
25
- type GraphEdgeRecord = GraphEdge & {
26
- from: GraphNodeRecord['id'];
27
- to: GraphNodeRecord['id'];
28
- };
18
+ import { groupInfo } from './memory-timeline.js';
29
19
 
30
20
  type EdgeStyle = {
31
21
  color: string;
@@ -33,15 +23,9 @@ type EdgeStyle = {
33
23
  width: number;
34
24
  };
35
25
 
36
- type GraphInput = {
37
- nodes: GraphNodeRecord[];
38
- edges: GraphEdgeRecord[];
39
- meta?: Record<string, unknown>;
40
- };
41
-
42
26
  const logger = new DebugLogger('Graph');
43
27
 
44
- /** What each dot colour stands for, in the words the legend shows. */
28
+ /** What a record's stored kind is called in the detail panel. */
45
29
  export const KIND_LABELS: Record<string, string> = {
46
30
  decision: 'Decision',
47
31
  preference: 'Preference',
@@ -72,6 +56,7 @@ export const RELATION_LABELS: Record<string, string> = {
72
56
  blocks: 'blocks',
73
57
  };
74
58
 
59
+ const OUTSIDE_COLOR = '#D1D5DB';
75
60
  const LABEL_LENGTH = 40;
76
61
 
77
62
  function shortLabel(text: string): string {
@@ -79,28 +64,121 @@ function shortLabel(text: string): string {
79
64
  return flat.length > LABEL_LENGTH ? `${flat.slice(0, LABEL_LENGTH - 1)}…` : flat;
80
65
  }
81
66
 
67
+ export interface FilterGraphNode {
68
+ id: string;
69
+ /** The timeline group of a selected dot, or `outside` for a linked record the filter left out. */
70
+ group: string;
71
+ kind: string | null;
72
+ label: string;
73
+ /** The record whose detail a click opens: the record itself, or a work item's latest update. */
74
+ detailId: string;
75
+ /** Updates of a work item inside the period. */
76
+ updates: number;
77
+ outside: boolean;
78
+ }
79
+
80
+ export interface FilterGraph {
81
+ nodes: FilterGraphNode[];
82
+ edges: Array<{ from: string; to: string; relation: string }>;
83
+ /** Which dot each listed record is drawn as: a work update is drawn as its item. */
84
+ recordNode: Record<string, string>;
85
+ }
86
+
87
+ /**
88
+ * The graph of what the filters selected. A work item is one dot for all its updates; a link
89
+ * between two updates of one item is not drawn, and a link that touches nothing selected is left
90
+ * out. Days arrive newest first, so an item's first update seen is its latest.
91
+ */
92
+ export function composeFilterGraph(
93
+ timeline: MemoryTimelineResponse,
94
+ links: MemoryLinks
95
+ ): FilterGraph {
96
+ const nodes = new Map<string, FilterGraphNode>();
97
+ const recordNode: Record<string, string> = {};
98
+ for (const day of timeline.days) {
99
+ for (const group of day.groups) {
100
+ for (const record of group.records ?? []) {
101
+ nodes.set(record.id, {
102
+ id: record.id,
103
+ group: group.group,
104
+ kind: record.kind,
105
+ label: record.topic,
106
+ detailId: record.id,
107
+ updates: 0,
108
+ outside: false,
109
+ });
110
+ recordNode[record.id] = record.id;
111
+ }
112
+ for (const item of group.items ?? []) {
113
+ const id = `item:${item.commitmentId}`;
114
+ const node = nodes.get(id) ?? {
115
+ id,
116
+ group: 'work',
117
+ kind: 'commitment',
118
+ label: item.title ?? item.topic,
119
+ detailId: item.revisions[0].id,
120
+ updates: 0,
121
+ outside: false,
122
+ };
123
+ node.updates += item.revisions.length;
124
+ nodes.set(id, node);
125
+ for (const revision of item.revisions) recordNode[revision.id] = id;
126
+ }
127
+ }
128
+ }
129
+ // Links name both ends with a node; a work update outside the filter is drawn as its item.
130
+ const dotFor = (ref: string): string => {
131
+ const listed = recordNode[ref];
132
+ if (listed !== undefined) return listed;
133
+ const commitmentId = links.nodes[ref].commitmentId;
134
+ return commitmentId === null ? ref : `item:${commitmentId}`;
135
+ };
136
+ const edges: FilterGraph['edges'] = [];
137
+ const seen = new Set<string>();
138
+ const outside: FilterGraphNode[] = [];
139
+ for (const link of links.edges) {
140
+ const from = dotFor(link.from);
141
+ const to = dotFor(link.to);
142
+ const key = `${from}\0${to}\0${link.relation}`;
143
+ if (from === to || seen.has(key) || (!nodes.has(from) && !nodes.has(to))) continue;
144
+ seen.add(key);
145
+ edges.push({ from, to, relation: link.relation });
146
+ for (const [dot, ref] of [
147
+ [from, link.from],
148
+ [to, link.to],
149
+ ] as const) {
150
+ if (nodes.has(dot)) continue;
151
+ const node: FilterGraphNode = {
152
+ id: dot,
153
+ group: 'outside',
154
+ kind: links.nodes[ref].kind,
155
+ label: links.nodes[ref].label,
156
+ detailId: ref,
157
+ updates: 0,
158
+ outside: true,
159
+ };
160
+ nodes.set(dot, node);
161
+ outside.push(node);
162
+ }
163
+ }
164
+ return {
165
+ nodes: [...nodes.values()].filter((node) => !node.outside).concat(outside),
166
+ edges,
167
+ recordNode,
168
+ };
169
+ }
170
+
82
171
  /**
83
172
  * Graph Module Class
84
173
  */
85
174
  export class GraphModule {
86
175
  network: VisNetwork | null = null;
87
- graphData: GraphInput = { nodes: [], edges: [], meta: {} };
88
- centerId: string | null = null;
89
- // A slower earlier read must not draw or describe a record the owner has moved away from.
90
- private recordRequest = 0;
176
+ graph: FilterGraph = { nodes: [], edges: [], recordNode: {} };
177
+ // The links between records do not depend on the filter: read once per page load.
178
+ private links: Promise<MemoryLinks> | null = null;
179
+ // A slower earlier read must not draw or describe what the owner has moved away from.
180
+ private drawRequest = 0;
91
181
  private detailRequest = 0;
92
- nodeColors: Record<string, string> = {
93
- decision: '#60A5FA',
94
- preference: '#C084FC',
95
- constraint: '#FB7185',
96
- lesson: '#FBBF24',
97
- workflow: '#F472B6',
98
- fact: '#2DD4BF',
99
- commitment: '#FB923C',
100
- observation: '#94A3B8',
101
- memory: '#CBD5E1', // Legacy memories may have no stored classification.
102
- registry: '#A3E635',
103
- };
104
182
  edgeStyles: Record<string, EdgeStyle> = {
105
183
  supersedes: { color: '#666666', dashes: false, width: 2 },
106
184
  mentions: { color: '#6B4C9A', dashes: false, width: 2 },
@@ -114,72 +192,77 @@ export class GraphModule {
114
192
  };
115
193
 
116
194
  /**
117
- * Draw a record and its direct links, then show its detail.
195
+ * Draw what the filters selected, from the same timeline answer the list shows.
118
196
  */
119
- async showRecord(id: string): Promise<void> {
120
- const request = ++this.recordRequest;
121
- const empty = getElementByIdOrNull<HTMLElement>('graph-empty');
122
- const loading = getElementByIdOrNull<HTMLElement>('graph-loading');
123
- if (empty) empty.style.display = 'none';
124
- if (loading) loading.style.display = 'flex';
125
- try {
126
- const data = (await API.getGraphNeighbors(id)) as GraphInput;
127
- if (request !== this.recordRequest) return;
128
- this.centerId = id;
129
- this.init(data);
130
- const center = data.nodes.find((node) => String(node.id) === id);
131
- if (center) void this.showDetail(center);
132
- } finally {
133
- if (loading && request === this.recordRequest) loading.style.display = 'none';
134
- }
197
+ async showSelection(timeline: MemoryTimelineResponse): Promise<void> {
198
+ const request = ++this.drawRequest;
199
+ // A failed read is not kept: the next filter change reads the links again.
200
+ this.links ??= API.getMemoryLinks().catch((error: unknown) => {
201
+ this.links = null;
202
+ throw error;
203
+ });
204
+ const links = await this.links;
205
+ if (request !== this.drawRequest) return;
206
+ this.init(composeFilterGraph(timeline, links));
135
207
  }
136
208
 
137
209
  /**
138
- * Initialize vis-network over one record's neighbourhood.
210
+ * A listed record was picked: select its dot and open the record's own detail.
139
211
  */
140
- init(data: GraphInput): void {
212
+ focusRecord(recordId: string): void {
213
+ const dot = this.graph.recordNode[recordId];
214
+ const node = this.graph.nodes.find((candidate) => candidate.id === dot);
215
+ if (this.network && dot !== undefined) {
216
+ this.network.selectNodes([dot]);
217
+ this.network.focus(dot, { scale: 1.2, animation: { duration: 400 } });
218
+ }
219
+ void this.showDetail({
220
+ detailId: recordId,
221
+ label: node?.label ?? recordId,
222
+ kind: node?.kind ?? null,
223
+ });
224
+ }
225
+
226
+ init(graph: FilterGraph): void {
141
227
  const container = getElementByIdOrNull<HTMLDivElement>('graph-canvas');
142
228
  if (!container) {
143
229
  logger.error('graph-canvas element not found');
144
230
  return;
145
231
  }
146
- this.graphData = data;
232
+ this.graph = graph;
147
233
  this.network?.destroy?.();
148
234
 
149
- const nodes = data.nodes.map((n) => {
150
- const kind = n.kind ?? 'memory';
151
- const center = String(n.id) === this.centerId;
152
- const color = this.getNodeColor(kind);
235
+ const empty = getElementByIdOrNull<HTMLElement>('graph-empty');
236
+ if (empty) {
237
+ empty.style.display = graph.nodes.length === 0 ? '' : 'none';
238
+ empty.textContent = 'Nothing saved for this filter.';
239
+ }
240
+
241
+ const nodes = graph.nodes.map((node) => {
242
+ const color = node.outside ? OUTSIDE_COLOR : groupInfo(node.group).color;
153
243
  return {
154
- id: n.id,
155
- // Source messages stay unlabelled dots: their time and text are on hover and click.
156
- label:
157
- kind === 'observation' && !center
158
- ? undefined
159
- : shortLabel(String(n.decision_preview ?? n.decision ?? n.topic ?? n.id)),
160
- title: this.createNodeTooltip(n),
244
+ id: node.id,
245
+ label: shortLabel(node.label),
246
+ title: this.createNodeTooltip(node),
161
247
  color: {
162
248
  background: color,
163
- border: center ? '#131313' : color,
249
+ border: color,
164
250
  highlight: { background: color, border: '#131313' },
165
251
  },
166
- size: center ? 22 : kind === 'observation' ? 9 : 14,
167
- borderWidth: center ? 3 : 1,
168
- font: { color: '#131313', size: center ? 15 : 13 },
169
- data: n,
252
+ size: node.outside ? 7 : node.updates > 0 ? 10 + Math.min(node.updates, 20) / 2 : 11,
253
+ font: { color: node.outside ? '#9CA3AF' : '#131313', size: 12 },
170
254
  };
171
255
  });
172
-
173
- const edges = data.edges.map((e) => {
174
- const style = this.getEdgeStyle(e.relationship);
256
+ const edges = graph.edges.map((edge) => {
257
+ const style = this.getEdgeStyle(edge.relation);
175
258
  return {
176
- from: e.from,
177
- to: e.to,
259
+ from: edge.from,
260
+ to: edge.to,
178
261
  arrows: { to: { enabled: true, scaleFactor: 0.5 } },
179
262
  color: style.color,
180
263
  dashes: style.dashes,
181
264
  width: style.width,
182
- title: this.relationLabel(e.relationship),
265
+ title: this.relationLabel(edge.relation),
183
266
  };
184
267
  });
185
268
 
@@ -192,43 +275,36 @@ export class GraphModule {
192
275
  physics: {
193
276
  enabled: true,
194
277
  barnesHut: {
195
- gravitationalConstant: -6000,
196
- centralGravity: 0.4,
197
- springLength: 140,
278
+ gravitationalConstant: -4000,
279
+ centralGravity: 0.5,
280
+ springLength: 120,
198
281
  springConstant: 0.04,
199
282
  damping: 0.09,
200
- avoidOverlap: 1,
283
+ avoidOverlap: 0.6,
201
284
  },
202
285
  stabilization: { enabled: true, iterations: 200, updateInterval: 25 },
203
286
  },
204
287
  interaction: { hover: true, tooltipDelay: 100, zoomView: true, dragView: true },
205
288
  }
206
289
  );
207
-
208
290
  this.network.on('click', (params: { nodes: Array<string | number> }) => {
209
- const targetId = params.nodes[0];
210
- if (targetId === undefined) return;
211
- const node = data.nodes.find((n) => String(n.id) === String(targetId));
291
+ const node = graph.nodes.find((candidate) => candidate.id === String(params.nodes[0]));
212
292
  if (node) void this.showDetail(node);
213
293
  });
214
- // Stepping to a linked record redraws the graph around it.
215
- this.network.on('doubleClick', (params: { nodes: Array<string | number> }) => {
216
- const targetId = params.nodes[0];
217
- if (targetId !== undefined && String(targetId) !== this.centerId) {
218
- void this.showRecord(String(targetId));
219
- }
220
- });
221
294
  this.network.on('stabilizationIterationsDone', () => {
222
295
  this.network?.setOptions?.({ physics: { enabled: false } });
223
296
  });
224
297
 
225
298
  this.renderLegend();
226
- const legend = getElementByIdOrNull<HTMLElement>('legend-panel');
227
- legend?.classList.add('visible');
299
+ getElementByIdOrNull<HTMLElement>('legend-panel')?.classList.add('visible');
228
300
  const stats = getElementByIdOrNull<HTMLElement>('graph-stats');
229
301
  if (stats) {
302
+ const selected = graph.nodes.filter((node) => !node.outside).length;
303
+ const outside = graph.nodes.length - selected;
230
304
  stats.style.display = '';
231
- stats.textContent = `${data.nodes.length} records and source messages · ${data.edges.length} links · click a dot for its detail, double-click to step to it`;
305
+ stats.textContent = `${selected} records and work items · ${graph.edges.length} links${
306
+ outside === 0 ? '' : ` · ${outside} linked records outside this filter, faded`
307
+ } · click a dot for its detail`;
232
308
  }
233
309
  }
234
310
 
@@ -236,45 +312,37 @@ export class GraphModule {
236
312
  // Styling and legend
237
313
  // =============================================
238
314
 
239
- getNodeColor(kind = 'memory'): string {
240
- return this.nodeColors[kind] ?? '#CBD5E1';
241
- }
242
-
243
315
  getEdgeStyle(relationship?: string): EdgeStyle {
244
316
  return (
245
317
  this.edgeStyles[relationship ?? 'default'] ?? { color: '#4a4a6a', dashes: false, width: 2 }
246
318
  );
247
319
  }
248
320
 
249
- kindLabel(kind: string): string {
250
- return KIND_LABELS[kind] ?? kind;
321
+ kindLabel(kind: string | null): string {
322
+ return kind === null ? KIND_LABELS.memory : (KIND_LABELS[kind] ?? kind);
251
323
  }
252
324
 
253
- relationLabel(relationship?: string): string {
254
- return relationship === undefined ? 'link' : (RELATION_LABELS[relationship] ?? relationship);
325
+ relationLabel(relationship: string): string {
326
+ return RELATION_LABELS[relationship] ?? relationship;
255
327
  }
256
328
 
257
- /** Count what is drawn, by kind and by relationship, with the words the legend shows. */
329
+ /** Count what is drawn, by group and by relationship, with the words the legend shows. */
258
330
  getLegendEntries() {
259
- const kinds = new Map<string, number>();
331
+ const groups = new Map<string, number>();
260
332
  const relations = new Map<string, number>();
261
- for (const node of this.graphData.nodes) {
262
- const kind = node.kind ?? 'memory';
263
- kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
333
+ for (const node of this.graph.nodes) {
334
+ groups.set(node.group, (groups.get(node.group) ?? 0) + 1);
264
335
  }
265
- for (const edge of this.graphData.edges) {
266
- const relation = edge.relationship ?? 'default';
267
- relations.set(relation, (relations.get(relation) ?? 0) + 1);
336
+ for (const edge of this.graph.edges) {
337
+ relations.set(edge.relation, (relations.get(edge.relation) ?? 0) + 1);
268
338
  }
269
339
  return {
270
- nodes: [...kinds]
271
- .sort(([, a], [, b]) => b - a)
272
- .map(([kind, count]) => ({
273
- kind,
274
- label: this.kindLabel(kind),
275
- count,
276
- color: this.getNodeColor(kind),
277
- })),
340
+ nodes: [...groups].map(([group, count]) => ({
341
+ group,
342
+ label: group === 'outside' ? 'Linked, outside this filter' : groupInfo(group).label,
343
+ count,
344
+ color: group === 'outside' ? OUTSIDE_COLOR : groupInfo(group).color,
345
+ })),
278
346
  edges: [...relations]
279
347
  .sort(([, a], [, b]) => b - a)
280
348
  .map(([relationship, count]) => ({
@@ -298,7 +366,7 @@ export class GraphModule {
298
366
  `<div class="legend-row">${sample}<span class="legend-label">${escapeHtml(label)}</span><span class="legend-count">${count}</span></div>`;
299
367
  container.innerHTML =
300
368
  section(
301
- 'Dots',
369
+ 'Dots (a work item is one dot for its updates)',
302
370
  entries.nodes.map(({ label, count, color }) =>
303
371
  row(`<span class="legend-dot" style="background:${color}"></span>`, label, count)
304
372
  )
@@ -317,17 +385,17 @@ export class GraphModule {
317
385
  if (entries.nodes.length === 0) container.textContent = 'Nothing drawn';
318
386
  }
319
387
 
320
- createNodeTooltip(node: GraphNodeRecord): string {
321
- const kind = this.kindLabel(node.kind ?? 'memory');
322
- const text = String(node.decision_preview ?? node.decision ?? '');
323
- return `${kind}${node.topic ? ` · ${node.topic}` : ''}\n${text.slice(0, 160)}`;
388
+ createNodeTooltip(node: FilterGraphNode): string {
389
+ const group = node.outside ? 'Linked, outside this filter' : groupInfo(node.group).label;
390
+ const updates = node.updates > 0 ? `\n${node.updates} updates in this period` : '';
391
+ return `${group} · ${this.kindLabel(node.kind)}\n${node.label.slice(0, 160)}${updates}`;
324
392
  }
325
393
 
326
394
  // =============================================
327
395
  // Detail Panel
328
396
  // =============================================
329
397
 
330
- async showDetail(node: GraphNodeRecord): Promise<void> {
398
+ async showDetail(target: Pick<FilterGraphNode, 'detailId' | 'label' | 'kind'>): Promise<void> {
331
399
  const panel = getElementByIdOrNull<HTMLDivElement>('decision-detail-modal');
332
400
  const topicEl = getElementByIdOrNull<HTMLElement>('detail-topic');
333
401
  const kindEl = getElementByIdOrNull<HTMLElement>('detail-kind');
@@ -338,20 +406,21 @@ export class GraphModule {
338
406
  logger.error('Detail panel elements missing');
339
407
  return;
340
408
  }
341
- if (topicEl) topicEl.textContent = node.topic || this.kindLabel(node.kind ?? 'memory');
342
- if (kindEl) kindEl.textContent = this.kindLabel(node.kind ?? 'memory');
343
- if (createdEl) {
344
- createdEl.textContent = node.created_at ? new Date(node.created_at).toLocaleString() : '-';
345
- }
409
+ if (topicEl) topicEl.textContent = target.label;
410
+ if (kindEl) kindEl.textContent = this.kindLabel(target.kind);
411
+ if (createdEl) createdEl.textContent = '-';
346
412
  decisionEl.innerHTML = '<span class="loading-similar">Loading details...</span>';
347
413
  reasoningEl.innerHTML = '';
348
414
  panel.classList.add('visible');
349
415
 
350
416
  const request = ++this.detailRequest;
351
417
  try {
352
- const detail = await API.getGraphDetail(String(node.id));
418
+ const detail = await API.getGraphDetail(target.detailId);
353
419
  if (request !== this.detailRequest) return;
354
420
  const detailNode = detail.node;
421
+ if (createdEl && detailNode.created_at) {
422
+ createdEl.textContent = new Date(detailNode.created_at).toLocaleString();
423
+ }
355
424
  decisionEl.innerHTML = renderSafeMarkdown(
356
425
  String(detailNode.decision || detailNode.decision_preview || '-')
357
426
  );
@@ -47,7 +47,7 @@ const OPERATION_LABELS: Record<string, string> = {
47
47
  withdraw: 'withdrawn',
48
48
  };
49
49
 
50
- function groupInfo(group: string): { label: string; hint: string; color: string } {
50
+ export function groupInfo(group: string): { label: string; hint: string; color: string } {
51
51
  return GROUPS[group] ?? { label: group, hint: `Records of kind ${group}`, color: '#CBD5E1' };
52
52
  }
53
53
 
@@ -71,7 +71,14 @@ export class MemoryTimelineModule {
71
71
  private bound = false;
72
72
  private debouncedLoad = debounce(() => void this.load(), 300);
73
73
 
74
- constructor(private readonly onSelect: (id: string) => void) {}
74
+ constructor(
75
+ private readonly hooks: {
76
+ /** A listed record was picked. */
77
+ onSelect: (id: string) => void;
78
+ /** The list now shows this answer; the graph draws the same records. */
79
+ onRender: (data: MemoryTimelineResponse) => void;
80
+ }
81
+ ) {}
75
82
 
76
83
  /** Wire the controls once, then read the current period. */
77
84
  async init(): Promise<void> {
@@ -127,7 +134,7 @@ export class MemoryTimelineModule {
127
134
  document
128
135
  .querySelector(`#memory-timeline [data-record-id="${CSS.escape(id)}"]`)
129
136
  ?.classList.add('selected');
130
- this.onSelect(id);
137
+ this.hooks.onSelect(id);
131
138
  }
132
139
 
133
140
  private params(): Record<string, string> {
@@ -148,6 +155,7 @@ export class MemoryTimelineModule {
148
155
  // A slower earlier read must not overwrite the answer to the latest filter.
149
156
  if (request !== this.request) return;
150
157
  this.render(data);
158
+ this.hooks.onRender(data);
151
159
  } catch (error) {
152
160
  if (request !== this.request) return;
153
161
  list.innerHTML = `<div class="mt-empty mt-error">Could not read what was saved: ${escapeHtml(
@@ -180,11 +180,11 @@ export class WikiModule {
180
180
 
181
181
  document.getElementById('wiki-new-btn')?.addEventListener('click', () => this.promptNewPage());
182
182
 
183
- // Auto-open index page only on initial load (no page selected yet)
183
+ // Auto-open the agent's table of contents only on initial load (no page selected yet)
184
184
  if (!this.currentPath) {
185
- const indexNode = tree.find((n) => n.name === 'index.md');
186
- if (!mobile && indexNode) {
187
- this.openPage(indexNode.path);
185
+ const homeNode = tree.find((n) => n.name === 'Home.md');
186
+ if (!mobile && homeNode) {
187
+ this.openPage(homeNode.path);
188
188
  }
189
189
  }
190
190
  }
@@ -79,7 +79,7 @@ declare global {
79
79
 
80
80
  interface VisNetwork {
81
81
  on(
82
- event: 'click' | 'doubleClick' | 'stabilized' | 'stabilizationIterationsDone',
82
+ event: 'click' | 'stabilized' | 'stabilizationIterationsDone',
83
83
  handler: (params: { nodes: Array<string | number> }) => void
84
84
  ): void;
85
85
  on(event: 'hoverNode' | 'blurNode', handler: (params: { node: string | number }) => void): void;
@@ -81,6 +81,11 @@ export interface TimelineGroup {
81
81
  items?: TimelineItem[];
82
82
  }
83
83
 
84
+ export interface MemoryLinks {
85
+ edges: Array<{ from: string; to: string; relation: string }>;
86
+ nodes: Record<string, { kind: string; label: string; commitmentId: string | null }>;
87
+ }
88
+
84
89
  export interface MemoryTimelineResponse {
85
90
  from: string;
86
91
  to: string;
@@ -683,9 +688,9 @@ export class API {
683
688
  // Graph API
684
689
  // =============================================
685
690
 
686
- /** One record and the records and source messages it links to directly. */
687
- static async getGraphNeighbors(id: string): Promise<GraphResponse> {
688
- return this.get<GraphResponse>('/api/graph/neighbors', { id });
691
+ /** Every link between records, with each end's kind, label and work item. */
692
+ static async getMemoryLinks(): Promise<MemoryLinks> {
693
+ return this.get<MemoryLinks>('/api/memory/links');
689
694
  }
690
695
 
691
696
  /** What was saved in a period, by day, kind and item. */
@@ -476,8 +476,8 @@ button:not([class*="p-"]):not([class*="px-"]):not([class*="py-"]) { padding: 6px
476
476
  flex-shrink: 0;
477
477
  }
478
478
 
479
- .graph-empty-text {
480
- max-width: 320px;
479
+ #graph-empty {
480
+ padding: 0 24px;
481
481
  text-align: center;
482
482
  font-size: 14px;
483
483
  color: #8a847c;
@@ -520,9 +520,7 @@
520
520
  ></div>
521
521
 
522
522
  <div class="absolute inset-0 flex items-center justify-center" id="graph-empty">
523
- <span class="graph-empty-text"
524
- >Pick a saved record on the left to see what it rests on and links to.</span
525
- >
523
+ Reading what was saved...
526
524
  </div>
527
525
 
528
526
  <div
@@ -1069,15 +1067,17 @@ export MAMA_AUTH_TOKEN="your-secure-token-here"</pre
1069
1067
 
1070
1068
  const memory = new MemoryModule();
1071
1069
  const graph = new GraphModule();
1072
- const timeline = new MemoryTimelineModule((id) => {
1073
- graph.showRecord(id).catch((error) => {
1074
- console.error('[MAMA] Failed to draw the record:', error);
1075
- const empty = document.getElementById('graph-empty');
1076
- if (empty) {
1077
- empty.style.display = '';
1078
- empty.textContent = `Could not read this record's links: ${error.message}`;
1079
- }
1080
- });
1070
+ const timeline = new MemoryTimelineModule({
1071
+ onSelect: (id) => graph.focusRecord(id),
1072
+ onRender: (data) =>
1073
+ graph.showSelection(data).catch((error) => {
1074
+ console.error('[MAMA] Failed to draw the memory graph:', error);
1075
+ const empty = document.getElementById('graph-empty');
1076
+ if (empty) {
1077
+ empty.style.display = '';
1078
+ empty.textContent = `Could not read the links between records: ${error.message}`;
1079
+ }
1080
+ }),
1081
1081
  });
1082
1082
  const wiki = new WikiModule();
1083
1083