@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.
- package/bin/ldm.js +187 -1
- package/lib/deploy.mjs +36 -1
- package/package.json +10 -2
- package/scripts/test-boot-dir-truth.mjs +158 -0
- package/scripts/test-boot-hook-registration.mjs +136 -0
- package/scripts/test-boot-payload-trim.mjs +200 -0
- package/scripts/test-crc-pair-login-flow.mjs +76 -1
- package/scripts/test-deploy-hook-ownership.mjs +160 -0
- package/scripts/test-doctor-hook-dedupe.mjs +188 -0
- package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
- package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
- package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
- package/shared/boot/boot-config.json +18 -8
- package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
- package/shared/rules/security.md +4 -0
- package/src/boot/README.md +24 -1
- package/src/boot/boot-config.json +18 -8
- package/src/boot/boot-hook.mjs +118 -28
- package/src/boot/installer.mjs +33 -10
- package/src/hosted-mcp/.env.example +4 -0
- package/src/hosted-mcp/app/footer.js +2 -2
- package/src/hosted-mcp/app/kaleidoscope-login.html +486 -42
- package/src/hosted-mcp/app/wip-logo.png +0 -0
- package/src/hosted-mcp/demo/footer.js +2 -2
- package/src/hosted-mcp/demo/index.html +166 -44
- package/src/hosted-mcp/demo/login.html +87 -23
- package/src/hosted-mcp/demo/privacy.html +4 -214
- package/src/hosted-mcp/demo/tos.html +4 -189
- package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
- package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
- package/src/hosted-mcp/legal/legal-footer.js +75 -0
- package/src/hosted-mcp/legal/legal.css +166 -0
- package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
- package/src/hosted-mcp/legal/privacy/index.html +253 -0
- package/src/hosted-mcp/server.mjs +662 -35
package/src/boot/boot-hook.mjs
CHANGED
|
@@ -38,18 +38,63 @@ function listDirSafe(dirPath) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
126
|
-
const
|
|
127
|
-
return { content:
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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}
|
|
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
|
-
|
|
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}
|
|
387
|
+
process.stderr.write(`${TAG} loaded ${loaded.length} sections, ${payloadBytes} bytes in ${elapsed}ms\n`);
|
|
298
388
|
process.exit(0);
|
|
299
389
|
}
|
|
300
390
|
|
package/src/boot/installer.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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 © 2026 WIP Computer, Inc. All rights reserved.</p>'
|
|
24
24
|
+ '<p style="margin:4px 0 0;">'
|
|
25
|
-
+ '<a href="/legal/privacy/
|
|
25
|
+
+ '<a href="/legal/privacy/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> | '
|
|
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 © 2026 WIP Computer, Inc. All rights reserved. '
|
|
34
|
-
+ '<a href="/legal/privacy/
|
|
34
|
+
+ '<a href="/legal/privacy/" style="color:#a8a4a0;text-decoration:none;">Privacy Policy</a> | '
|
|
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> | '
|