@oooooooooo/ch0de 0.0.0-stage → 1.9.55

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 (96) hide show
  1. package/API-TESTING.md +76 -0
  2. package/DEBTAUR-INTEGRATION.md +71 -0
  3. package/EDITING.md +80 -0
  4. package/EXPERT-PANEL.md +16 -0
  5. package/INTEGRATIONS.md +75 -0
  6. package/LICENSE +21 -0
  7. package/MODELS-DEV-LICENSE +21 -0
  8. package/PROVIDER-CONNECTIONS.md +48 -0
  9. package/PROVIDERS.md +17 -0
  10. package/README.md +523 -2
  11. package/RELIABILITY.md +95 -0
  12. package/UX-1.3.md +83 -0
  13. package/UX-1.4.1.md +13 -0
  14. package/UX-1.4.md +48 -0
  15. package/UX-1.5.md +13 -0
  16. package/UX-1.6.md +7 -0
  17. package/UX-1.7.3.md +13 -0
  18. package/UX-1.7.4.md +13 -0
  19. package/UX-1.7.md +19 -0
  20. package/acceptance-check.js +27 -0
  21. package/agent-budget.js +4 -0
  22. package/api-testing.js +140 -0
  23. package/automatic-re.js +19 -0
  24. package/benchmarks/public-baselines.json +29 -0
  25. package/benchmarks/render-1.4.0.json +8 -0
  26. package/benchmarks/targets.js +11 -0
  27. package/browser-check.js +1062 -0
  28. package/build-progress.js +10 -0
  29. package/build-quality.js +49 -0
  30. package/chode.js +7937 -0
  31. package/debtaur-engine.js +305 -0
  32. package/debtaur-orchestration.js +456 -0
  33. package/debtaur-worker.cjs +76 -0
  34. package/doctor.js +24 -0
  35. package/edit-transactions.js +79 -0
  36. package/elapsed.js +28 -0
  37. package/evidence-ledger.js +26 -0
  38. package/expert-panel.cjs +42 -0
  39. package/helios-ledger.js +405 -0
  40. package/install.cjs +25 -0
  41. package/integrations.js +30 -0
  42. package/json-output.js +8 -0
  43. package/local-memory.js +613 -0
  44. package/local-models.js +52 -0
  45. package/mcp-client.js +50 -0
  46. package/memory.js +511 -0
  47. package/model-catalog.js +46 -0
  48. package/native-tools.js +27 -0
  49. package/nosignups.js +325 -0
  50. package/observation-progress.js +33 -0
  51. package/orchestrator.js +299 -0
  52. package/package.json +158 -6
  53. package/plan-z6.js +490 -0
  54. package/project-memory.js +27 -0
  55. package/provider-capacity.js +13 -0
  56. package/provider-catalog.js +246 -0
  57. package/provider-connect.js +66 -0
  58. package/provider-credentials.js +5 -0
  59. package/provider-directory.js +8 -0
  60. package/provider-directory.json +32300 -0
  61. package/provider-errors.js +11 -0
  62. package/provider-picker.js +188 -0
  63. package/provider-policy.js +4 -0
  64. package/provider-race.js +25 -0
  65. package/provider-request.js +45 -0
  66. package/provider-store.js +13 -0
  67. package/rea-environment.js +5 -0
  68. package/read-window.js +21 -0
  69. package/recovery-context.js +3 -0
  70. package/reliability-memory.js +38 -0
  71. package/reliability-services.js +34 -0
  72. package/reliability-store.js +16 -0
  73. package/request-deadline.js +4 -0
  74. package/serve-frontend.html +172 -0
  75. package/settings-reset.js +11 -0
  76. package/skill-catalog.js +16 -0
  77. package/task-group.js +22 -0
  78. package/terminal-graphics.js +39 -0
  79. package/terminal-logo.js +76 -0
  80. package/terminal-ui.js +79 -0
  81. package/tool-call-validation.js +26 -0
  82. package/tool-health.js +22 -0
  83. package/tool-history.js +5 -0
  84. package/toolbox.js +156 -0
  85. package/tui-provider-picker.js +134 -0
  86. package/tui-worker.js +32 -0
  87. package/web-context.js +232 -0
  88. package/wolfram-math.js +6 -0
  89. package/worker-transport.js +11 -0
  90. package/workspace-commands.js +45 -0
  91. package/workspace-log.js +56 -0
  92. package/workspace-store.js +7 -0
  93. package/workspace-tui.js +113 -0
  94. package/workspace-view.js +109 -0
  95. package/worktrees.js +18 -0
  96. package/z6-integration.js +227 -0
@@ -0,0 +1,613 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Local Memory Module for Chode
5
+ *
6
+ * Filesystem-based persistent memory with Z6P-inspired design:
7
+ * - Hash-bound state vectors for integrity
8
+ * - Local, traversable storage in .chode/memory/
9
+ * - Session compression after N messages
10
+ * - Handoff state for provider switching
11
+ * - Constraint validation hooks
12
+ *
13
+ * Replaces HEMO vault dependency (remote HTTP) with local-first approach.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+
20
+ // Resolved lazily so in-process cwd changes (tests, chdir) keep memory local.
21
+ function dirs() {
22
+ const memory = path.join(process.cwd(), '.chode', 'memory');
23
+ return { memory: memory, sessions: path.join(memory, 'sessions'), audit: path.join(memory, 'audit') };
24
+ }
25
+ const COMPRESSION_THRESHOLD = 20;
26
+ const MAX_MESSAGES = 50;
27
+
28
+ // Z6 state vector indices
29
+ const Z6_STATE = {
30
+ STEP: 0, // Current step index (0-5)
31
+ STATUS: 1, // Session status (0=active, 1=paused, 2=completed, 3=failed, 4=blocked, 5=waiting)
32
+ PROVIDER_HEALTH: 2, // Provider health score (0-5)
33
+ CAPACITY: 3, // Available capacity (0-5)
34
+ QUEUE_DEPTH: 4, // Pending tasks (0-5)
35
+ ERROR_STATE: 5 // Error state (0=none, 1=recoverable, 2=unrecoverable, 3=rate-limited, 4=auth-failed, 5=timeout)
36
+ };
37
+
38
+ function ensureDirs() {
39
+ const { memory: MEMORY_DIR, sessions: SESSIONS_DIR, audit: AUDIT_DIR } = dirs();
40
+ if (!fs.existsSync(MEMORY_DIR)) fs.mkdirSync(MEMORY_DIR, { recursive: true });
41
+ if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
42
+ if (!fs.existsSync(AUDIT_DIR)) fs.mkdirSync(AUDIT_DIR, { recursive: true });
43
+ }
44
+
45
+ function hash(data) {
46
+ return crypto.createHash('sha256').update(data).digest('hex');
47
+ }
48
+
49
+ function selfHash(data, primaryHash) {
50
+ return crypto.createHash('sha256').update(data + primaryHash).digest('hex');
51
+ }
52
+
53
+ // Canonical payload every session hash is computed over. Both the write path
54
+ // and the verification path must build this object with identical key order.
55
+ function sessionStatePayload(id, stateVector, plan, handoff) {
56
+ return JSON.stringify({ id: id, stateVector: stateVector || [0, 0, 0, 0, 0, 0], plan: plan, handoff: handoff });
57
+ }
58
+
59
+ function verifyStoredState(data, where) {
60
+ const payload = sessionStatePayload(data.id, data.stateVector, data.plan, data.handoff);
61
+ const expected = hash(payload);
62
+ const expectedSelf = selfHash(payload, expected);
63
+ if (data.hash !== expected || data.selfHash !== expectedSelf) {
64
+ throw new Error(where + ' corrupted: hash mismatch');
65
+ }
66
+ return { hash: expected, selfHash: expectedSelf };
67
+ }
68
+
69
+ // Keys an integrity layer added on top of a payload; stripping them recovers
70
+ // the exact bytes the hash was taken over (insertion order is preserved).
71
+ function stripIntegrityKeys(record) {
72
+ const copy = { ...record };
73
+ delete copy.timestamp;
74
+ delete copy.hash;
75
+ delete copy.selfHash;
76
+ return copy;
77
+ }
78
+
79
+ /**
80
+ * Session record with Z6 state vector
81
+ */
82
+ class Session {
83
+ constructor(id, metadata = {}) {
84
+ this.id = id;
85
+ this.createdAt = Date.now();
86
+ this.updatedAt = Date.now();
87
+ this.messages = [];
88
+ this.plan = null;
89
+ this.handoff = null;
90
+ this.stateVector = [0, 0, 0, 0, 0, 0]; // Z6 state
91
+ this.metadata = metadata;
92
+ }
93
+
94
+ statePayload() {
95
+ return sessionStatePayload(this.id, this.stateVector, this.plan, this.handoff);
96
+ }
97
+
98
+ computeHash() {
99
+ return hash(this.statePayload());
100
+ }
101
+
102
+ computeSelfHash() {
103
+ const payload = this.statePayload();
104
+ return selfHash(payload, hash(payload));
105
+ }
106
+
107
+ serialize() {
108
+ return {
109
+ id: this.id,
110
+ createdAt: this.createdAt,
111
+ updatedAt: this.updatedAt,
112
+ messages: this.messages,
113
+ plan: this.plan,
114
+ handoff: this.handoff,
115
+ stateVector: this.stateVector,
116
+ metadata: this.metadata,
117
+ hash: this.computeHash(),
118
+ selfHash: this.computeSelfHash()
119
+ };
120
+ }
121
+
122
+ static deserialize(data) {
123
+ const s = new Session(data.id, data.metadata);
124
+ s.createdAt = data.createdAt;
125
+ s.updatedAt = data.updatedAt;
126
+ s.messages = []; // Messages loaded from individual files, not meta.json
127
+ s.plan = data.plan;
128
+ s.handoff = data.handoff;
129
+ s.stateVector = data.stateVector || [0, 0, 0, 0, 0, 0];
130
+ return s;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Message record with hash-bound integrity
136
+ */
137
+ class Message {
138
+ constructor(role, content, metadata = {}) {
139
+ this.id = crypto.randomUUID();
140
+ this.role = role;
141
+ this.content = content;
142
+ this.timestamp = Date.now();
143
+ this.metadata = metadata;
144
+ this.hash = null;
145
+ this.selfHash = null;
146
+ this.computeHashes();
147
+ }
148
+
149
+ computeHashes() {
150
+ const data = JSON.stringify({
151
+ id: this.id,
152
+ role: this.role,
153
+ content: this.content,
154
+ timestamp: this.timestamp,
155
+ metadata: this.metadata
156
+ });
157
+ this.hash = hash(data);
158
+ this.selfHash = selfHash(data, this.hash);
159
+ }
160
+
161
+ serialize() {
162
+ return {
163
+ ...this,
164
+ hash: this.hash,
165
+ selfHash: this.selfHash
166
+ };
167
+ }
168
+ }
169
+
170
+ // Verify a message record read from disk against its stored hashes.
171
+ function verifyMessageRecord(msgData, where) {
172
+ const payload = JSON.stringify({
173
+ id: msgData.id,
174
+ role: msgData.role,
175
+ content: msgData.content,
176
+ timestamp: msgData.timestamp,
177
+ metadata: msgData.metadata
178
+ });
179
+ const expected = hash(payload);
180
+ const expectedSelf = selfHash(payload, expected);
181
+ if (msgData.hash !== expected || msgData.selfHash !== expectedSelf) {
182
+ throw new Error(where + ' corrupted: hash mismatch');
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Local Memory Manager
188
+ */
189
+ class LocalMemoryManager {
190
+ constructor() {
191
+ ensureDirs();
192
+ this.sessions = new Map();
193
+ this.auditLog = [];
194
+ }
195
+
196
+ /**
197
+ * Create or load a session
198
+ */
199
+ getOrCreateSession(sessionId, metadata = {}) {
200
+ const { sessions: SESSIONS_DIR } = dirs();
201
+ const sessionPath = path.join(SESSIONS_DIR, sessionId, 'meta.json');
202
+
203
+ if (fs.existsSync(sessionPath)) {
204
+ let data;
205
+ try {
206
+ data = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
207
+ } catch (e) {
208
+ throw new Error('Session corrupted (parse error): ' + sessionId);
209
+ }
210
+ verifyStoredState(data, 'Session');
211
+ const session = Session.deserialize(data);
212
+
213
+ // Load messages and verify each one's integrity
214
+ const messagesDir = path.join(SESSIONS_DIR, sessionId, 'messages');
215
+ if (fs.existsSync(messagesDir)) {
216
+ const files = fs.readdirSync(messagesDir).sort();
217
+ for (const file of files) {
218
+ let msgData;
219
+ try {
220
+ msgData = JSON.parse(fs.readFileSync(path.join(messagesDir, file), 'utf8'));
221
+ } catch (e) {
222
+ throw new Error('Message corrupted (parse error): ' + sessionId + '/' + file);
223
+ }
224
+ verifyMessageRecord(msgData, 'Message');
225
+ session.messages.push(msgData);
226
+ }
227
+ }
228
+
229
+ this.sessions.set(sessionId, session);
230
+ return session;
231
+ }
232
+
233
+ const session = new Session(sessionId, metadata);
234
+ this.sessions.set(sessionId, session);
235
+ this.saveSession(session);
236
+ return session;
237
+ }
238
+
239
+ /**
240
+ * Save session to disk
241
+ */
242
+ saveSession(session) {
243
+ const { sessions: SESSIONS_DIR } = dirs();
244
+ session.updatedAt = Date.now();
245
+ const sessionDir = path.join(SESSIONS_DIR, session.id);
246
+ if (!fs.existsSync(sessionDir)) fs.mkdirSync(sessionDir, { recursive: true });
247
+
248
+ // Save metadata atomically (temp file + rename, like build-progress) so a
249
+ // crash mid-write never leaves a truncated meta.json behind.
250
+ const metaPath = path.join(sessionDir, 'meta.json');
251
+ const tempPath = metaPath + '.' + process.pid + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
252
+ try {
253
+ fs.writeFileSync(tempPath, JSON.stringify(session.serialize(), null, 2), 'utf8');
254
+ fs.renameSync(tempPath, metaPath);
255
+ } finally {
256
+ try { fs.unlinkSync(tempPath); } catch (_) {}
257
+ }
258
+
259
+ // Save messages individually (for traversability)
260
+ const messagesDir = path.join(sessionDir, 'messages');
261
+ if (!fs.existsSync(messagesDir)) fs.mkdirSync(messagesDir, { recursive: true });
262
+
263
+ for (let i = 0; i < session.messages.length; i++) {
264
+ const msg = session.messages[i];
265
+ const msgPath = path.join(messagesDir, `${String(i + 1).padStart(4, '0')}.json`);
266
+ if (!fs.existsSync(msgPath)) {
267
+ const msgData = msg.serialize ? msg.serialize() : msg;
268
+ fs.writeFileSync(msgPath, JSON.stringify(msgData, null, 2), 'utf8');
269
+ }
270
+ }
271
+
272
+ // Log state transition to audit
273
+ this.logTransition(session);
274
+ }
275
+
276
+ /**
277
+ * Add a message to session
278
+ */
279
+ addMessage(sessionId, role, content, metadata = {}) {
280
+ const session = this.getOrCreateSession(sessionId);
281
+ const message = new Message(role, content, metadata);
282
+ session.messages.push(message);
283
+
284
+ // Compress if over threshold
285
+ if (session.messages.length > COMPRESSION_THRESHOLD * 2) {
286
+ this.compressSession(session);
287
+ }
288
+
289
+ // Trim to max
290
+ if (session.messages.length > MAX_MESSAGES) {
291
+ session.messages = session.messages.slice(-MAX_MESSAGES);
292
+ }
293
+
294
+ this.saveSession(session);
295
+ return message;
296
+ }
297
+
298
+ /**
299
+ * Compress old messages (keep recent + summary)
300
+ */
301
+ compressSession(session) {
302
+ const keepCount = COMPRESSION_THRESHOLD;
303
+ const toRemove = session.messages.length - keepCount;
304
+
305
+ if (toRemove <= 0) return;
306
+
307
+ // Create summary of removed messages
308
+ const summary = new Message('system', `[SUMMARY: ${toRemove} messages compressed at ${new Date().toISOString()}]`, {
309
+ compressedCount: toRemove
310
+ });
311
+
312
+ // Keep last N messages
313
+ session.messages = [summary, ...session.messages.slice(-keepCount)];
314
+
315
+ // Re-save all messages (overwrites old files)
316
+ const { sessions: SESSIONS_DIR } = dirs();
317
+ const messagesDir = path.join(SESSIONS_DIR, session.id, 'messages');
318
+ if (fs.existsSync(messagesDir)) {
319
+ fs.rmSync(messagesDir, { recursive: true, force: true });
320
+ }
321
+ fs.mkdirSync(messagesDir, { recursive: true });
322
+
323
+ for (let i = 0; i < session.messages.length; i++) {
324
+ const msg = session.messages[i];
325
+ const msgPath = path.join(messagesDir, `${String(i + 1).padStart(4, '0')}.json`);
326
+ const msgData = msg.serialize ? msg.serialize() : msg;
327
+ fs.writeFileSync(msgPath, JSON.stringify(msgData, null, 2), 'utf8');
328
+ }
329
+
330
+ this.saveSession(session);
331
+ }
332
+
333
+ /**
334
+ * Set handoff state for provider switching
335
+ */
336
+ setHandoff(sessionId, handoff) {
337
+ const session = this.getOrCreateSession(sessionId);
338
+ // Never overwrite keys the payload already owns (a Z6 handoff carries its
339
+ // own state hashes); only plain handoffs get the memory-level envelope.
340
+ if (handoff === null || typeof handoff !== 'object' || Array.isArray(handoff)) {
341
+ throw new Error('Handoff must be a plain object');
342
+ }
343
+ const payload = JSON.stringify({ ...handoff });
344
+ const payloadHash = hash(payload);
345
+ const record = { ...handoff, timestamp: Date.now() };
346
+ if (record.hash === undefined) record.hash = payloadHash;
347
+ if (record.selfHash === undefined) record.selfHash = selfHash(payload, payloadHash);
348
+ session.handoff = record;
349
+ session.stateVector[Z6_STATE.STEP] = handoff.step || 0;
350
+ this.saveSession(session);
351
+ }
352
+
353
+ /**
354
+ * Get handoff state (verifies stored hash; throws on tampering)
355
+ */
356
+ getHandoff(sessionId) {
357
+ const session = this.getOrCreateSession(sessionId);
358
+ const stored = session.handoff;
359
+ if (!stored) return null;
360
+ const envelopePayload = JSON.stringify(stripIntegrityKeys(stored));
361
+ const envelopeHash = hash(envelopePayload);
362
+ let ok = stored.hash === envelopeHash && stored.selfHash === selfHash(envelopePayload, envelopeHash);
363
+ if (!ok && stored.state && typeof stored.state === 'object') {
364
+ // Self-hashed shape (Z6P handoff): hash covers the state sub-object.
365
+ const stateJson = JSON.stringify(stored.state);
366
+ const stateHash = hash(stateJson);
367
+ ok = stored.hash === stateHash &&
368
+ stored.selfHash === crypto.createHash('sha256').update(stateJson + stateHash).digest('hex');
369
+ }
370
+ if (!ok) throw new Error('Handoff corrupted: hash mismatch');
371
+ return stored;
372
+ }
373
+
374
+ /**
375
+ * Set plan (Z6Plan serialized)
376
+ */
377
+ setPlan(sessionId, plan) {
378
+ const session = this.getOrCreateSession(sessionId);
379
+ session.plan = plan;
380
+ this.saveSession(session);
381
+ }
382
+
383
+ /**
384
+ * Get plan
385
+ */
386
+ getPlan(sessionId) {
387
+ const session = this.getOrCreateSession(sessionId);
388
+ return session.plan;
389
+ }
390
+
391
+ /**
392
+ * Update state vector
393
+ */
394
+ updateStateVector(sessionId, updates) {
395
+ const session = this.getOrCreateSession(sessionId);
396
+ for (const [key, value] of Object.entries(updates)) {
397
+ if (Z6_STATE[key] !== undefined) {
398
+ // Z6 modulo: ensure result is in [0, 5] even for negative values
399
+ session.stateVector[Z6_STATE[key]] = ((value % 6) + 6) % 6;
400
+ }
401
+ }
402
+ this.saveSession(session);
403
+ }
404
+
405
+ /**
406
+ * Get state vector
407
+ */
408
+ getStateVector(sessionId) {
409
+ const session = this.getOrCreateSession(sessionId);
410
+ return [...session.stateVector];
411
+ }
412
+
413
+ /**
414
+ * List all sessions
415
+ */
416
+ listSessions() {
417
+ const { sessions: SESSIONS_DIR } = dirs();
418
+ const sessions = [];
419
+ if (fs.existsSync(SESSIONS_DIR)) {
420
+ const dirs = fs.readdirSync(SESSIONS_DIR);
421
+ for (const dir of dirs) {
422
+ const metaPath = path.join(SESSIONS_DIR, dir, 'meta.json');
423
+ if (fs.existsSync(metaPath)) {
424
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
425
+ sessions.push({
426
+ id: meta.id,
427
+ createdAt: meta.createdAt,
428
+ updatedAt: meta.updatedAt,
429
+ messageCount: meta.messages?.length || 0,
430
+ stateVector: meta.stateVector,
431
+ hasPlan: !!meta.plan,
432
+ hasHandoff: !!meta.handoff
433
+ });
434
+ }
435
+ }
436
+ }
437
+ return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
438
+ }
439
+
440
+ /**
441
+ * Get session summary (without full messages)
442
+ */
443
+ getSessionSummary(sessionId) {
444
+ const session = this.getOrCreateSession(sessionId);
445
+ return {
446
+ id: session.id,
447
+ createdAt: session.createdAt,
448
+ updatedAt: session.updatedAt,
449
+ messageCount: session.messages.length,
450
+ stateVector: session.stateVector,
451
+ hasPlan: !!session.plan,
452
+ hasHandoff: !!session.handoff,
453
+ hash: session.computeHash(),
454
+ selfHash: session.computeSelfHash()
455
+ };
456
+ }
457
+
458
+ /**
459
+ * Log state transition to audit trail
460
+ */
461
+ logTransition(session) {
462
+ const { audit: AUDIT_DIR } = dirs();
463
+ if (!fs.existsSync(AUDIT_DIR)) fs.mkdirSync(AUDIT_DIR, { recursive: true });
464
+ const date = new Date().toISOString().split('T')[0];
465
+ const auditFile = path.join(AUDIT_DIR, `${date}.log`);
466
+
467
+ const entry = {
468
+ timestamp: Date.now(),
469
+ sessionId: session.id,
470
+ stateVector: session.stateVector,
471
+ hash: session.computeHash(),
472
+ selfHash: session.computeSelfHash()
473
+ };
474
+
475
+ fs.appendFileSync(auditFile, JSON.stringify(entry) + '\n', 'utf8');
476
+ }
477
+
478
+ /**
479
+ * Delete a session
480
+ */
481
+ deleteSession(sessionId) {
482
+ const { sessions: SESSIONS_DIR } = dirs();
483
+ const sessionDir = path.join(SESSIONS_DIR, sessionId);
484
+ if (fs.existsSync(sessionDir)) {
485
+ fs.rmSync(sessionDir, { recursive: true, force: true });
486
+ this.sessions.delete(sessionId);
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Cleanup old sessions (older than days)
492
+ */
493
+ cleanup(maxAgeDays = 7) {
494
+ const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
495
+ const sessions = this.listSessions();
496
+ let deleted = 0;
497
+
498
+ for (const session of sessions) {
499
+ if (session.updatedAt < cutoff) {
500
+ this.deleteSession(session.id);
501
+ deleted++;
502
+ }
503
+ }
504
+
505
+ return deleted;
506
+ }
507
+
508
+ /**
509
+ * Export session for inspection
510
+ */
511
+ exportSession(sessionId) {
512
+ const session = this.getOrCreateSession(sessionId);
513
+ return session.serialize();
514
+ }
515
+
516
+ /**
517
+ * Verify session integrity from DISK (not the in-memory copy) so tampering
518
+ * with meta.json is actually detected. Tautological in-memory checks are useless.
519
+ */
520
+ verifySession(sessionId) {
521
+ const { sessions: SESSIONS_DIR } = dirs();
522
+ const sessionDir = path.join(SESSIONS_DIR, sessionId);
523
+ const metaPath = path.join(sessionDir, 'meta.json');
524
+ if (!fs.existsSync(metaPath)) {
525
+ return { valid: false, reason: 'not_found' };
526
+ }
527
+ let data;
528
+ try {
529
+ data = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
530
+ } catch (e) {
531
+ return { valid: false, reason: 'parse_error' };
532
+ }
533
+ let expected;
534
+ try {
535
+ expected = verifyStoredState(data, 'Session');
536
+ } catch (e) {
537
+ return { valid: false, reason: 'hash_mismatch' };
538
+ }
539
+ const messagesDir = path.join(sessionDir, 'messages');
540
+ let messageCount = 0;
541
+ if (fs.existsSync(messagesDir)) {
542
+ try { messageCount = fs.readdirSync(messagesDir).length; }
543
+ catch (e) { messageCount = 0; }
544
+ }
545
+ return {
546
+ valid: true,
547
+ reason: null,
548
+ hash: expected.hash,
549
+ selfHash: expected.selfHash,
550
+ messageCount: messageCount,
551
+ stateVector: data.stateVector || [0, 0, 0, 0, 0, 0]
552
+ };
553
+ }
554
+ }
555
+
556
+ // Singleton instance
557
+ let instance = null;
558
+
559
+ function getMemoryManager() {
560
+ if (!instance) {
561
+ instance = new LocalMemoryManager();
562
+ }
563
+ return instance;
564
+ }
565
+
566
+ // CLI interface
567
+ if (require.main === module) {
568
+ const cmd = process.argv[2];
569
+ const memory = getMemoryManager();
570
+
571
+ if (cmd === 'list') {
572
+ const sessions = memory.listSessions();
573
+ console.log('Sessions:');
574
+ for (const s of sessions) {
575
+ console.log(` ${s.id} - ${new Date(s.updatedAt).toLocaleString()} (${s.messageCount} msgs)`);
576
+ }
577
+ } else if (cmd === 'show' && process.argv[3]) {
578
+ const summary = memory.getSessionSummary(process.argv[3]);
579
+ console.log(JSON.stringify(summary, null, 2));
580
+ } else if (cmd === 'export' && process.argv[3]) {
581
+ const data = memory.exportSession(process.argv[3]);
582
+ console.log(JSON.stringify(data, null, 2));
583
+ } else if (cmd === 'verify' && process.argv[3]) {
584
+ const result = memory.verifySession(process.argv[3]);
585
+ console.log(result.valid ? '✓ Valid' : '✗ Invalid', result);
586
+ } else if (cmd === 'cleanup') {
587
+ const days = parseInt(process.argv[3]) || 7;
588
+ const deleted = memory.cleanup(days);
589
+ console.log(`Deleted ${deleted} sessions older than ${days} days`);
590
+ } else if (cmd === 'handoff' && process.argv[3]) {
591
+ const handoff = memory.getHandoff(process.argv[3]);
592
+ console.log(handoff ? JSON.stringify(handoff, null, 2) : 'No handoff state');
593
+ } else {
594
+ console.log('Usage: node local-memory.js <command> [args]');
595
+ console.log('Commands:');
596
+ console.log(' list - List all sessions');
597
+ console.log(' show <session-id> - Show session summary');
598
+ console.log(' export <session-id> - Export full session');
599
+ console.log(' verify <session-id> - Verify session integrity');
600
+ console.log(' cleanup [days] - Delete sessions older than N days (default: 7)');
601
+ console.log(' handoff <session-id> - Show handoff state');
602
+ }
603
+ }
604
+
605
+ module.exports = {
606
+ getMemoryManager,
607
+ LocalMemoryManager,
608
+ Session,
609
+ Message,
610
+ Z6_STATE,
611
+ COMPRESSION_THRESHOLD,
612
+ MAX_MESSAGES
613
+ };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+ const http = require('http');
3
+ const https = require('https');
4
+
5
+ function base(value = process.env.OLLAMA_URL || 'http://localhost:11434') {
6
+ const url = new URL(value);
7
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
8
+ throw Error('OLLAMA_URL must be an HTTP(S) base URL without credentials, query or fragment.');
9
+ return url.href.replace(/\/+$/, '');
10
+ }
11
+
12
+ // No subprocess or shell; bound both response bytes and total elapsed time.
13
+ function discover(options = {}) {
14
+ return new Promise(resolve => {
15
+ let target;
16
+ try { target = new URL(base(options.url) + '/api/tags'); }
17
+ catch { resolve({ok: false, models: [], reason: 'invalid-url'}); return; }
18
+ let settled = false, request, timer;
19
+ const finish = result => {
20
+ if (settled) return;
21
+ settled = true;
22
+ clearTimeout(timer);
23
+ resolve(result);
24
+ };
25
+ const fail = reason => finish({ok: false, models: [], reason});
26
+ timer = setTimeout(() => { fail('timeout'); request?.destroy(); }, options.timeoutMs || 2000);
27
+ try {
28
+ request = (target.protocol === 'https:' ? https : http).get(target, response => {
29
+ if (response.statusCode !== 200) { fail('http-' + response.statusCode); response.destroy(); return; }
30
+ const chunks = []; let size = 0;
31
+ response.on('data', chunk => {
32
+ size += chunk.length;
33
+ if (size > 1024 * 1024) { fail('response-too-large'); response.destroy(); return; }
34
+ chunks.push(chunk);
35
+ });
36
+ response.on('error', () => fail('connection'));
37
+ response.on('end', () => {
38
+ if (settled) return;
39
+ try {
40
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
41
+ if (!Array.isArray(data.models)) { fail('invalid-response'); return; }
42
+ const models = [...new Set(data.models.map(m => m && (m.name || m.model))
43
+ .filter(name => typeof name === 'string' && name.length <= 200 && !/[\s\x00-\x1f]/.test(name)))].slice(0, 500);
44
+ finish({ok: true, models, reason: models.length ? null : 'no-models'});
45
+ } catch { fail('invalid-response'); }
46
+ });
47
+ });
48
+ request.on('error', () => fail('connection'));
49
+ } catch { fail('connection'); }
50
+ });
51
+ }
52
+ module.exports = {base, discover};