memhouse 0.18.4 → 0.18.5
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 +110 -0
- package/memhouse/convert.js +60 -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 +1 -1
- 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=… |
|
|
@@ -2000,6 +2002,10 @@ async function cmdDoctor() {
|
|
|
2000
2002
|
: `token capture: ${zero.map((r) => `${r.source} (${r.n} messages)`).join(', ')} ship ZERO tokens — their cost shows as $0, not as unknown`,
|
|
2001
2003
|
'that adapter does not extract usage; the messages are stored, the numbers are not');
|
|
2002
2004
|
} catch { /* a house that cannot be read is already reported above */ }
|
|
2005
|
+
try {
|
|
2006
|
+
const moved = await adoptMovedHouse(cfg, { fix: false });
|
|
2007
|
+
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)');
|
|
2008
|
+
} catch { /* connection problems are reported above */ }
|
|
2003
2009
|
const sh = shipperHealth();
|
|
2004
2010
|
const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
|
|
2005
2011
|
add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
|
|
@@ -2221,6 +2227,10 @@ async function cmdUpdate() {
|
|
|
2221
2227
|
}
|
|
2222
2228
|
}
|
|
2223
2229
|
console.log(ok('files updated'));
|
|
2230
|
+
// The house may have been converted to the one layout since this machine last ran:
|
|
2231
|
+
// the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
|
|
2232
|
+
// shipper restarts, or its first pass refuses the old address forever.
|
|
2233
|
+
try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ }
|
|
2224
2234
|
|
|
2225
2235
|
// Refresh the Claude plugin wherever it is ALREADY installed, so `update` keeps the
|
|
2226
2236
|
// skills in lockstep with the package instead of leaving a stale `/mem:*` behind. Only
|
|
@@ -3417,6 +3427,30 @@ async function cmdWhoami() {
|
|
|
3417
3427
|
* declined. Never touches the file's password: what it shows is the user, the house and
|
|
3418
3428
|
* the server, which the guide beside the file shows too.
|
|
3419
3429
|
*/
|
|
3430
|
+
/**
|
|
3431
|
+
* After the operator converted the house to the one layout, a member's env still says
|
|
3432
|
+
* MEMHOUSE_DB=<their name>. Their rooms now live in `mem` as <name>_*. Detect that and
|
|
3433
|
+
* rewrite the env: the member's `memhouse update` is the moment it happens, and doctor
|
|
3434
|
+
* names it. Returns 'moved' (env rewritten), 'current', or 'unknown' (could not tell).
|
|
3435
|
+
*/
|
|
3436
|
+
async function adoptMovedHouse(cfg, { fix = true, quiet = false } = {}) {
|
|
3437
|
+
if (!cfg.stated || !cfg.user) return 'unknown';
|
|
3438
|
+
const mine = physicalRoom('messages', cfg.user);
|
|
3439
|
+
let rows;
|
|
3440
|
+
try {
|
|
3441
|
+
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: '' });
|
|
3442
|
+
} catch { return 'unknown'; }
|
|
3443
|
+
const has = (d, n) => rows.some((r) => r.database === d && r.name === n);
|
|
3444
|
+
if (has(cfg.db, mine)) return 'current';
|
|
3445
|
+
if (cfg.db !== 'mem' && has('mem', mine) && !has(cfg.db, 'messages')) {
|
|
3446
|
+
if (!fix) return 'moved';
|
|
3447
|
+
writeEnvFile({ ...cfg, db: 'mem' });
|
|
3448
|
+
if (!quiet) console.log(ok(`your rooms moved: ${cfg.db}.messages is now mem.${mine} — ${short(ENV_FILE)} now says MEMHOUSE_DB=mem`));
|
|
3449
|
+
return 'moved';
|
|
3450
|
+
}
|
|
3451
|
+
return 'current';
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3420
3454
|
async function offerInvite() {
|
|
3421
3455
|
const inv = require(path.join(REPO_ROOT, 'memhouse', 'invitefile'));
|
|
3422
3456
|
// The current directory only. The guide says "from the directory holding the file", and
|
|
@@ -4014,6 +4048,82 @@ async function cmdUninstall() {
|
|
|
4014
4048
|
console.log(r.sessions_v);
|
|
4015
4049
|
break;
|
|
4016
4050
|
}
|
|
4051
|
+
case 'convert': {
|
|
4052
|
+
// Operator: move a pre-one-layout house (a database per member) to the one layout.
|
|
4053
|
+
// Discovers members by their databases, plans per member, executes as admin, records
|
|
4054
|
+
// an event in each member's new events room. --print-sql shows the plan; --dry-run
|
|
4055
|
+
// discovers and plans without touching anything; --guides <dir> renders
|
|
4056
|
+
// MEMHOUSE-UPGRADE.md per member for the operator to hand out.
|
|
4057
|
+
const convert = require(path.join(REPO_ROOT, 'memhouse', 'convert'));
|
|
4058
|
+
const db = flags.db && flags.db !== true ? String(flags.db) : 'mem';
|
|
4059
|
+
const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
|
|
4060
|
+
const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
|
|
4061
|
+
if (!au) { console.log(bad('convert needs an admin credential: --admin-user/--admin-password (or the one this instance keeps)')); process.exitCode = 1; break; }
|
|
4062
|
+
const who = { ...cfg, user: au, password: ap };
|
|
4063
|
+
const only = flags.member && flags.member !== true ? String(flags.member) : null;
|
|
4064
|
+
// Candidates: a user whose own database holds `messages` and who has no rooms in `db` yet.
|
|
4065
|
+
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: '' });
|
|
4066
|
+
const members = cands.map((r) => r.member).filter((m) => !only || m === only);
|
|
4067
|
+
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; }
|
|
4068
|
+
console.log(ok(`${members.length} member(s) to convert into '${db}': ${members.join(', ')}`));
|
|
4069
|
+
const plans = [];
|
|
4070
|
+
for (const m of members) {
|
|
4071
|
+
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 }));
|
|
4072
|
+
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 : [] }));
|
|
4073
|
+
plans.push({ member: m, shares, policies, steps: convert.planMember({ member: m, db, shares, policies }) });
|
|
4074
|
+
}
|
|
4075
|
+
if (flags['print-sql'] === true || flags['dry-run'] === true) {
|
|
4076
|
+
console.log(`CREATE DATABASE IF NOT EXISTS ${db};`);
|
|
4077
|
+
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)}`);
|
|
4078
|
+
if (flags['dry-run'] === true) console.log(`\n${warn('dry run — nothing executed')}`);
|
|
4079
|
+
break;
|
|
4080
|
+
}
|
|
4081
|
+
if (flags.yes !== true && process.stdin.isTTY) {
|
|
4082
|
+
const a = (await ask(`Convert ${members.length} member(s) now? Renames are instant and reversible only by renaming back. (yes/no)`, 'no')).toLowerCase();
|
|
4083
|
+
if (a !== 'yes' && a !== 'y') { console.log(' nothing done.'); break; }
|
|
4084
|
+
}
|
|
4085
|
+
await ch(who, `CREATE DATABASE IF NOT EXISTS ${db}`, { database: '' });
|
|
4086
|
+
let converted = 0;
|
|
4087
|
+
for (const p of plans) {
|
|
4088
|
+
let failed = null;
|
|
4089
|
+
for (const st of p.steps) {
|
|
4090
|
+
if (st.when === 'empty') {
|
|
4091
|
+
const left = await chRows(who, `SELECT count() AS n FROM system.tables WHERE database = ${sqlStr(p.member)}`, { database: '' });
|
|
4092
|
+
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; }
|
|
4093
|
+
}
|
|
4094
|
+
try { await ch(who, st.sql, { database: '' }); }
|
|
4095
|
+
catch (e) {
|
|
4096
|
+
const m = (e && e.message ? e.message : String(e)).split('\n')[0];
|
|
4097
|
+
if (st.optional) { console.log(warn(` ${p.member}: skipped — ${st.why}: ${m}`)); continue; }
|
|
4098
|
+
failed = `${st.why}: ${m}`; break;
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
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; }
|
|
4102
|
+
try {
|
|
4103
|
+
const ev = physicalRoom('events', p.member);
|
|
4104
|
+
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()
|
|
4105
|
+
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`) };
|
|
4106
|
+
const use = Object.keys(want).filter((c) => cols.has(c));
|
|
4107
|
+
if (use.length) await ch(who, `INSERT INTO ${db}.${ev} (${use.join(', ')}) VALUES (${use.map((c) => want[c]).join(', ')})`, { database: '' });
|
|
4108
|
+
else console.log(warn(` ${p.member}: events room has none of the expected columns — no event recorded`));
|
|
4109
|
+
} catch (e) { console.log(warn(` ${p.member}: event not recorded — ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 120)}`)); }
|
|
4110
|
+
converted++;
|
|
4111
|
+
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'}` : ''}`));
|
|
4112
|
+
}
|
|
4113
|
+
console.log(ok(`${converted}/${members.length} converted`));
|
|
4114
|
+
if (flags.guides) {
|
|
4115
|
+
const outDir = flags.out && flags.out !== true ? String(flags.out) : process.cwd();
|
|
4116
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
4117
|
+
const tpl = fs.readFileSync(path.join(REPO_ROOT, 'memhouse', 'delivery', 'UPGRADE.md.tpl'), 'utf-8');
|
|
4118
|
+
for (const p of plans) {
|
|
4119
|
+
const rooms = [...ROOM_TYPES, ...META_TYPES].map((t) => `${db}.${physicalRoom(t, p.member)}`).join('\n');
|
|
4120
|
+
const md = tpl.replaceAll('{{NAME}}', p.member).replaceAll('{{URL}}', cfg.url).replaceAll('{{DB}}', db).replaceAll('{{ROOMS}}', rooms)
|
|
4121
|
+
.replaceAll('{{CHANNEL}}', cfg.channel || 'latest').replaceAll('{{OPERATOR}}', os.userInfo().username).replaceAll('{{VERSION}}', PKG.version).replaceAll('{{DATE}}', new Date().toISOString().slice(0, 10));
|
|
4122
|
+
const f = path.join(outDir, `MEMHOUSE-UPGRADE-${p.member}.md`); fs.writeFileSync(f, md); console.log(ok(`guide written: ${short(f)}`));
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
break;
|
|
4126
|
+
}
|
|
4017
4127
|
case 'members': {
|
|
4018
4128
|
// "Who is in this database, and what can each of them reach?" Before shared houses a
|
|
4019
4129
|
// database had exactly one member and the question did not exist; this layout creates
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
module.exports = { planMember, render, 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.5",
|
|
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,7 +16,7 @@ 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'],
|
|
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
20
|
status: [], 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',
|
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.5",
|
|
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": {
|