memhouse 0.18.4 → 0.18.6
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/memhouse.js +156 -4
- package/memhouse/convert.js +75 -0
- package/memhouse/delivery/UPGRADE.md.tpl +67 -0
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/envfile.js +8 -1
- package/memhouse/flags.js +2 -2
- package/memhouse/host.js +55 -2
- package/memhouse/server/queries.js +42 -11
- package/memhouse/server/server.js +2 -0
- package/memhouse/shipper/ship.js +23 -2
- package/package.json +1 -1
package/bin/memhouse.js
CHANGED
|
@@ -453,6 +453,8 @@ Data ship one incremental pass (--full | --loop [sec])
|
|
|
453
453
|
instance which memhouse this is: home, binary, house, rooms, scope, daemons, bound playbooks
|
|
454
454
|
rooms what your rooms are actually called (--json)
|
|
455
455
|
members who is in a house and what each can reach (--db, admin)
|
|
456
|
+
convert operator: move a pre-one-layout house (a database per member) to the one layout
|
|
457
|
+
(--admin-user/--admin-password, --member, --print-sql, --dry-run, --guides --out DIR)
|
|
456
458
|
start | stop | shipper loop + dashboard as background daemons
|
|
457
459
|
share <user> let someone read this house (--only project=… |
|
|
458
460
|
session=… | host=… | source=… | folder=… | since=… |
|
|
@@ -1640,13 +1642,29 @@ async function cmdStatus() {
|
|
|
1640
1642
|
if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
|
|
1641
1643
|
else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
|
|
1642
1644
|
if (out.fleet && out.fleet.length) {
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1645
|
+
// A writer appears in this list because it has rows — the list is GROUP BY over the
|
|
1646
|
+
// messages, not a registry — so a machine that was re-identified (before host ids were
|
|
1647
|
+
// derived from the machine) stays here forever, and cannot be tidied away without
|
|
1648
|
+
// deleting its sessions. Retired writers are therefore folded into one line instead of
|
|
1649
|
+
// shouting a permanent warning: they are history, not a fleet to maintain. `--all`
|
|
1650
|
+
// prints every one. A writer is RETIRED when it has been silent past the cutoff; a
|
|
1651
|
+
// pre-0.10 writer that is still active keeps its warning, because that one can still bite.
|
|
1652
|
+
const RETIRED_MS = 14 * 24 * 3600 * 1000;
|
|
1653
|
+
const retired = out.fleet.filter((f) => f.ageMs !== null && f.ageMs > RETIRED_MS);
|
|
1654
|
+
const shown = flags.all === true ? out.fleet : out.fleet.filter((f) => !retired.includes(f));
|
|
1655
|
+
const live = out.fleet.filter((f) => !retired.includes(f));
|
|
1656
|
+
const badOnes = live.filter((f) => f.verdict !== 'ok');
|
|
1657
|
+
console.log((badOnes.length ? warn : ok)(`fleet: ${live.length} active writer(s)${retired.length ? `, ${retired.length} retired` : ''}`));
|
|
1658
|
+
for (const f of shown) {
|
|
1646
1659
|
const mark = f.verdict === 'legacy' ? ' ⚠ pre-0.10 — upgrade it (or revoke its mutation grants); its re-ships delete retained parses'
|
|
1647
1660
|
: f.verdict === 'outdated' ? ' ⚠ supports an older schema — refusing every pass until updated'
|
|
1648
1661
|
: f.verdict === 'stale' ? ' • stale' : '';
|
|
1649
|
-
|
|
1662
|
+
const tag = retired.includes(f) ? ' · retired' : mark;
|
|
1663
|
+
console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${tag}`);
|
|
1664
|
+
}
|
|
1665
|
+
if (retired.length && flags.all !== true) {
|
|
1666
|
+
const oldest = fleetAge(Math.max(...retired.map((f) => f.ageMs)));
|
|
1667
|
+
console.log(` ${String(`${retired.length} retired writer(s)`).padEnd(28)} ${' '.padEnd(8)} silent ${oldest} and longer — their sessions are still here: memhouse status --all`);
|
|
1650
1668
|
}
|
|
1651
1669
|
}
|
|
1652
1670
|
{
|
|
@@ -2000,6 +2018,10 @@ async function cmdDoctor() {
|
|
|
2000
2018
|
: `token capture: ${zero.map((r) => `${r.source} (${r.n} messages)`).join(', ')} ship ZERO tokens — their cost shows as $0, not as unknown`,
|
|
2001
2019
|
'that adapter does not extract usage; the messages are stored, the numbers are not');
|
|
2002
2020
|
} catch { /* a house that cannot be read is already reported above */ }
|
|
2021
|
+
try {
|
|
2022
|
+
const moved = await adoptMovedHouse(cfg, { fix: false });
|
|
2023
|
+
if (moved === 'moved') add(false, `rooms moved to mem.${physicalRoom('messages', cfg.user).replace(/_messages$/, '')}_* — this env still says MEMHOUSE_DB=${cfg.db}`, 'memhouse update (rewrites the env and restarts the shipper)');
|
|
2024
|
+
} catch { /* connection problems are reported above */ }
|
|
2003
2025
|
const sh = shipperHealth();
|
|
2004
2026
|
const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
|
|
2005
2027
|
add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
|
|
@@ -2221,6 +2243,25 @@ async function cmdUpdate() {
|
|
|
2221
2243
|
}
|
|
2222
2244
|
}
|
|
2223
2245
|
console.log(ok('files updated'));
|
|
2246
|
+
// The house may have been converted to the one layout since this machine last ran:
|
|
2247
|
+
// the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
|
|
2248
|
+
// shipper restarts, or its first pass refuses the old address forever.
|
|
2249
|
+
try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ }
|
|
2250
|
+
|
|
2251
|
+
// Bring the house to the schema the NEW code expects, before any daemon restarts.
|
|
2252
|
+
// A release that adds rooms (0.18.3's stat tables) used to land after the restart, so
|
|
2253
|
+
// the freshly started shipper failed its first passes with "has no <member>_sessions…",
|
|
2254
|
+
// `status` quoted that failure, and the dashboard served a page whose every request
|
|
2255
|
+
// 500'd — all of it self-healing eventually, all of it alarming meanwhile. Two real
|
|
2256
|
+
// members hit this within an hour of each other. The new binary owns the new schema, so
|
|
2257
|
+
// it runs it here, in the one moment the daemons are already down.
|
|
2258
|
+
{
|
|
2259
|
+
const r = spawnSync(process.execPath, [path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'), '--ensure-schema'],
|
|
2260
|
+
{ env: childEnv(resolveConfig()), encoding: 'utf-8', stdio: 'pipe', timeout: 120000 });
|
|
2261
|
+
const said = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').filter((l) => l.includes('[memhouse]')).pop();
|
|
2262
|
+
if (r.status === 0) console.log(ok(`house schema current${said ? ` — ${said.replace('[memhouse] ', '')}` : ''}`));
|
|
2263
|
+
else console.log(warn(`could not bring the house to this version's schema — the daemons start anyway; run: memhouse doctor${said ? `\n ${said}` : ''}`));
|
|
2264
|
+
}
|
|
2224
2265
|
|
|
2225
2266
|
// Refresh the Claude plugin wherever it is ALREADY installed, so `update` keeps the
|
|
2226
2267
|
// skills in lockstep with the package instead of leaving a stale `/mem:*` behind. Only
|
|
@@ -3417,6 +3458,30 @@ async function cmdWhoami() {
|
|
|
3417
3458
|
* declined. Never touches the file's password: what it shows is the user, the house and
|
|
3418
3459
|
* the server, which the guide beside the file shows too.
|
|
3419
3460
|
*/
|
|
3461
|
+
/**
|
|
3462
|
+
* After the operator converted the house to the one layout, a member's env still says
|
|
3463
|
+
* MEMHOUSE_DB=<their name>. Their rooms now live in `mem` as <name>_*. Detect that and
|
|
3464
|
+
* rewrite the env: the member's `memhouse update` is the moment it happens, and doctor
|
|
3465
|
+
* names it. Returns 'moved' (env rewritten), 'current', or 'unknown' (could not tell).
|
|
3466
|
+
*/
|
|
3467
|
+
async function adoptMovedHouse(cfg, { fix = true, quiet = false } = {}) {
|
|
3468
|
+
if (!cfg.stated || !cfg.user) return 'unknown';
|
|
3469
|
+
const mine = physicalRoom('messages', cfg.user);
|
|
3470
|
+
let rows;
|
|
3471
|
+
try {
|
|
3472
|
+
rows = await chRows(cfg, `SELECT database, name FROM system.tables WHERE (database = ${sqlStr(cfg.db)} AND name IN ('messages', ${sqlStr(mine)})) OR (database = 'mem' AND name = ${sqlStr(mine)})`, { database: '' });
|
|
3473
|
+
} catch { return 'unknown'; }
|
|
3474
|
+
const has = (d, n) => rows.some((r) => r.database === d && r.name === n);
|
|
3475
|
+
if (has(cfg.db, mine)) return 'current';
|
|
3476
|
+
if (cfg.db !== 'mem' && has('mem', mine) && !has(cfg.db, 'messages')) {
|
|
3477
|
+
if (!fix) return 'moved';
|
|
3478
|
+
writeEnvFile({ ...cfg, db: 'mem' });
|
|
3479
|
+
if (!quiet) console.log(ok(`your rooms moved: ${cfg.db}.messages is now mem.${mine} — ${short(ENV_FILE)} now says MEMHOUSE_DB=mem`));
|
|
3480
|
+
return 'moved';
|
|
3481
|
+
}
|
|
3482
|
+
return 'current';
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3420
3485
|
async function offerInvite() {
|
|
3421
3486
|
const inv = require(path.join(REPO_ROOT, 'memhouse', 'invitefile'));
|
|
3422
3487
|
// The current directory only. The guide says "from the directory holding the file", and
|
|
@@ -4014,6 +4079,93 @@ async function cmdUninstall() {
|
|
|
4014
4079
|
console.log(r.sessions_v);
|
|
4015
4080
|
break;
|
|
4016
4081
|
}
|
|
4082
|
+
case 'convert': {
|
|
4083
|
+
// Operator: move a pre-one-layout house (a database per member) to the one layout.
|
|
4084
|
+
// Discovers members by their databases, plans per member, executes as admin, records
|
|
4085
|
+
// an event in each member's new events room. --print-sql shows the plan; --dry-run
|
|
4086
|
+
// discovers and plans without touching anything; --guides <dir> renders
|
|
4087
|
+
// MEMHOUSE-UPGRADE.md per member for the operator to hand out.
|
|
4088
|
+
const convert = require(path.join(REPO_ROOT, 'memhouse', 'convert'));
|
|
4089
|
+
const db = flags.db && flags.db !== true ? String(flags.db) : 'mem';
|
|
4090
|
+
const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
|
|
4091
|
+
const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
|
|
4092
|
+
if (!au) { console.log(bad('convert needs an admin credential: --admin-user/--admin-password (or the one this instance keeps)')); process.exitCode = 1; break; }
|
|
4093
|
+
const who = { ...cfg, user: au, password: ap };
|
|
4094
|
+
const only = flags.member && flags.member !== true ? String(flags.member) : null;
|
|
4095
|
+
// Candidates: a user whose own database holds `messages` and who has no rooms in `db` yet.
|
|
4096
|
+
const cands = await chRows(who, `SELECT u.name AS member FROM system.users u WHERE u.name IN (SELECT database FROM system.tables WHERE name = 'messages') AND u.name NOT IN (SELECT replaceRegexpOne(name, '_messages$', '') FROM system.tables WHERE database = ${sqlStr(db)} AND name LIKE '%\\_messages') ORDER BY u.name`, { database: '' });
|
|
4097
|
+
const members = cands.map((r) => r.member).filter((m) => !only || m === only);
|
|
4098
|
+
if (!members.length) { console.log(ok(only ? `'${only}' has nothing to convert` : 'nothing to convert — no member still owns a database of their own')); break; }
|
|
4099
|
+
console.log(ok(`${members.length} member(s) to convert into '${db}': ${members.join(', ')}`));
|
|
4100
|
+
const plans = [];
|
|
4101
|
+
for (const m of members) {
|
|
4102
|
+
const shares = (await chRows(who, `SELECT DISTINCT user_name FROM system.grants WHERE access_type = 'SELECT' AND database = ${sqlStr(m)} AND (table = '' OR table IS NULL) AND user_name != ${sqlStr(m)}`, { database: '' })).map((r) => ({ reader: r.user_name }));
|
|
4103
|
+
const policies = (await chRows(who, `SELECT name, table, select_filter AS filter, apply_to_list AS readers FROM system.row_policies WHERE database = ${sqlStr(m)}`, { database: '' })).map((r) => ({ name: r.name, table: r.table, filter: r.filter, readers: Array.isArray(r.readers) ? r.readers : [] }));
|
|
4104
|
+
plans.push({ member: m, shares, policies, steps: convert.planMember({ member: m, db, shares, policies }) });
|
|
4105
|
+
}
|
|
4106
|
+
if (flags['print-sql'] === true || flags['dry-run'] === true) {
|
|
4107
|
+
console.log(`CREATE DATABASE IF NOT EXISTS ${db};`);
|
|
4108
|
+
for (const p of plans) console.log(`\n-- ${p.member}: ${p.shares.length} share(s), ${p.policies.length} row polic${p.policies.length === 1 ? 'y' : 'ies'}\n${convert.render(p.steps)}`);
|
|
4109
|
+
if (flags['dry-run'] === true) console.log(`\n${warn('dry run — nothing executed')}`);
|
|
4110
|
+
break;
|
|
4111
|
+
}
|
|
4112
|
+
if (flags.yes !== true && process.stdin.isTTY) {
|
|
4113
|
+
const a = (await ask(`Convert ${members.length} member(s) now? Renames are instant and reversible only by renaming back. (yes/no)`, 'no')).toLowerCase();
|
|
4114
|
+
if (a !== 'yes' && a !== 'y') { console.log(' nothing done.'); break; }
|
|
4115
|
+
}
|
|
4116
|
+
await ch(who, `CREATE DATABASE IF NOT EXISTS ${db}`, { database: '' });
|
|
4117
|
+
let converted = 0;
|
|
4118
|
+
for (const p of plans) {
|
|
4119
|
+
let failed = null;
|
|
4120
|
+
for (const st of p.steps) {
|
|
4121
|
+
if (st.when === 'empty') {
|
|
4122
|
+
const left = await chRows(who, `SELECT count() AS n FROM system.tables WHERE database = ${sqlStr(p.member)}`, { database: '' });
|
|
4123
|
+
if (Number(left[0] && left[0].n) !== 0) { console.log(warn(` ${p.member}: database kept — ${left[0].n} unexpected table(s) remain in it`)); continue; }
|
|
4124
|
+
}
|
|
4125
|
+
try { await ch(who, st.sql, { database: '' }); }
|
|
4126
|
+
catch (e) {
|
|
4127
|
+
const m = (e && e.message ? e.message : String(e)).split('\n')[0];
|
|
4128
|
+
if (st.optional) { console.log(warn(` ${p.member}: skipped — ${st.why}: ${m}`)); continue; }
|
|
4129
|
+
failed = `${st.why}: ${m}`; break;
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
if (failed) { console.log(bad(`${p.member}: stopped — ${failed}`)); console.log(' what already ran stays (renames are visible in system.tables); fix and re-run: memhouse convert --member ' + p.member); process.exitCode = 1; continue; }
|
|
4133
|
+
try {
|
|
4134
|
+
const ev = physicalRoom('events', p.member);
|
|
4135
|
+
const cols = new Set((await chRows(who, `SELECT name FROM system.columns WHERE database = ${sqlStr(db)} AND table = ${sqlStr(ev)} AND default_kind != 'MATERIALIZED'`, { database: '' })).map((r) => r.name)); // actor is MATERIALIZED currentUser()
|
|
4136
|
+
const want = { event_at: 'now64(3)', kind: "'layout'", id: "'one-layout'", status: "'converted'", from_version: "'db-per-member'", to_version: "'one-layout'", actor: sqlStr(au), host: sqlStr(os.hostname()), rows_before: '0', rows_after: '0', detail: sqlStr(`renamed ${p.member}.* → ${db}.${p.member}_*; ${p.shares.length} share(s), ${p.policies.length} row polic(y/ies) carried`) };
|
|
4137
|
+
const use = Object.keys(want).filter((c) => cols.has(c));
|
|
4138
|
+
if (use.length) await ch(who, `INSERT INTO ${db}.${ev} (${use.join(', ')}) VALUES (${use.map((c) => want[c]).join(', ')})`, { database: '' });
|
|
4139
|
+
else console.log(warn(` ${p.member}: events room has none of the expected columns — no event recorded`));
|
|
4140
|
+
} catch (e) { console.log(warn(` ${p.member}: event not recorded — ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 120)}`)); }
|
|
4141
|
+
// The rooms moved, but the house also needs what THIS version adds — the
|
|
4142
|
+
// per-member stat tables the dashboard reads. `ensureSchema` makes them, and a
|
|
4143
|
+
// plain `ship` never calls it, so leaving them to the member meant a dashboard
|
|
4144
|
+
// that scanned millions of rows (or 500'd) until someone happened to run
|
|
4145
|
+
// `ship --ensure-schema`. Create them here, as the admin, while we are already
|
|
4146
|
+
// holding the member's rooms in mind.
|
|
4147
|
+
try {
|
|
4148
|
+
const st = convert.statTables(db, p.member);
|
|
4149
|
+
for (const sql of st) await ch(who, sql, { database: '' });
|
|
4150
|
+
if (st.length) console.log(ok(` ${p.member}: dashboard tables created (${st.length})`));
|
|
4151
|
+
} catch (e) { console.log(warn(` ${p.member}: dashboard tables not created — their first ship will do it: ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 90)}`)); }
|
|
4152
|
+
converted++;
|
|
4153
|
+
console.log(ok(`${p.member}: rooms now ${db}.${p.member}_* — ${p.shares.length} share(s) carried${p.policies.length ? `, ${p.policies.length} row polic${p.policies.length === 1 ? 'y' : 'ies'}` : ''}`));
|
|
4154
|
+
}
|
|
4155
|
+
console.log(ok(`${converted}/${members.length} converted`));
|
|
4156
|
+
if (flags.guides) {
|
|
4157
|
+
const outDir = flags.out && flags.out !== true ? String(flags.out) : process.cwd();
|
|
4158
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
4159
|
+
const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'delivery', 'UPGRADE.md.tpl'), 'utf-8');
|
|
4160
|
+
for (const p of plans) {
|
|
4161
|
+
const rooms = [...ROOM_TYPES, ...META_TYPES].map((t) => `${db}.${physicalRoom(t, p.member)}`).join('\n');
|
|
4162
|
+
const md = tpl.replaceAll('{{NAME}}', p.member).replaceAll('{{URL}}', cfg.url).replaceAll('{{DB}}', db).replaceAll('{{ROOMS}}', rooms)
|
|
4163
|
+
.replaceAll('{{CHANNEL}}', cfg.channel || 'latest').replaceAll('{{OPERATOR}}', os.userInfo().username).replaceAll('{{VERSION}}', PKG.version).replaceAll('{{DATE}}', new Date().toISOString().slice(0, 10));
|
|
4164
|
+
const f = path.join(outDir, `MEMHOUSE-UPGRADE-${p.member}.md`); fs.writeFileSync(f, md); console.log(ok(`guide written: ${short(f)}`));
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
break;
|
|
4168
|
+
}
|
|
4017
4169
|
case 'members': {
|
|
4018
4170
|
// "Who is in this database, and what can each of them reach?" Before shared houses a
|
|
4019
4171
|
// database had exactly one member and the question did not exist; this layout creates
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Convert a pre-one-layout house to the one layout — the plan, as SQL, pure.
|
|
2
|
+
//
|
|
3
|
+
// Before 0.18 every member owned a DATABASE named for them: `polat.messages`,
|
|
4
|
+
// `polat.house_meta`, granted ALL ON polat.*. The one layout puts everyone in one database
|
|
5
|
+
// as `mem.polat_messages`, `mem.polat_meta`, with one wildcard grant and nothing on the
|
|
6
|
+
// database itself. Moving is a RENAME per table — metadata only, nothing copied — plus the
|
|
7
|
+
// grant swap, and shares re-expressed on the new names. The 0.17.1 dashboard views are
|
|
8
|
+
// dropped: in the one layout the shipper fills the stat tables (a refreshable view needs
|
|
9
|
+
// CREATE TABLE on the whole database, the grant this layout withholds).
|
|
10
|
+
//
|
|
11
|
+
// The operator runs it once, as an admin. Members then `memhouse update`; the new build
|
|
12
|
+
// finds the moved rooms and repairs its own env (see adoptMovedHouse in the CLI and shipper).
|
|
13
|
+
|
|
14
|
+
const { roomPattern, physicalRoom, ROOM_TYPES, META_TYPES, MEMBER_PIN } = require('./house/house');
|
|
15
|
+
const { roomGrant } = require('./provision');
|
|
16
|
+
|
|
17
|
+
const LEGACY = { messages: 'messages', sessions: 'sessions', tool_calls: 'tool_calls', meta: 'house_meta', events: 'house_events' };
|
|
18
|
+
const STAT_VIEWS = ['session_stats_mv', 'session_model_stats_mv', 'session_tool_stats_mv'];
|
|
19
|
+
const STAT_TABLES = ['session_stats', 'session_model_stats', 'session_tool_stats'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} p
|
|
23
|
+
* @param {string} p.member the member (= the old database name)
|
|
24
|
+
* @param {string} [p.db='mem'] the one-layout house
|
|
25
|
+
* @param {Array<{reader:string}>} [p.shares] whole-house reads granted to others (SELECT ON member.*)
|
|
26
|
+
* @param {Array<{name:string, table:string, filter:string, readers:string[]}>} [p.policies] row policies on the old tables
|
|
27
|
+
* @returns {Array<{sql:string, why:string, optional?:boolean, when?:string}>}
|
|
28
|
+
*/
|
|
29
|
+
function planMember({ member, db = 'mem', shares = [], policies = [] }) {
|
|
30
|
+
const steps = [];
|
|
31
|
+
for (const v of STAT_VIEWS) steps.push({ sql: `DROP TABLE IF EXISTS ${member}.${v}`, why: '0.17.1 refreshable view — the shipper fills stats in the one layout' });
|
|
32
|
+
for (const t of STAT_TABLES) steps.push({ sql: `DROP TABLE IF EXISTS ${member}.${t}`, why: '0.17.1 stat table — recreated by the member\'s next ship in the new shape' });
|
|
33
|
+
const pairs = [...ROOM_TYPES, ...META_TYPES].map((t) => `${member}.${LEGACY[t]} TO ${db}.${physicalRoom(t, member)}`);
|
|
34
|
+
steps.push({ sql: `RENAME TABLE ${pairs.join(', ')}`, why: 'the five rooms move into the house under the member\'s name — one atomic rename, nothing copied' });
|
|
35
|
+
steps.push({ sql: `REVOKE ALL ON ${member}.* FROM ${member}`, why: 'the database-wide grant the old layout needed' });
|
|
36
|
+
steps.push({ sql: roomGrant(db, member), why: `their rooms — ${db}.${roomPattern(member)} — and nothing else` });
|
|
37
|
+
steps.push({ sql: `ALTER USER ${member} ADD SETTING ${MEMBER_PIN}`, why: 'user_id is stamped per row; async inserts would skip it', optional: true });
|
|
38
|
+
steps.push({ sql: `GRANT ALTER USER ON ${member} TO ${member}`, why: '`memhouse passwd` without an admin', optional: true });
|
|
39
|
+
steps.push({ sql: `GRANT SHOW USERS ON *.* TO ${member}`, why: 'names only, to know who to share with', optional: true });
|
|
40
|
+
steps.push({ sql: `GRANT REMOTE ON *.* TO ${member}`, why: '`memhouse relocate`', optional: true });
|
|
41
|
+
for (const s of shares) {
|
|
42
|
+
steps.push({ sql: `REVOKE SELECT ON ${member}.* FROM ${s.reader}`, why: `${s.reader}'s read of the old database`, optional: true });
|
|
43
|
+
steps.push({ sql: `GRANT SELECT ON ${db}.${roomPattern(member)} TO ${s.reader}`, why: `${s.reader} keeps reading ${member}'s rooms`, optional: true });
|
|
44
|
+
}
|
|
45
|
+
for (const p of policies) {
|
|
46
|
+
const type = Object.keys(LEGACY).find((k) => LEGACY[k] === p.table) || null;
|
|
47
|
+
if (!type) continue;
|
|
48
|
+
const readers = p.readers.length ? p.readers.join(', ') : 'ALL';
|
|
49
|
+
steps.push({ sql: `CREATE ROW POLICY OR REPLACE ${p.name} ON ${db}.${physicalRoom(type, member)} FOR SELECT USING ${p.filter} TO ${readers}`, why: `row policy '${p.name}' follows the room`, optional: true });
|
|
50
|
+
steps.push({ sql: `DROP ROW POLICY IF EXISTS ${p.name} ON ${member}.${p.table}`, why: 'its copy on the old name', optional: true });
|
|
51
|
+
}
|
|
52
|
+
steps.push({ sql: `DROP DATABASE IF EXISTS ${member}`, why: 'the emptied database', when: 'empty' });
|
|
53
|
+
return steps;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function render(steps) {
|
|
57
|
+
return steps.map((s) => `-- ${s.why}${s.optional ? ' (optional)' : ''}${s.when ? ` (only when ${s.when})` : ''}\n${s.sql};`).join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The CREATE statements for a member's dashboard tables, in the one layout. `convert` runs
|
|
62
|
+
* these so a converted house is complete the moment it is converted — the alternative was
|
|
63
|
+
* a dashboard with nothing precomputed until someone ran `ship --ensure-schema` by hand.
|
|
64
|
+
* Same source of truth the shipper uses, so the two cannot drift.
|
|
65
|
+
*/
|
|
66
|
+
function statTables(db, member) {
|
|
67
|
+
const house = require('./house/house');
|
|
68
|
+
// statCreateStatements builds unqualified names (the member runs it inside their own
|
|
69
|
+
// house); convert runs as the admin with no default database, so qualify every name.
|
|
70
|
+
return house.statCreateStatements(house.roomNames(member))
|
|
71
|
+
.map((sql) => sql.replace(/\b(CREATE TABLE IF NOT EXISTS |FROM )(?!\()/g, (m0) => m0)
|
|
72
|
+
.replace(new RegExp(`\\b(${member}_(?:session_stats|session_model_stats|session_tool_stats|sessions|messages|tool_calls|meta|events))\\b`, 'g'), `${db}.$1`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { planMember, render, statTables, LEGACY, STAT_VIEWS, STAT_TABLES };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Your memhouse moved — one command brings you along
|
|
2
|
+
|
|
3
|
+
{{OPERATOR}} upgraded the house at `{{URL}}` to memhouse's **one layout**. Your memory is
|
|
4
|
+
all still there; only its address changed. Where you had a database of your own
|
|
5
|
+
(`{{NAME}}.messages`), you now have rooms named for you inside the shared house:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
{{ROOMS}}
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Nothing was copied or deleted — the tables were renamed in place. Your shares were carried
|
|
12
|
+
over by the operator. Until you upgrade, your shipper stops adding new sessions (it refuses
|
|
13
|
+
the old address rather than writing somewhere wrong); nothing is lost meanwhile.
|
|
14
|
+
|
|
15
|
+
Do it yourself, or hand this page to your coding agent, from any directory:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
claude "Read MEMHOUSE-UPGRADE.md and follow it. Ask me before each step that changes this machine."
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 1. Upgrade
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
memhouse update
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
That installs the new build from the channel you are on ({{CHANNEL}}), notices that your
|
|
28
|
+
rooms moved, rewrites `~/.memhouse/env` to point at house `{{DB}}`, refreshes the `/mem`
|
|
29
|
+
skills in your Claude config, and restarts the shipper. If `memhouse` is not installed on
|
|
30
|
+
this machine at all: `npm install -g memhouse@{{CHANNEL}}`, then `memhouse status`.
|
|
31
|
+
|
|
32
|
+
## 2. Check
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
memhouse instance
|
|
36
|
+
memhouse status
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`instance` should say house `{{DB}}`, member `{{NAME}}`, rooms `{{DB}}.{{NAME}}_*`, shipper
|
|
40
|
+
running. `status` shows your session count — the same as before — and, after a few
|
|
41
|
+
minutes, a fresh pass.
|
|
42
|
+
|
|
43
|
+
## 3. What is different
|
|
44
|
+
|
|
45
|
+
- **Reading a housemate.** In `/mem:recall`, a friend's memory is `{{DB}}.<friend>_messages`
|
|
46
|
+
— no database switch. `/mem:house` lists who can read yours.
|
|
47
|
+
- **The dashboard** (`memhouse start`, then the printed URL) is faster: the tables it reads
|
|
48
|
+
are now filled by your own shipper at the end of each pass.
|
|
49
|
+
- **Nothing to re-share.** Grants you gave or received were re-expressed on the new names.
|
|
50
|
+
|
|
51
|
+
## If something looks wrong
|
|
52
|
+
|
|
53
|
+
- `memhouse status` says *"last pass FAILED"* → `memhouse stop && memhouse start`.
|
|
54
|
+
- `memhouse update` says it is *already current* but `instance` still shows house `{{NAME}}`
|
|
55
|
+
→ run `memhouse doctor`; it names the fix.
|
|
56
|
+
- Anything else: send {{OPERATOR}} the output of `memhouse doctor` — it prints no secrets.
|
|
57
|
+
|
|
58
|
+
## For an agent following this page
|
|
59
|
+
|
|
60
|
+
- Never print `~/.memhouse/env` or any `MEMHOUSE_*` value; the transcript you are in is
|
|
61
|
+
itself shipped into this house.
|
|
62
|
+
- Run exactly the commands above. Do not create or drop tables, do not edit the env by
|
|
63
|
+
hand — `memhouse update` does the rewrite, and `memhouse doctor` names any remaining fix.
|
|
64
|
+
- If `memhouse update` needs `sudo` (an `EACCES` from npm), that is the one place it is fine.
|
|
65
|
+
- Stop and show the person the output when anything says ✗.
|
|
66
|
+
|
|
67
|
+
memhouse {{VERSION}} · generated {{DATE}}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mem",
|
|
3
3
|
"description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: recall past sessions and answer from them, run analytics, check the house, bring people in, and administer the server. Skills install as /mem:recall, /mem:sql, /mem:house, /mem:access, and /mem:admin.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.6",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
package/memhouse/envfile.js
CHANGED
|
@@ -61,4 +61,11 @@ function credentialDrift(text, current = {}) {
|
|
|
61
61
|
return { changed, values };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/** The env file text with one key set (replaced in place, or appended). Pure. */
|
|
65
|
+
function setKey(text, key, value) {
|
|
66
|
+
const line = `${key}='${String(value).replace(/'/g, "'\\''")}'`;
|
|
67
|
+
const re = new RegExp(`^${key}=.*$`, 'm');
|
|
68
|
+
return re.test(text) ? text.replace(re, line) : `${text.replace(/\n*$/, '')}\n${line}\n`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { setKey, credentialDrift, parse, quoteShell, quoteSystemd, assertSingleLine };
|
package/memhouse/flags.js
CHANGED
|
@@ -16,8 +16,8 @@ const COMMAND_FLAGS = {
|
|
|
16
16
|
null: [], help: [], version: [],
|
|
17
17
|
whoami: ['admin'],
|
|
18
18
|
share: ['only', 'revoke', 'list'],
|
|
19
|
-
discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], instance: [], members: ['db','admin-user','admin-password'],
|
|
20
|
-
status: [], start: [],
|
|
19
|
+
discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], instance: [], members: ['db','admin-user','admin-password'], convert: ['url','db','admin-user','admin-password','member','print-sql','dry-run','guides','out'],
|
|
20
|
+
status: ['all'], start: [],
|
|
21
21
|
onboard: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'target', 'house-port', 'tag', 'local'],
|
|
22
22
|
install: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'env', 'print-sql', 'member',
|
|
23
23
|
'member-password', 'admin-user', 'admin-password', 'ensure-schema', 'rotate-password', 'force',
|
package/memhouse/host.js
CHANGED
|
@@ -53,6 +53,59 @@ function read(home = homeDir()) {
|
|
|
53
53
|
return null;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* A fingerprint of THIS MACHINE, not of this install.
|
|
58
|
+
*
|
|
59
|
+
* It used to be `randomBytes(16)`, which lives only in host.json — so wiping MEMHOUSE_HOME
|
|
60
|
+
* (`uninstall --full-removal`, a reset, a new laptop image) made the same physical machine
|
|
61
|
+
* come back as a stranger. One MacBook read as three writers in the fleet list, each
|
|
62
|
+
* holding a slice of the same machine's history.
|
|
63
|
+
*
|
|
64
|
+
* Now it is derived from a stable per-machine id — IOPlatformUUID on macOS,
|
|
65
|
+
* /etc/machine-id (or the D-Bus one) on Linux, MachineGuid on Windows — hashed with a
|
|
66
|
+
* fixed salt so the raw hardware id never leaves the machine and cannot be recovered from
|
|
67
|
+
* a row. Same machine, same fingerprint, reinstall after reinstall. Where no such id can
|
|
68
|
+
* be read, it falls back to random: an identity that is merely unstable is far better than
|
|
69
|
+
* one that collides, and the fallback is recorded so callers can say which happened.
|
|
70
|
+
*
|
|
71
|
+
* @returns {string} 32 hex characters
|
|
72
|
+
*/
|
|
73
|
+
function machineFingerprint(raw = stableMachineId()) {
|
|
74
|
+
const SALT = 'memhouse/host/v1'; // versioned: changing it re-identifies every machine
|
|
75
|
+
return raw
|
|
76
|
+
? crypto.createHash('sha256').update(`${SALT}:${raw}`).digest('hex').slice(0, 32)
|
|
77
|
+
: crypto.randomBytes(16).toString('hex');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The OS's own machine id, or null when it cannot be read. Never stored, never shipped. */
|
|
81
|
+
function stableMachineId() {
|
|
82
|
+
const run = (cmd, args) => {
|
|
83
|
+
try {
|
|
84
|
+
const r = require('child_process').execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 });
|
|
85
|
+
return String(r || '').trim() || null;
|
|
86
|
+
} catch { return null; }
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
if (process.platform === 'darwin') {
|
|
90
|
+
const out = run('/usr/sbin/ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
|
|
91
|
+
const m = out && /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out);
|
|
92
|
+
return m ? m[1] : null;
|
|
93
|
+
}
|
|
94
|
+
if (process.platform === 'linux') {
|
|
95
|
+
for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
96
|
+
try { const v = fs.readFileSync(f, 'utf-8').trim(); if (v) return v; } catch { /* next */ }
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (process.platform === 'win32') {
|
|
101
|
+
const out = run('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid']);
|
|
102
|
+
const m = out && /MachineGuid\s+REG_SZ\s+(\S+)/.exec(out);
|
|
103
|
+
return m ? m[1] : null;
|
|
104
|
+
}
|
|
105
|
+
} catch { /* fall through */ }
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
56
109
|
/**
|
|
57
110
|
* The identity of this machine, creating it on first use.
|
|
58
111
|
*
|
|
@@ -69,7 +122,7 @@ function identity(home = homeDir()) {
|
|
|
69
122
|
let rec = read(home);
|
|
70
123
|
if (!rec) {
|
|
71
124
|
rec = {
|
|
72
|
-
fingerprint:
|
|
125
|
+
fingerprint: machineFingerprint(),
|
|
73
126
|
hostname,
|
|
74
127
|
platform: os.platform(),
|
|
75
128
|
arch: os.arch(),
|
|
@@ -98,4 +151,4 @@ function identity(home = homeDir()) {
|
|
|
98
151
|
};
|
|
99
152
|
}
|
|
100
153
|
|
|
101
|
-
module.exports = { identity, read, filePath, idFrom, FILE };
|
|
154
|
+
module.exports = { identity, read, filePath, idFrom, machineFingerprint, stableMachineId, FILE };
|
|
@@ -27,23 +27,54 @@ const config = {
|
|
|
27
27
|
database: process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The house may have moved under this process: the operator converts a pre-one-layout
|
|
32
|
+
* house and the member's rooms go from `<name>.*` to `mem.<name>_*`, while this server
|
|
33
|
+
* still holds the database its env named at boot. The shipper and `update` learned to
|
|
34
|
+
* adopt that; the dashboard did not, so every endpoint answered 500 behind a page that
|
|
35
|
+
* simply looked blank. Checked once at startup, cheap, and never fatal.
|
|
36
|
+
*
|
|
37
|
+
* @returns {Promise<'current'|'moved'|'unknown'>}
|
|
38
|
+
*/
|
|
39
|
+
async function adoptMovedHouse() {
|
|
40
|
+
const me = config.username;
|
|
41
|
+
if (!me || config.database === 'mem') return 'current';
|
|
42
|
+
const probe = createClient({ ...clientOptions(), database: 'system' });
|
|
43
|
+
try {
|
|
44
|
+
const rs = await probe.query({
|
|
45
|
+
query: `SELECT database, name FROM system.tables WHERE (database = {old:String} AND name IN ('messages', {mine:String})) OR (database = 'mem' AND name = {mine:String})`,
|
|
46
|
+
query_params: { old: config.database, mine: `${me}_messages` },
|
|
47
|
+
format: 'JSONEachRow',
|
|
48
|
+
});
|
|
49
|
+
const rows = await rs.json();
|
|
50
|
+
const has = (d, n) => rows.some((r) => r.database === d && r.name === n);
|
|
51
|
+
if (has(config.database, `${me}_messages`) || has(config.database, 'messages')) return 'current';
|
|
52
|
+
if (!has('mem', `${me}_messages`)) return 'unknown';
|
|
53
|
+
console.log(`[memhouse] rooms moved: ${config.database}.messages is now mem.${me}_messages — reading house 'mem'`);
|
|
54
|
+
config.database = 'mem';
|
|
55
|
+
_client = null; // rebuilt with the new database on the next call
|
|
56
|
+
return 'moved';
|
|
57
|
+
} catch { return 'unknown'; }
|
|
58
|
+
finally { try { await probe.close(); } catch { /* nothing to close */ } }
|
|
59
|
+
}
|
|
60
|
+
|
|
30
61
|
let _client = null;
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
_client = createClient({
|
|
34
|
-
// See the note in shipper/ship.js: the driver's ERROR-level dump lands ahead of every
|
|
35
|
-
// message we print, so a handled refusal reads like a crash. MEMHOUSE_DEBUG=1 restores it.
|
|
62
|
+
function clientOptions() {
|
|
63
|
+
return {
|
|
36
64
|
log: { level: process.env.MEMHOUSE_DEBUG ? ClickHouseLogLevel.DEBUG : ClickHouseLogLevel.OFF },
|
|
37
65
|
url: config.url,
|
|
38
66
|
username: config.username,
|
|
39
67
|
password: config.password,
|
|
40
68
|
database: config.database,
|
|
41
69
|
request_timeout: 60000,
|
|
42
|
-
clickhouse_settings: {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
70
|
+
clickhouse_settings: { ...READ_SETTINGS, output_format_json_quote_64bit_integers: 0 },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function getClient() {
|
|
74
|
+
if (_client) return _client;
|
|
75
|
+
// See the note in shipper/ship.js: the driver's ERROR-level dump lands ahead of every
|
|
76
|
+
// message we print, so a handled refusal reads like a crash. MEMHOUSE_DEBUG=1 restores it.
|
|
77
|
+
_client = createClient(clientOptions());
|
|
47
78
|
return _client;
|
|
48
79
|
}
|
|
49
80
|
|
|
@@ -1101,7 +1132,7 @@ async function schema() {
|
|
|
1101
1132
|
};
|
|
1102
1133
|
}
|
|
1103
1134
|
|
|
1104
|
-
module.exports = {
|
|
1135
|
+
module.exports = { adoptMovedHouse,
|
|
1105
1136
|
getClient, config,
|
|
1106
1137
|
getOverview, getDailyActivity, getDashboardStats,
|
|
1107
1138
|
getChats, countChats, getChat,
|
|
@@ -435,6 +435,8 @@ app.get('*', (req, res) => {
|
|
|
435
435
|
});
|
|
436
436
|
|
|
437
437
|
if (require.main === module) {
|
|
438
|
+
// Before serving: has the house moved under us? (see queries.adoptMovedHouse)
|
|
439
|
+
qy.adoptMovedHouse().catch(() => {});
|
|
438
440
|
app.listen(PORT, HOST, () => {
|
|
439
441
|
console.log(`[memhouse] dashboard → http://localhost:${PORT} (house '${qy.config.database}' @ ${qy.config.url.replace(/\/\/.*@/, '//')})`
|
|
440
442
|
+ (HOST !== '127.0.0.1' ? ` [bound to ${HOST} — remotely reachable]` : ''));
|
package/memhouse/shipper/ship.js
CHANGED
|
@@ -200,7 +200,7 @@ function toInt(v) {
|
|
|
200
200
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
function makeClient() {
|
|
203
|
+
function makeClient(override = {}) {
|
|
204
204
|
// No house-shaped default. `memhouse` spawns this with the resolved config in the
|
|
205
205
|
// environment, so a missing MEMHOUSE_URL here means the shipper was started some other
|
|
206
206
|
// way — a stale service unit, a hand-rolled cron, a copied command — and the old
|
|
@@ -224,7 +224,7 @@ function makeClient() {
|
|
|
224
224
|
url: process.env.MEMHOUSE_URL,
|
|
225
225
|
username: process.env.MEMHOUSE_USER,
|
|
226
226
|
password: process.env.MEMHOUSE_PASSWORD || '',
|
|
227
|
-
database: process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
|
|
227
|
+
database: override.database || process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
|
|
228
228
|
// gzip the INSERT body. Measured on a real house behind a Cloudflare tunnel: a
|
|
229
229
|
// 2000-row batch was ~27 MB of JSON and the insert spent 76s in NetworkReceive alone
|
|
230
230
|
// (index build was 0.8s, CPU 1.1s) — the upload, not ClickHouse, was the whole cost.
|
|
@@ -1310,6 +1310,27 @@ async function printStats(client) {
|
|
|
1310
1310
|
async function main() {
|
|
1311
1311
|
const argv = process.argv.slice(2);
|
|
1312
1312
|
let client = makeClient();
|
|
1313
|
+
// The operator may have converted the house to the one layout since this env was
|
|
1314
|
+
// written: the member's rooms moved from <name>.* to mem.<name>_*. Adopt that here —
|
|
1315
|
+
// rewrite the env, point this process at `mem` — instead of refusing the old address on
|
|
1316
|
+
// every pass until someone runs `memhouse update`.
|
|
1317
|
+
try {
|
|
1318
|
+
const me = process.env.MEMHOUSE_USER; const db = process.env.MEMHOUSE_DB || me;
|
|
1319
|
+
if (me && db !== 'mem') {
|
|
1320
|
+
const mine = `${me}_messages`;
|
|
1321
|
+
// Probe through `system`: after a conversion the env's database no longer exists, and a
|
|
1322
|
+
// session defaulting to it is refused before any query runs.
|
|
1323
|
+
const probe = makeClient({ database: 'system' });
|
|
1324
|
+
const rs = await probe.query({ query: `SELECT database, name FROM system.tables WHERE (database = '${db.replace(/'/g, "\\'")}' AND name IN ('messages', '${mine}')) OR (database = 'mem' AND name = '${mine}')`, format: 'JSONEachRow' });
|
|
1325
|
+
const rows = await rs.json(); const has = (d, n) => rows.some((r) => r.database === d && r.name === n);
|
|
1326
|
+
if (!has(db, mine) && !has(db, 'messages') && has('mem', mine)) {
|
|
1327
|
+
const envPath = require('path').join(process.env.MEMHOUSE_HOME || require('path').join(require('os').homedir(), '.memhouse'), 'env');
|
|
1328
|
+
try { const fs = require('fs'); fs.writeFileSync(envPath, require('../envfile').setKey(fs.readFileSync(envPath, 'utf-8'), 'MEMHOUSE_DB', 'mem')); } catch { /* env not writable: this process still adopts */ }
|
|
1329
|
+
process.env.MEMHOUSE_DB = 'mem'; client = makeClient();
|
|
1330
|
+
console.error(`[memhouse] your rooms moved: ${db}.messages is now mem.${mine} — env now says MEMHOUSE_DB=mem`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
} catch { /* unreachable or unreadable: the pass will say */ }
|
|
1313
1334
|
try {
|
|
1314
1335
|
if (argv.includes('--ensure-schema')) {
|
|
1315
1336
|
const n = await ensureSchema(client);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memhouse",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.6",
|
|
4
4
|
"description": "memhouse \u2014 agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, kernel-agency install, and the agentlytics dashboard",
|
|
5
5
|
"main": "bin/memhouse.js",
|
|
6
6
|
"bin": {
|