@wipcomputer/wip-ldm-os 0.4.85-alpha.30 → 0.4.85-alpha.33

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 (35) hide show
  1. package/bin/ldm.js +187 -1
  2. package/lib/deploy.mjs +36 -1
  3. package/package.json +10 -2
  4. package/scripts/test-boot-dir-truth.mjs +158 -0
  5. package/scripts/test-boot-hook-registration.mjs +136 -0
  6. package/scripts/test-boot-payload-trim.mjs +200 -0
  7. package/scripts/test-crc-pair-login-flow.mjs +76 -1
  8. package/scripts/test-deploy-hook-ownership.mjs +160 -0
  9. package/scripts/test-doctor-hook-dedupe.mjs +188 -0
  10. package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
  11. package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
  12. package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
  13. package/shared/boot/boot-config.json +18 -8
  14. package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
  15. package/shared/rules/security.md +4 -0
  16. package/src/boot/README.md +24 -1
  17. package/src/boot/boot-config.json +18 -8
  18. package/src/boot/boot-hook.mjs +118 -28
  19. package/src/boot/installer.mjs +33 -10
  20. package/src/hosted-mcp/.env.example +4 -0
  21. package/src/hosted-mcp/app/footer.js +2 -2
  22. package/src/hosted-mcp/app/kaleidoscope-login.html +486 -42
  23. package/src/hosted-mcp/app/wip-logo.png +0 -0
  24. package/src/hosted-mcp/demo/footer.js +2 -2
  25. package/src/hosted-mcp/demo/index.html +166 -44
  26. package/src/hosted-mcp/demo/login.html +87 -23
  27. package/src/hosted-mcp/demo/privacy.html +4 -214
  28. package/src/hosted-mcp/demo/tos.html +4 -189
  29. package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
  30. package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
  31. package/src/hosted-mcp/legal/legal-footer.js +75 -0
  32. package/src/hosted-mcp/legal/legal.css +166 -0
  33. package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
  34. package/src/hosted-mcp/legal/privacy/index.html +253 -0
  35. package/src/hosted-mcp/server.mjs +662 -35
@@ -38,18 +38,63 @@ function listDirSafe(dirPath) {
38
38
  }
39
39
  }
40
40
 
41
- function truncateTop(content, maxLines) {
42
- if (!maxLines || !content) return content;
41
+ // Per-step default line caps applied when boot-config.json does not set
42
+ // maxLines for the step. Keyed by the step key in config.steps. Any of these
43
+ // can be overridden per-step via a maxLines value in boot-config.json.
44
+ // Lives in code (not just the config template) because the installer preserves
45
+ // an existing user boot-config.json, so config-template changes never reach
46
+ // existing installs. Defaults must ride along with the hook.
47
+ const DEFAULT_MAX_LINES = {
48
+ sharedContext: 80,
49
+ soul: 80,
50
+ context: 60,
51
+ repoLocations: 80,
52
+ };
53
+
54
+ // Default staleness cutoffs. Overridable via top-level config keys
55
+ // (stalenessDays, dailyLogStalenessDays) or per-step keys of the same name.
56
+ const DEFAULT_STALENESS_DAYS = 14; // most-recent strategy (journals)
57
+ const DEFAULT_DAILY_LOG_STALENESS_DAYS = 2; // daily-logs strategy guard
58
+
59
+ function resolveMaxLines(key, step) {
60
+ if (step.maxLines != null) return step.maxLines;
61
+ if (DEFAULT_MAX_LINES[key] != null) return DEFAULT_MAX_LINES[key];
62
+ return null;
63
+ }
64
+
65
+ // Returns { text, capped }. When truncation happens, the marker names the
66
+ // full path so a future reader can pull the rest.
67
+ function truncateTop(content, maxLines, fullPath) {
68
+ if (!maxLines || !content) return { text: content, capped: false };
43
69
  const lines = content.split('\n');
44
- if (lines.length <= maxLines) return content;
45
- return lines.slice(0, maxLines).join('\n') + `\n[... truncated at ${maxLines} lines, ${lines.length} total ...]`;
70
+ if (lines.length <= maxLines) return { text: content, capped: false };
71
+ const where = fullPath || '(path unknown)';
72
+ const marker = `\n[... truncated at ${maxLines} of ${lines.length} lines. Read the rest: ${where} ...]`;
73
+ return { text: lines.slice(0, maxLines).join('\n') + marker, capped: true };
46
74
  }
47
75
 
48
- function truncateBottom(content, maxLines) {
49
- if (!maxLines || !content) return content;
76
+ function truncateBottom(content, maxLines, fullPath) {
77
+ if (!maxLines || !content) return { text: content, capped: false };
50
78
  const lines = content.split('\n');
51
- if (lines.length <= maxLines) return content;
52
- return `[... showing last ${maxLines} of ${lines.length} lines ...]\n` + lines.slice(-maxLines).join('\n');
79
+ if (lines.length <= maxLines) return { text: content, capped: false };
80
+ const where = fullPath || '(path unknown)';
81
+ const marker = `[... showing last ${maxLines} of ${lines.length} lines. Read the rest: ${where} ...]\n`;
82
+ return { text: marker + lines.slice(-maxLines).join('\n'), capped: true };
83
+ }
84
+
85
+ // Extract a YYYY-MM-DD prefix from a filename, or null if none.
86
+ function dateStrFromName(fileName) {
87
+ const m = /^(\d{4}-\d{2}-\d{2})/.exec(fileName || '');
88
+ return m ? m[1] : null;
89
+ }
90
+
91
+ // Whole-day difference (todayStr - dateStr) in days. Both are YYYY-MM-DD.
92
+ // Positive means dateStr is in the past. null if either is unparseable.
93
+ function ageInDays(dateStr, todayStr) {
94
+ const a = Date.parse(`${dateStr}T00:00:00Z`);
95
+ const b = Date.parse(`${todayStr}T00:00:00Z`);
96
+ if (Number.isNaN(a) || Number.isNaN(b)) return null;
97
+ return Math.round((b - a) / 86400000);
53
98
  }
54
99
 
55
100
  function getTodayAndYesterday(timezone) {
@@ -99,6 +144,8 @@ function getDefaultConfig() {
99
144
  agentId: 'cc-mini',
100
145
  timezone: 'America/Los_Angeles',
101
146
  maxTotalLines: 2000,
147
+ stalenessDays: DEFAULT_STALENESS_DAYS,
148
+ dailyLogStalenessDays: DEFAULT_DAILY_LOG_STALENESS_DAYS,
102
149
  steps: {
103
150
  sharedContext: { path: '~/.openclaw/workspace/SHARED-CONTEXT.md', label: 'SHARED-CONTEXT.md', stepNumber: 2, critical: true },
104
151
  journals: { dir: '~/.ldm/agents/cc-mini/memory/journals', label: 'Most Recent CC Journal (Legacy)', stepNumber: 3, maxLines: 80, strategy: 'most-recent' },
@@ -113,52 +160,79 @@ function getDefaultConfig() {
113
160
  };
114
161
  }
115
162
 
116
- function processStep(key, step, dates) {
163
+ function processStep(key, step, dates, config) {
117
164
  // Reminder-only step (e.g. full history)
118
165
  if (step.reminder) {
119
- return { content: step.reminder, loaded: true, fileName: null };
166
+ return { content: step.reminder, loaded: true, fileName: null, capped: false, stale: false };
120
167
  }
121
168
 
169
+ const maxLines = resolveMaxLines(key, step);
170
+
122
171
  // Single file step
123
172
  if (step.path) {
173
+ const resolved = resolvePath(step.path);
124
174
  const content = readFileSafe(step.path);
125
- if (!content) return { content: null, loaded: false, fileName: resolvePath(step.path) };
126
- const trimmed = step.maxLines ? truncateTop(content, step.maxLines) : content;
127
- return { content: trimmed, loaded: true, fileName: resolvePath(step.path) };
175
+ if (!content) return { content: null, loaded: false, fileName: resolved, capped: false, stale: false };
176
+ const { text, capped } = truncateTop(content, maxLines, resolved);
177
+ return { content: text, loaded: true, fileName: resolved, capped, stale: false };
128
178
  }
129
179
 
130
180
  // Directory-based step
131
181
  if (step.dir) {
132
182
  if (step.strategy === 'most-recent') {
133
183
  const fileName = findMostRecent(step.dir);
134
- if (!fileName) return { content: null, loaded: false, fileName: resolvePath(step.dir) };
184
+ if (!fileName) return { content: null, loaded: false, fileName: resolvePath(step.dir), capped: false, stale: false };
135
185
  const fullPath = join(resolvePath(step.dir), fileName);
136
186
  const content = readFileSafe(fullPath);
137
- if (!content) return { content: null, loaded: false, fileName: fullPath };
138
- const trimmed = step.maxLines ? truncateTop(content, step.maxLines) : content;
139
- return { content: trimmed, loaded: true, fileName: `${fileName}` };
187
+ if (!content) return { content: null, loaded: false, fileName: fullPath, capped: false, stale: false };
188
+
189
+ // Staleness cutoff: if the newest file is older than the cutoff, do not
190
+ // inject its body. Emit a single line with the path and date instead.
191
+ const stalenessDays = step.stalenessDays ?? config.stalenessDays ?? DEFAULT_STALENESS_DAYS;
192
+ const fileDate = dateStrFromName(fileName);
193
+ const age = fileDate ? ageInDays(fileDate, dates.today) : null;
194
+ if (age != null && age > stalenessDays) {
195
+ const line = `[stale: newest file is ${fileDate} (${age}d old, cutoff ${stalenessDays}d). Body not injected. Read if needed: ${fullPath}]`;
196
+ return { content: line, loaded: true, fileName, capped: false, stale: true };
197
+ }
198
+
199
+ const { text, capped } = truncateTop(content, maxLines, fullPath);
200
+ return { content: text, loaded: true, fileName, capped, stale: false };
140
201
  }
141
202
 
142
203
  if (step.strategy === 'daily-logs') {
204
+ const dailyStalenessDays = step.dailyLogStalenessDays ?? config.dailyLogStalenessDays ?? DEFAULT_DAILY_LOG_STALENESS_DAYS;
143
205
  const parts = [];
144
206
  let anyLoaded = false;
207
+ let anyCapped = false;
208
+ let anyStale = false;
145
209
  for (const day of (step.days || ['today'])) {
146
210
  const dateStr = day === 'today' ? dates.today : dates.yesterday;
147
211
  const fileName = `${dateStr}.md`;
148
212
  const fullPath = join(resolvePath(step.dir), fileName);
149
213
  const content = readFileSafe(fullPath);
150
- if (content) {
151
- const trimmed = step.maxLines ? truncateBottom(content, step.maxLines) : content;
152
- parts.push(`--- ${day} (${dateStr}) ---\n${trimmed}`);
153
- anyLoaded = true;
214
+ if (!content) continue;
215
+ anyLoaded = true;
216
+
217
+ // Guard: a day file dated older than the cutoff should not normally
218
+ // happen (days are today/yesterday), but if it does, emit path-only.
219
+ const age = ageInDays(dateStr, dates.today);
220
+ if (age != null && age > dailyStalenessDays) {
221
+ parts.push(`--- ${day} (${dateStr}) ---\n[stale: ${age}d old, cutoff ${dailyStalenessDays}d. Body not injected. Read if needed: ${fullPath}]`);
222
+ anyStale = true;
223
+ continue;
154
224
  }
225
+
226
+ const { text, capped } = truncateBottom(content, maxLines, fullPath);
227
+ if (capped) anyCapped = true;
228
+ parts.push(`--- ${day} (${dateStr}) ---\n${text}`);
155
229
  }
156
- if (!anyLoaded) return { content: null, loaded: false, fileName: resolvePath(step.dir) };
157
- return { content: parts.join('\n\n'), loaded: true, fileName: null };
230
+ if (!anyLoaded) return { content: null, loaded: false, fileName: resolvePath(step.dir), capped: false, stale: false };
231
+ return { content: parts.join('\n\n'), loaded: true, fileName: null, capped: anyCapped, stale: anyStale };
158
232
  }
159
233
  }
160
234
 
161
- return { content: null, loaded: false, fileName: null };
235
+ return { content: null, loaded: false, fileName: null, capped: false, stale: false };
162
236
  }
163
237
 
164
238
  async function main() {
@@ -181,6 +255,8 @@ async function main() {
181
255
  const sections = [];
182
256
  const loaded = [];
183
257
  const skipped = [];
258
+ const cappedSteps = [];
259
+ const staleSteps = [];
184
260
  let totalLines = 0;
185
261
 
186
262
  // Sort steps by stepNumber
@@ -189,7 +265,7 @@ async function main() {
189
265
  );
190
266
 
191
267
  for (const [key, step] of stepEntries) {
192
- const result = processStep(key, step, dates);
268
+ const result = processStep(key, step, dates, config);
193
269
 
194
270
  if (result.loaded && result.content) {
195
271
  const criticalTag = step.critical ? ' (CRITICAL)' : '';
@@ -198,6 +274,8 @@ async function main() {
198
274
  sections.push(`${header}\n${result.content}`);
199
275
  loaded.push(`Step ${step.stepNumber}: ${step.label}`);
200
276
  totalLines += result.content.split('\n').length;
277
+ if (result.capped) cappedSteps.push(`Step ${step.stepNumber}`);
278
+ if (result.stale) staleSteps.push(`Step ${step.stepNumber}`);
201
279
  } else {
202
280
  skipped.push(`Step ${step.stepNumber}: ${step.label}`);
203
281
  if (result.fileName) {
@@ -275,16 +353,28 @@ async function main() {
275
353
  } catch {}
276
354
 
277
355
  const elapsed = Date.now() - startTime;
278
- const footer = `== Boot complete. Loaded ${loaded.length}/9 files in ${elapsed}ms. ==`;
356
+ const footer = `== Boot complete. Loaded ${loaded.length} sections in ${elapsed}ms. ==`;
279
357
  if (skipped.length > 0) {
280
358
  sections.push(`${footer}\nSkipped: ${skipped.join(', ')}`);
281
359
  } else {
282
360
  sections.push(footer);
283
361
  }
284
362
 
285
- const additionalContext =
363
+ // Body first, then a one-line payload summary so future slowness triage is
364
+ // trivial: total bytes/lines injected, which steps were line-capped, and
365
+ // which were skipped as stale (path-only).
366
+ const bodyText =
286
367
  `== LDM OS BOOT SEQUENCE (loaded automatically by SessionStart hook) ==\n\n` +
287
368
  sections.join('\n\n');
369
+ const payloadBytes = Buffer.byteLength(bodyText, 'utf-8');
370
+ const payloadLines = bodyText.split('\n').length;
371
+ const summary =
372
+ `== Boot payload: ${payloadBytes} bytes, ${payloadLines} lines, ${loaded.length} sections. ` +
373
+ `Capped: ${cappedSteps.length ? cappedSteps.join(', ') : 'none'}. ` +
374
+ `Stale/path-only: ${staleSteps.length ? staleSteps.join(', ') : 'none'}. ` +
375
+ `Missing: ${skipped.length ? skipped.length : 'none'}. ==`;
376
+
377
+ const additionalContext = `${bodyText}\n\n${summary}`;
288
378
 
289
379
  const output = {
290
380
  hookSpecificOutput: {
@@ -294,7 +384,7 @@ async function main() {
294
384
  };
295
385
 
296
386
  process.stdout.write(JSON.stringify(output));
297
- process.stderr.write(`${TAG} loaded ${loaded.length}/9 files in ${elapsed}ms\n`);
387
+ process.stderr.write(`${TAG} loaded ${loaded.length} sections, ${payloadBytes} bytes in ${elapsed}ms\n`);
298
388
  process.exit(0);
299
389
  }
300
390
 
@@ -142,13 +142,20 @@ export function configureSessionStartHook() {
142
142
  if (!settings.hooks) settings.hooks = {};
143
143
  if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
144
144
 
145
- // Find existing boot-hook entry
146
- const existingIdx = settings.hooks.SessionStart.findIndex((entry) => {
145
+ const isBootEntry = (entry) => {
147
146
  const hooks = entry?.hooks;
148
147
  if (!Array.isArray(hooks)) return false;
149
148
  return hooks.some((h) =>
150
149
  h?.command?.includes('boot-hook') || h?.command?.includes('shared/boot')
151
150
  );
151
+ };
152
+
153
+ // Find ALL boot-hook entries. Duplicate registrations accumulate across
154
+ // installs (10 copies observed 2026-07-04, each running the boot script
155
+ // once per session), so this owns the whole set, not just the first.
156
+ const ownedIdxs = [];
157
+ settings.hooks.SessionStart.forEach((entry, i) => {
158
+ if (isBootEntry(entry)) ownedIdxs.push(i);
152
159
  });
153
160
 
154
161
  const hookEntry = {
@@ -160,16 +167,32 @@ export function configureSessionStartHook() {
160
167
  }],
161
168
  };
162
169
 
163
- if (existingIdx >= 0) {
164
- // Update in place
165
- settings.hooks.SessionStart[existingIdx] = hookEntry;
166
- return 'SessionStart hook updated in settings.json';
167
- } else {
168
- // Append
169
- settings.hooks.SessionStart.push(hookEntry);
170
+ // Collapse duplicates beyond the first. Walk right-to-left so earlier
171
+ // indices stay valid while splicing.
172
+ let removed = 0;
173
+ for (let j = ownedIdxs.length - 1; j >= 1; j--) {
174
+ settings.hooks.SessionStart.splice(ownedIdxs[j], 1);
175
+ removed++;
176
+ }
177
+
178
+ if (ownedIdxs.length > 0) {
179
+ const idx = ownedIdxs[0];
180
+ const identical =
181
+ JSON.stringify(settings.hooks.SessionStart[idx]) === JSON.stringify(hookEntry);
182
+ if (identical && removed === 0) {
183
+ // Nothing to change; do not touch the file.
184
+ return 'SessionStart hook already configured';
185
+ }
186
+ settings.hooks.SessionStart[idx] = hookEntry;
170
187
  writeJSON(CC_SETTINGS, settings);
171
- return 'SessionStart hook added to settings.json';
188
+ return removed > 0
189
+ ? `SessionStart hook updated in settings.json (removed ${removed} duplicate entr${removed === 1 ? 'y' : 'ies'})`
190
+ : 'SessionStart hook updated in settings.json';
172
191
  }
192
+
193
+ settings.hooks.SessionStart.push(hookEntry);
194
+ writeJSON(CC_SETTINGS, settings);
195
+ return 'SessionStart hook added to settings.json';
173
196
  }
174
197
 
175
198
  // ── Registry ──
@@ -1,3 +1,7 @@
1
1
  # Kaleidoscope server environment
2
2
  # Copy to .env and fill in values. Never commit .env itself.
3
3
  DATABASE_URL=postgresql://kaleidoscope:YOUR_PASSWORD@localhost:5432/kaleidoscope
4
+ KALEIDOSCOPE_LIVE_WALL_FILE=/var/lib/wip/kaleidoscope-live-wall.json
5
+ KALEIDOSCOPE_LIVE_WALL_MEDIA_DIR=/var/www/wip.computer/public_html/media/kaleidoscope/generated
6
+ KALEIDOSCOPE_LIVE_WALL_MAX_IMAGE_BYTES=10485760
7
+ KALEIDOSCOPE_LIVE_WALL_FETCH_TIMEOUT_MS=15000
@@ -22,7 +22,7 @@
22
22
  + '<p style="margin:2px 0 0;">Learning Dreaming Machines</p>'
23
23
  + '<p style="margin:8px 0 0;">Copyright &copy; 2026 WIP Computer, Inc. All rights reserved.</p>'
24
24
  + '<p style="margin:4px 0 0;">'
25
- + '<a href="/legal/privacy/en-ww/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> &nbsp;|&nbsp; '
25
+ + '<a href="/legal/privacy/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> &nbsp;|&nbsp; '
26
26
  + '<a href="/legal/internet-services/terms/site.html" style="color:#a8a4a0;text-decoration:none;">Terms of Use</a></p>'
27
27
  + '<p style="margin:4px 0 0;">'
28
28
  + '<a href="/agent.txt" style="color:#a8a4a0;text-decoration:none;">Are you an AI Agent?</a></p>'
@@ -31,7 +31,7 @@
31
31
  inner.innerHTML = '<p style="margin:0;">WIP Computer, Inc.</p>'
32
32
  + '<p style="margin:2px 0 0;">Learning Dreaming Machines</p>'
33
33
  + '<p style="margin:8px 0 0;">Copyright &copy; 2026 WIP Computer, Inc. All rights reserved. &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'
34
- + '<a href="/legal/privacy/en-ww/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> &nbsp;|&nbsp; '
34
+ + '<a href="/legal/privacy/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> &nbsp;|&nbsp; '
35
35
  + '<a href="/legal/internet-services/terms/site.html" style="color:#a8a4a0;text-decoration:none;">Terms of Use</a></p>'
36
36
  + '<p style="margin:4px 0 0;">'
37
37
  + '<a href="/agent.txt" style="color:#a8a4a0;text-decoration:none;">Are you an AI Agent?</a> &nbsp;|&nbsp; '