memhouse 0.15.0 → 0.17.0
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/README.md +1 -1
- package/bin/memhouse.js +296 -22
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -2
- package/memhouse/delivery/AGENT-INSTALL.md +2 -2
- package/memhouse/delivery/PROMPT.md +15 -9
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/reference/HOUSE.md +154 -0
- package/memhouse/delivery/plugin/skills/access/SKILL.md +148 -0
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +29 -0
- package/memhouse/delivery/plugin/skills/house/SKILL.md +99 -0
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +119 -0
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +73 -194
- package/memhouse/flags.js +77 -0
- package/memhouse/share.js +64 -0
- package/package.json +1 -1
- package/memhouse/delivery/plugin/skills/ask/SKILL.md +0 -131
- package/memhouse/delivery/plugin/skills/hello/SKILL.md +0 -121
- package/memhouse/delivery/plugin/skills/invite/SKILL.md +0 -130
- package/memhouse/delivery/plugin/skills/search/SKILL.md +0 -186
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +0 -164
- package/memhouse/delivery/plugin/skills/share/SKILL.md +0 -130
- package/memhouse/delivery/plugin/skills/status/SKILL.md +0 -139
- package/memhouse/delivery/plugin/skills/users/SKILL.md +0 -97
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ an agent — so an agent can install its own memory unattended.
|
|
|
99
99
|
config directory on the machine** — `~/.claude`, whatever `CLAUDE_CONFIG_DIR` points
|
|
100
100
|
at, and each Kommander-style playbook under `~/.claude-playbooks/`. All are selected by
|
|
101
101
|
default; answer with numbers to narrow it, or pass `--target DIR` for exactly one.
|
|
102
|
-
Installing into just the default config is how `/mem:
|
|
102
|
+
Installing into just the default config is how `/mem:recall` ends up missing from
|
|
103
103
|
the instance you actually work in, silently, since a missing skill never announces
|
|
104
104
|
itself.
|
|
105
105
|
|
package/bin/memhouse.js
CHANGED
|
@@ -30,6 +30,7 @@ const {
|
|
|
30
30
|
const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
|
|
31
31
|
const { consumeSecretChunk } = require(path.join(REPO_ROOT, 'memhouse', 'secret-input'));
|
|
32
32
|
const { capabilitiesFrom } = require(path.join(REPO_ROOT, 'memhouse', 'capabilities'));
|
|
33
|
+
const { unknownFlags, allowedFlags, suggestFlag } = require(path.join(REPO_ROOT, 'memhouse', 'flags'));
|
|
33
34
|
|
|
34
35
|
const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
|
|
35
36
|
const ENV_FILE = path.join(HOME_DIR, 'env');
|
|
@@ -53,6 +54,22 @@ for (let i = 0; i < rest.length; i++) {
|
|
|
53
54
|
}
|
|
54
55
|
const JSON_OUT = flags.json === true;
|
|
55
56
|
|
|
57
|
+
// Unknown flags stop the run — see memhouse/flags.js for why a shrug is not enough.
|
|
58
|
+
(() => {
|
|
59
|
+
const bad = unknownFlags(cmd, flags);
|
|
60
|
+
if (!bad.length) return;
|
|
61
|
+
const allowed = allowedFlags(cmd);
|
|
62
|
+
for (const f of bad) {
|
|
63
|
+
const hint = suggestFlag(f, allowed);
|
|
64
|
+
console.error(`memhouse: unknown option --${f}${hint ? ` (did you mean --${hint}?)` : ''}`);
|
|
65
|
+
}
|
|
66
|
+
const list = [...allowed].sort().map((f) => `--${f}`).join(' ');
|
|
67
|
+
console.error(` ${cmd || 'memhouse'} takes: ${list || '(no options)'}`);
|
|
68
|
+
console.error(' Nothing ran. A flag that is ignored rather than refused is how a --dry-run typo');
|
|
69
|
+
console.error(' becomes a real migration.');
|
|
70
|
+
process.exit(2);
|
|
71
|
+
})();
|
|
72
|
+
|
|
56
73
|
let ONBOARDING = false;
|
|
57
74
|
let _inviteFileToShred = null;
|
|
58
75
|
let _inviteWantsRotate = false;
|
|
@@ -382,6 +399,10 @@ Data ship one incremental pass (--full | --loop [sec])
|
|
|
382
399
|
resume <session-id> print the command that reopens that session in its editor
|
|
383
400
|
sessions-query print the session rollup SQL for this credential
|
|
384
401
|
start | stop | shipper loop + dashboard as background daemons
|
|
402
|
+
share <user> let someone read this house (--only project=… |
|
|
403
|
+
session=… | host=… | source=… | folder=… | since=… |
|
|
404
|
+
until=… scopes it with row policies; --revoke
|
|
405
|
+
withdraws and drops them; --list shows who)
|
|
385
406
|
whoami which credential is in play, and what it may do
|
|
386
407
|
(--json; --admin reads MEMHOUSE_ADMIN_*)
|
|
387
408
|
status daemons, connection, counts, freshness (--json)
|
|
@@ -699,7 +720,7 @@ ${rooms.trim()}
|
|
|
699
720
|
-- The whole database, WITH GRANT OPTION. ALL on your own house reaches nothing outside it
|
|
700
721
|
-- — the database is the boundary — and it lets the shipper create and evolve its own
|
|
701
722
|
-- tables. The grant option makes the member the real owner: they can hand on any of their
|
|
702
|
-
-- own data (\`/mem:
|
|
723
|
+
-- own data (\`/mem:access\` still opens only a read-only SELECT window, but the owner is not
|
|
703
724
|
-- boxed into read-only sharing of their own house). Scoped to this db, and no CREATE USER
|
|
704
725
|
-- rides with it — a member still cannot mint accounts or reach another house.
|
|
705
726
|
GRANT ALL ON ${db}.* TO ${member} WITH GRANT OPTION;
|
|
@@ -714,9 +735,9 @@ GRANT ALL ON ${db}.* TO ${member} WITH GRANT OPTION;
|
|
|
714
735
|
-- trust model, flagged by \`memhouse doctor\`, and re-asserted on every \`ship --ensure-schema\`.
|
|
715
736
|
GRANT ALTER USER ON ${member} TO ${member};
|
|
716
737
|
|
|
717
|
-
-- See the OTHER members, read-only. SHOW USERS lets \`/mem:
|
|
738
|
+
-- See the OTHER members, read-only. SHOW USERS lets \`/mem:house\` list who is on this
|
|
718
739
|
-- ClickHouse (names only — no passwords, no data) so a member can find who to share with.
|
|
719
|
-
-- It grants no read of anyone's rows; reading a housemate's memory still needs /mem:
|
|
740
|
+
-- It grants no read of anyone's rows; reading a housemate's memory still needs /mem:access.
|
|
720
741
|
GRANT SHOW USERS ON *.* TO ${member};
|
|
721
742
|
|
|
722
743
|
-- \`memhouse relocate\` runs FROM the destination, pulling the source over remoteSecure() —
|
|
@@ -791,7 +812,7 @@ async function adminBootstrap(cfg, admin) {
|
|
|
791
812
|
password = generatePassword();
|
|
792
813
|
// For a local install the password must be SHOWN — it is the user's only copy. For
|
|
793
814
|
// an invite it must NOT: the caller writes it to the credential file, and printing
|
|
794
|
-
// it here would land it in the terminal and, via /mem:
|
|
815
|
+
// it here would land it in the terminal and, via /mem:access, in a transcript
|
|
795
816
|
// memhouse itself ships. admin.quiet is the invite path.
|
|
796
817
|
if (!admin.quiet) {
|
|
797
818
|
console.log('');
|
|
@@ -822,7 +843,7 @@ async function adminBootstrap(cfg, admin) {
|
|
|
822
843
|
// create and evolve the rooms (--ensure-schema below).
|
|
823
844
|
try {
|
|
824
845
|
// ALL, WITH GRANT OPTION: the database is theirs, so they may do anything with their
|
|
825
|
-
// own data AND hand any of it on. /mem:
|
|
846
|
+
// own data AND hand any of it on. /mem:access still opens only a read-only window (it
|
|
826
847
|
// grants SELECT), but the owner is not boxed into read-only sharing of their own house.
|
|
827
848
|
// Scoped to their db: the grant option reaches nothing outside it, and no CREATE USER
|
|
828
849
|
// comes with it, so a member still cannot mint accounts or touch another house.
|
|
@@ -834,12 +855,12 @@ async function adminBootstrap(cfg, admin) {
|
|
|
834
855
|
// self-rotation.
|
|
835
856
|
try { await q(`GRANT ALTER USER ON ${admin.member} TO ${admin.member}`, { database: '' }); }
|
|
836
857
|
catch { /* admin without access-management: passwd stays admin-assisted for this member */ }
|
|
837
|
-
// See the OTHER members, read-only: SHOW USERS lets `/mem:
|
|
858
|
+
// See the OTHER members, read-only: SHOW USERS lets `/mem:house` list who is on this
|
|
838
859
|
// ClickHouse (names only — no passwords, no data) so a member can find who to share
|
|
839
|
-
// with. It grants no read of anyone's rows; that still needs an explicit /mem:
|
|
860
|
+
// with. It grants no read of anyone's rows; that still needs an explicit /mem:access.
|
|
840
861
|
// Best-effort like ALTER USER above — an admin without access-management just skips it.
|
|
841
862
|
try { await q(`GRANT SHOW USERS ON *.* TO ${admin.member}`, { database: '' }); }
|
|
842
|
-
catch { /* no access-management: /mem:
|
|
863
|
+
catch { /* no access-management: /mem:house section 4 stays admin-only for this member */ }
|
|
843
864
|
// REMOTE: `memhouse relocate` runs FROM the destination, pulling the source over
|
|
844
865
|
// remoteSecure() — a table function ClickHouse gates behind its own access type,
|
|
845
866
|
// separate from any GRANT ALL on a database. Without it every relocate a member runs
|
|
@@ -1126,14 +1147,29 @@ async function cmdInstall({ interactive }) {
|
|
|
1126
1147
|
// shipper or an installed service is still pointed at the old house, and rewriting the
|
|
1127
1148
|
// file orphans it with no error anywhere.
|
|
1128
1149
|
const prior = readEnvFile();
|
|
1129
|
-
|
|
1130
|
-
|
|
1150
|
+
const displaces = Boolean(prior.MEMHOUSE_URL)
|
|
1151
|
+
&& (!sameEndpoint(prior.MEMHOUSE_URL, cfg.url) || (prior.MEMHOUSE_DB && prior.MEMHOUSE_DB !== cfg.db));
|
|
1152
|
+
if (displaces && flags.force !== true) {
|
|
1131
1153
|
console.log(bad(`${ENV_FILE} already points at ${prior.MEMHOUSE_URL} / ${prior.MEMHOUSE_DB || '?'}`));
|
|
1132
1154
|
console.log(` installing over it would leave any running shipper or service on the old house.`);
|
|
1133
1155
|
console.log(' memhouse setup --url … --db … (move deliberately)');
|
|
1134
1156
|
console.log(' memhouse install --force … (overwrite anyway)');
|
|
1135
1157
|
return 1;
|
|
1136
1158
|
}
|
|
1159
|
+
if (displaces) {
|
|
1160
|
+
// --force used to overwrite the file and say nothing. The password inside is often
|
|
1161
|
+
// the ONLY copy — an invited member cannot mint another — so replacing it silently
|
|
1162
|
+
// orphans a house that still exists and still costs disk. A drill found this by
|
|
1163
|
+
// taking the hatch the refusal above offers: the arriver read "already exists",
|
|
1164
|
+
// reached for --force, and destroyed a working credential without being told.
|
|
1165
|
+
// `relocate` already keeps the old file for the same operation; so does this now.
|
|
1166
|
+
console.log(warn(`replacing the credential for ${prior.MEMHOUSE_USER || '?'}@${prior.MEMHOUSE_URL} (house '${prior.MEMHOUSE_DB || '?'}')`));
|
|
1167
|
+
try {
|
|
1168
|
+
fs.copyFileSync(ENV_FILE, `${ENV_FILE}.pre-install`);
|
|
1169
|
+
console.log(` previous config kept at ${`${ENV_FILE}.pre-install`.replace(os.homedir(), '~')} — it holds that password`);
|
|
1170
|
+
} catch { console.log(warn(' could not keep a copy of the previous config')); }
|
|
1171
|
+
console.log(' that house still exists; nothing was deleted from the server.');
|
|
1172
|
+
}
|
|
1137
1173
|
// Preflight WITHOUT selecting the house — on a fresh standalone ClickHouse the
|
|
1138
1174
|
// database doesn't exist yet, and selecting it would fail before we can create it.
|
|
1139
1175
|
try { await ch(cfg, 'SELECT 1', { database: '' }); }
|
|
@@ -1207,6 +1243,28 @@ async function cmdInstall({ interactive }) {
|
|
|
1207
1243
|
// config file behind every failed attempt, and the next command reads it as truth.
|
|
1208
1244
|
writeEnvFile(cfg);
|
|
1209
1245
|
console.log(ok(`config written: ${ENV_FILE}`));
|
|
1246
|
+
// A running shipper holds the OLD house until it restarts. It does not fail — it keeps
|
|
1247
|
+
// shipping, to the house that is no longer configured, while `status` shows a green
|
|
1248
|
+
// "shipper: running" beside a green "house: empty" and the new house stays at zero.
|
|
1249
|
+
// Measured in a drill: 145 sessions "skipped" into a house holding no rows, because the
|
|
1250
|
+
// skip predicate was still being evaluated against the old one. `relocate` restarts
|
|
1251
|
+
// deliberately for exactly this reason; install used to do nothing at all.
|
|
1252
|
+
if (displaces && shipperHealth().running) {
|
|
1253
|
+
const st = (() => { try { return require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { return null; } })();
|
|
1254
|
+
const viaService = String(shipperHealth().via || '').startsWith('service');
|
|
1255
|
+
if (viaService && st) {
|
|
1256
|
+
const cmd = st.kind === 'systemd'
|
|
1257
|
+
? ['systemctl', ['--user', 'restart', 'memhouse-shipper']]
|
|
1258
|
+
: ['launchctl', ['kickstart', '-k', `gui/${process.getuid()}/com.memhouse.shipper`]];
|
|
1259
|
+
const r = spawnSync(cmd[0], cmd[1], { stdio: 'pipe', encoding: 'utf-8' });
|
|
1260
|
+
console.log(r.status === 0 ? ok('shipper restarted against the new house')
|
|
1261
|
+
: warn('restart the shipper yourself, or it keeps writing to the old house: memhouse service restart'));
|
|
1262
|
+
} else {
|
|
1263
|
+
const pid = pidOf('shipper');
|
|
1264
|
+
if (pid) { try { process.kill(pid, 'SIGTERM'); console.log(ok(`stopped the shipper (pid ${pid}) — it was pointed at the old house`)); } catch { /* raced */ } }
|
|
1265
|
+
console.log(' start it against the new one: memhouse start');
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1210
1268
|
// Mint this machine's identity here rather than leaving it to whatever runs first.
|
|
1211
1269
|
// Installing is the moment a machine joins the member's rooms, and the id is what every
|
|
1212
1270
|
// later `WHERE host = …` depends on — so it is worth naming once, out loud, at the point
|
|
@@ -1269,9 +1327,9 @@ function printGettingStarted(cfg) {
|
|
|
1269
1327
|
console.log(' memhouse start dashboard + shipper loop (background daemons)');
|
|
1270
1328
|
console.log(` -> http://localhost:${cfg.port || 4640} browse, search, and analyze every session`);
|
|
1271
1329
|
console.log(' memhouse service install or: ship at login, no terminal needed');
|
|
1272
|
-
console.log(' memhouse plugins install claude give your agents /mem:
|
|
1273
|
-
console.log(' /mem:
|
|
1274
|
-
console.log(' /mem:
|
|
1330
|
+
console.log(' memhouse plugins install claude give your agents /mem:house, /mem:recall,');
|
|
1331
|
+
console.log(' /mem:recall, /mem:access, /mem:sql,');
|
|
1332
|
+
console.log(' /mem:house');
|
|
1275
1333
|
console.log(' memhouse search <terms> find a past conversation right now');
|
|
1276
1334
|
console.log(' memhouse doctor every line a check mark = healthy');
|
|
1277
1335
|
console.log(' The house keeps shipping as you work; nothing else to do.');
|
|
@@ -1331,7 +1389,7 @@ async function cmdOnboard() {
|
|
|
1331
1389
|
const targets = claudeTargets();
|
|
1332
1390
|
if (targets.length) {
|
|
1333
1391
|
console.log('');
|
|
1334
|
-
console.log(`Claude Code skills: ${
|
|
1392
|
+
console.log(`Claude Code skills: ${skillNames().map((n) => `/mem:${n}`).join(', ')}`);
|
|
1335
1393
|
const chosen = await chooseTargets(targets, 'Install into');
|
|
1336
1394
|
for (const t of chosen) console.log(ok(`installed skills into ${short(installPluginInto(t.dir))}`));
|
|
1337
1395
|
if (chosen.length) console.log(' they load next time that Claude Code starts');
|
|
@@ -2228,7 +2286,7 @@ async function cmdUpdate() {
|
|
|
2228
2286
|
// A pilot rarely has one. `~/.claude` is the stock install, `CLAUDE_CONFIG_DIR` points at
|
|
2229
2287
|
// whichever they are running right now, and Kommander-style playbooks live under
|
|
2230
2288
|
// `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
|
|
2231
|
-
// skills/. Installing into one and calling it done leaves /mem:
|
|
2289
|
+
// skills/. Installing into one and calling it done leaves /mem:recall missing from
|
|
2232
2290
|
// every other instance the pilot uses — silently, because a missing skill does not announce
|
|
2233
2291
|
// itself, it just never appears.
|
|
2234
2292
|
//
|
|
@@ -2264,7 +2322,7 @@ const isPluginInstalled = (dir) => fs.existsSync(path.join(dir, PLUGIN_MARK));
|
|
|
2264
2322
|
|
|
2265
2323
|
function installPluginInto(dir) {
|
|
2266
2324
|
// The plugin was named `memhouse` until 0.10.0. A leftover copy under the old name
|
|
2267
|
-
// would load BESIDE the new one — /memhouse:search and /mem:
|
|
2325
|
+
// would load BESIDE the new one — /memhouse:search and /mem:recall both resolving, one
|
|
2268
2326
|
// of them stale forever. Remove it only when it is provably OURS (it carries our
|
|
2269
2327
|
// plugin.json); a directory someone else named `memhouse` is not ours to delete.
|
|
2270
2328
|
const legacy = path.join(dir, 'skills', 'memhouse');
|
|
@@ -2280,7 +2338,7 @@ function installPluginInto(dir) {
|
|
|
2280
2338
|
const dst = path.join(dir, 'skills', 'mem');
|
|
2281
2339
|
// REPLACE, not overlay. cpSync over an existing install refreshes the skills and
|
|
2282
2340
|
// leaves anything else standing — a machine that once had a build with extra skills
|
|
2283
|
-
// kept offering /mem:replay and /mem:
|
|
2341
|
+
// kept offering /mem:replay and /mem:house forever, stale, beside the real ones.
|
|
2284
2342
|
// Ownership means OUR MANIFEST, checked by name — "any plugin.json" would have deleted
|
|
2285
2343
|
// an unrelated plugin that happened to pick the same directory name.
|
|
2286
2344
|
const dstManifest = path.join(dst, '.claude-plugin', 'plugin.json');
|
|
@@ -2334,15 +2392,27 @@ async function chooseTargets(targets, verb) {
|
|
|
2334
2392
|
//
|
|
2335
2393
|
// Claude Code gives a skill a namespace only when it arrives inside a plugin: a directory
|
|
2336
2394
|
// under <config>/skills/ containing .claude-plugin/plugin.json loads as
|
|
2337
|
-
// `mem@skills-dir` and its skills become /mem:
|
|
2395
|
+
// `mem@skills-dir` and its skills become /mem:recall,
|
|
2338
2396
|
// /mem:sql. Copied in flat, the same three files register as unrelated top-level
|
|
2339
2397
|
// skills named after their folders — which is what this used to do, while plugin.json sat
|
|
2340
2398
|
// unread one directory away claiming the colon form. Driving a real Claude Code is what
|
|
2341
|
-
// caught it: `/mem:
|
|
2399
|
+
// caught it: `/mem:recall` answered `Unknown command. Did you mean /memhouse-search?`
|
|
2400
|
+
/**
|
|
2401
|
+
* The plugin's skills, by name. A directory is a skill only when it carries a SKILL.md —
|
|
2402
|
+
* `plugin/` also holds `reference/`, shared prose the skills point at, and listing that
|
|
2403
|
+
* as `/mem:reference` would advertise a command nobody can run.
|
|
2404
|
+
*/
|
|
2405
|
+
function skillNames() {
|
|
2406
|
+
const dir = path.join(DELIVERY, 'plugin', 'skills');
|
|
2407
|
+
try {
|
|
2408
|
+
return fs.readdirSync(dir).filter((n) => fs.existsSync(path.join(dir, n, 'SKILL.md'))).sort();
|
|
2409
|
+
} catch { return []; }
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2342
2412
|
async function cmdPlugins() {
|
|
2343
2413
|
const sub = positional[0] || 'list';
|
|
2344
2414
|
const pluginSrc = path.join(DELIVERY, 'plugin');
|
|
2345
|
-
const names =
|
|
2415
|
+
const names = skillNames(); // SKILL.md-bearing dirs only — `reference/` is not a skill
|
|
2346
2416
|
const invocations = names.map((n) => `/mem:${n}`).join(', ');
|
|
2347
2417
|
// --target overrides the discovery rather than joining it: given one, that is the only
|
|
2348
2418
|
// directory touched.
|
|
@@ -2932,7 +3002,7 @@ async function cmdNightly() {
|
|
|
2932
3002
|
* machine's config is never touched and nothing local ships. The server half is exactly
|
|
2933
3003
|
* adminBootstrap (create user if absent, create house, GRANT ALL + SELECT WITH GRANT
|
|
2934
3004
|
* OPTION, async pin, then verify AS THE MEMBER), so an invited member is
|
|
2935
|
-
* indistinguishable from one minted by a local admin install — /mem:
|
|
3005
|
+
* indistinguishable from one minted by a local admin install — /mem:access works for
|
|
2936
3006
|
* them on day one.
|
|
2937
3007
|
*
|
|
2938
3008
|
* The output file IS a credential. The header says so, the handoff advice names safe
|
|
@@ -2951,12 +3021,209 @@ async function cmdNightly() {
|
|
|
2951
3021
|
* can ask "is there an admin credential in this environment?" without inventing a place
|
|
2952
3022
|
* to store one. Nothing here prints or persists a password.
|
|
2953
3023
|
*/
|
|
3024
|
+
/**
|
|
3025
|
+
* `memhouse share <user> [--only <scope>] [--revoke]`, `memhouse share --list`
|
|
3026
|
+
*
|
|
3027
|
+
* Full share is a GRANT and nothing else. Partial share adds one ROW POLICY per room, so
|
|
3028
|
+
* the grantee reads only what the scope allows. See memhouse/share.js for why there is
|
|
3029
|
+
* deliberately no permissive catch-all policy, and what was measured to rule it out.
|
|
3030
|
+
*/
|
|
3031
|
+
async function cmdShare() {
|
|
3032
|
+
const share = require(path.join(REPO_ROOT, 'memhouse', 'share'));
|
|
3033
|
+
const cfg = requireConfig(resolveConfig(), 'share');
|
|
3034
|
+
if (!cfg.user) return 1;
|
|
3035
|
+
const db = cfg.db;
|
|
3036
|
+
|
|
3037
|
+
// Statements, not reads: this is the one command in the read family that writes, so it
|
|
3038
|
+
// does NOT go through the readonly connection the skills use.
|
|
3039
|
+
const run = (sql) => ch(cfg, sql, { database: db });
|
|
3040
|
+
const rows = (sql) => chRows(cfg, sql, { database: db });
|
|
3041
|
+
|
|
3042
|
+
// List only when asked for it, or when given nothing at all. `--only` or `--revoke`
|
|
3043
|
+
// without a user is a mistake worth naming, not a reason to print the list.
|
|
3044
|
+
const bare = !positional[0] && flags.only === undefined && flags.revoke === undefined;
|
|
3045
|
+
if (flags.list === true || bare) return shareList(cfg, share);
|
|
3046
|
+
|
|
3047
|
+
const user = positional[0];
|
|
3048
|
+
if (!user) { console.log(bad('usage: memhouse share <user> [--only <scope>] [--revoke] | memhouse share --list')); return 2; }
|
|
3049
|
+
// The name is spliced into GRANT/REVOKE/CREATE POLICY unquoted, so it must be a name
|
|
3050
|
+
// ClickHouse accepts bare. Refuse rather than quote: a handle you cannot type plainly
|
|
3051
|
+
// is one that will be gotten wrong somewhere else.
|
|
3052
|
+
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(user, 'user'); }
|
|
3053
|
+
catch (e) { console.log(bad(e.message)); return 1; }
|
|
3054
|
+
if (user === cfg.user) { console.log(bad(`'${user}' is you — you already read your own house`)); return 1; }
|
|
3055
|
+
|
|
3056
|
+
// ── revoke ────────────────────────────────────────────────────────────────────
|
|
3057
|
+
// Dropping the grant alone leaves the policies behind, and they are NOT inert: a later
|
|
3058
|
+
// re-share silently inherits the old scope. Measured — a re-granted user saw 2,121 rows
|
|
3059
|
+
// instead of the whole house, with nothing on any surface explaining the filter.
|
|
3060
|
+
if (flags.revoke === true) {
|
|
3061
|
+
let dropped = 0;
|
|
3062
|
+
for (const room of ROOM_TYPES) {
|
|
3063
|
+
try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${room}`); dropped++; }
|
|
3064
|
+
catch (e) { console.log(warn(`could not drop the policy on ${room}: ${e.message.split('\n')[0]}`)); }
|
|
3065
|
+
}
|
|
3066
|
+
try { await run(`REVOKE SELECT ON ${db}.* FROM ${user}`); }
|
|
3067
|
+
catch (e) { console.log(bad(`could not revoke: ${netReason(e)}`)); return 1; }
|
|
3068
|
+
await houseMeta(cfg, `share:${user}`, `revoked ${new Date().toISOString().slice(0, 10)}`);
|
|
3069
|
+
console.log(ok(`revoked '${user}' — SELECT withdrawn and ${dropped} row polic${dropped === 1 ? 'y' : 'ies'} dropped`));
|
|
3070
|
+
console.log(' Dropping the grant alone would have left the policies, and a later re-share');
|
|
3071
|
+
console.log(' would have quietly reinherited this scope.');
|
|
3072
|
+
return 0;
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
// ── grant ─────────────────────────────────────────────────────────────────────
|
|
3076
|
+
const scopeRaw = flags.only !== undefined && flags.only !== true ? String(flags.only) : null;
|
|
3077
|
+
let scope = null;
|
|
3078
|
+
if (flags.only === true) { console.log(bad('--only needs a scope, e.g. --only project=memhouse or --only session=<id>')); return 1; }
|
|
3079
|
+
if (scopeRaw) {
|
|
3080
|
+
try { scope = share.parseScope(scopeRaw); } catch (e) { console.log(bad(e.message)); return 1; }
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
// A partial share only behaves if readers WITHOUT a policy still see every row. That is
|
|
3084
|
+
// `users_without_row_policies_can_read_rows`, which is server config rather than a query
|
|
3085
|
+
// setting and whose default has moved between versions — so measure it, do not assume.
|
|
3086
|
+
if (scope) {
|
|
3087
|
+
const verdict = await probePermissive(cfg, db);
|
|
3088
|
+
if (verdict === false) {
|
|
3089
|
+
console.log(bad('this ClickHouse hides every row from readers that have no row policy.'));
|
|
3090
|
+
console.log(' Scoping one person would blindfold everyone else you have already shared with,');
|
|
3091
|
+
console.log(' and yourself. Nothing was changed. Ask whoever runs the server to set:');
|
|
3092
|
+
console.log(' <access_control_improvements>');
|
|
3093
|
+
console.log(' <users_without_row_policies_can_read_rows>true</users_without_row_policies_can_read_rows>');
|
|
3094
|
+
console.log(' </access_control_improvements>');
|
|
3095
|
+
console.log(' A full share (no --only) is unaffected and works today.');
|
|
3096
|
+
return 1;
|
|
3097
|
+
}
|
|
3098
|
+
if (verdict === null) console.log(warn('could not verify how this server treats readers without a policy — continuing'));
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
try { await run(`GRANT SELECT ON ${db}.* TO ${user}`); }
|
|
3102
|
+
catch (e) { console.log(bad(`could not grant: ${netReason(e)}`)); return 1; }
|
|
3103
|
+
|
|
3104
|
+
if (!scope) {
|
|
3105
|
+
// A previous partial share leaves policies that would still be filtering. Widening to
|
|
3106
|
+
// a full share has to clear them or "full" is a lie.
|
|
3107
|
+
let cleared = 0;
|
|
3108
|
+
for (const room of ROOM_TYPES) {
|
|
3109
|
+
try {
|
|
3110
|
+
const had = await rows(`SELECT count() AS n FROM system.row_policies WHERE database = ${sqlStr(db)} AND short_name = ${sqlStr(share.policyName(user, room))}`);
|
|
3111
|
+
if (Number(had[0] && had[0].n) > 0) { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${room}`); cleared++; }
|
|
3112
|
+
} catch { /* nothing to clear */ }
|
|
3113
|
+
}
|
|
3114
|
+
await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)}`);
|
|
3115
|
+
console.log(ok(`'${user}' can now read every session in '${db}'`));
|
|
3116
|
+
if (cleared) console.log(warn(`cleared ${cleared} row polic${cleared === 1 ? 'y' : 'ies'} from an earlier scoped share — this is now a FULL share`));
|
|
3117
|
+
console.log(` Everything: every project, machine and editor, including anything ever pasted`);
|
|
3118
|
+
console.log(` into a session. Narrow it with: memhouse share ${user} --only project=<name>`);
|
|
3119
|
+
console.log(` Withdraw with: memhouse share ${user} --revoke`);
|
|
3120
|
+
return 0;
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
for (const room of ROOM_TYPES) {
|
|
3124
|
+
const pred = share.scopePredicate(scope, room, sqlStr);
|
|
3125
|
+
try { await run(`CREATE ROW POLICY OR REPLACE ${share.policyName(user, room)} ON ${db}.${room} USING ${pred} TO ${user}`); }
|
|
3126
|
+
catch (e) {
|
|
3127
|
+
console.log(bad(`could not scope ${room}: ${e.message.split('\n')[0]}`));
|
|
3128
|
+
console.log(' PARTIALLY APPLIED — a share filtered on some rooms and not others leaks.');
|
|
3129
|
+
console.log(` Put it right with: memhouse share ${user} --revoke`);
|
|
3130
|
+
return 1;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
await houseMeta(cfg, `share:${user}`, `granted ${new Date().toISOString().slice(0, 10)} scope=${scopeRaw}`);
|
|
3134
|
+
|
|
3135
|
+
// Say what they can actually reach, counted through their own filter.
|
|
3136
|
+
console.log(ok(`'${user}' can read '${db}' where ${scopeRaw}`));
|
|
3137
|
+
for (const room of ROOM_TYPES) {
|
|
3138
|
+
try {
|
|
3139
|
+
const r = await rows(`SELECT count() AS n FROM ${room} WHERE ${share.scopePredicate(scope, room, sqlStr)}`);
|
|
3140
|
+
const all = await rows(`SELECT count() AS n FROM ${room}`);
|
|
3141
|
+
console.log(` ${room.padEnd(11)} ${r[0].n} of ${all[0].n} rows`);
|
|
3142
|
+
} catch { /* counting is a courtesy */ }
|
|
3143
|
+
}
|
|
3144
|
+
console.log(` Widen to everything: memhouse share ${user}`);
|
|
3145
|
+
console.log(` Withdraw: memhouse share ${user} --revoke`);
|
|
3146
|
+
return 0;
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
/**
|
|
3150
|
+
* Does a reader with NO row policy still see rows once a policy exists on the table?
|
|
3151
|
+
*
|
|
3152
|
+
* Server config decides this and SQL cannot read it, so measure: put a deny-everything
|
|
3153
|
+
* policy on a scratch table aimed at everyone EXCEPT us, then read it. Rows back means
|
|
3154
|
+
* unpolicied readers are unaffected. Needs no second account and no admin.
|
|
3155
|
+
*
|
|
3156
|
+
* true = permissive, false = restrictive, null = could not tell.
|
|
3157
|
+
*/
|
|
3158
|
+
async function probePermissive(cfg, db) {
|
|
3159
|
+
const t = `_mh_probe_${process.pid}`;
|
|
3160
|
+
const run = (sql) => ch(cfg, sql, { database: db });
|
|
3161
|
+
try {
|
|
3162
|
+
await run(`CREATE TABLE IF NOT EXISTS ${t} (x UInt8) ENGINE = MergeTree ORDER BY x`);
|
|
3163
|
+
await run(`INSERT INTO ${t} VALUES (1)`, { settings: { async_insert: 0 } });
|
|
3164
|
+
await run(`CREATE ROW POLICY OR REPLACE ${t}_p ON ${db}.${t} USING 0 TO ALL EXCEPT ${cfg.user}`);
|
|
3165
|
+
const r = await chRows(cfg, `SELECT count() AS n FROM ${t}`, { database: db });
|
|
3166
|
+
return Number(r[0] && r[0].n) === 1;
|
|
3167
|
+
} catch { return null; } finally {
|
|
3168
|
+
try { await run(`DROP ROW POLICY IF EXISTS ${t}_p ON ${db}.${t}`); } catch { /* nothing staged */ }
|
|
3169
|
+
try { await run(`DROP TABLE IF EXISTS ${t} SYNC`); } catch { /* nothing staged */ }
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
/** Who can read this house, what they are scoped to, and any policy left behind. */
|
|
3174
|
+
async function shareList(cfg, share) {
|
|
3175
|
+
const db = cfg.db;
|
|
3176
|
+
let recorded = [];
|
|
3177
|
+
try {
|
|
3178
|
+
recorded = await chRows(cfg, "SELECT substring(key, 7) AS user, value AS state FROM house_meta FINAL WHERE key LIKE 'share:%' ORDER BY key", { database: db });
|
|
3179
|
+
} catch { /* unreadable record */ }
|
|
3180
|
+
let policies = [];
|
|
3181
|
+
try {
|
|
3182
|
+
policies = await chRows(cfg, `SELECT short_name, table, select_filter FROM system.row_policies WHERE database = ${sqlStr(db)} ORDER BY short_name`, { database: db });
|
|
3183
|
+
} catch { /* members may not read system.row_policies on every server */ }
|
|
3184
|
+
|
|
3185
|
+
if (!recorded.length && !policies.length) {
|
|
3186
|
+
console.log(ok(`nobody has been granted a read of '${db}'`));
|
|
3187
|
+
console.log(` Share it with: memhouse share <user> [--only project=<name>]`);
|
|
3188
|
+
return 0;
|
|
3189
|
+
}
|
|
3190
|
+
console.log(` who can read '${db}' — memhouse's own record, not ClickHouse's grant table:`);
|
|
3191
|
+
for (const r of recorded) {
|
|
3192
|
+
const scoped = policies.filter((p) => String(p.short_name).startsWith(`mh_share_${r.user}_`));
|
|
3193
|
+
console.log(` ${String(r.user).padEnd(16)} ${r.state}${scoped.length ? '' : (String(r.state).startsWith('granted') ? ' (full house)' : '')}`);
|
|
3194
|
+
for (const p of scoped) console.log(` ${String(p.table).padEnd(11)} ${p.select_filter}`);
|
|
3195
|
+
}
|
|
3196
|
+
console.log(' A grant made by hand does not appear here — this list is what memhouse recorded.');
|
|
3197
|
+
|
|
3198
|
+
// Policies whose room is gone: dropping a house does NOT drop its policies, and a house
|
|
3199
|
+
// later recreated under the same name silently inherits them.
|
|
3200
|
+
const orphans = [];
|
|
3201
|
+
for (const p of policies) {
|
|
3202
|
+
try {
|
|
3203
|
+
const e = await chRows(cfg, `SELECT count() AS n FROM system.tables WHERE database = ${sqlStr(db)} AND name = ${sqlStr(p.table)}`, { database: db });
|
|
3204
|
+
if (Number(e[0] && e[0].n) === 0) orphans.push(p);
|
|
3205
|
+
} catch { /* cannot tell */ }
|
|
3206
|
+
}
|
|
3207
|
+
if (orphans.length) {
|
|
3208
|
+
console.log('');
|
|
3209
|
+
console.log(warn(`${orphans.length} row polic${orphans.length === 1 ? 'y points' : 'ies point'} at a table that no longer exists:`));
|
|
3210
|
+
for (const p of orphans) console.log(` ${p.short_name} ON ${db}.${p.table}`);
|
|
3211
|
+
console.log(' Dropping a table or a house leaves its policies behind, and recreating one under');
|
|
3212
|
+
console.log(' the same name silently reinherits them. Drop them:');
|
|
3213
|
+
for (const p of orphans) console.log(` DROP ROW POLICY ${p.short_name} ON ${db}.${p.table}`);
|
|
3214
|
+
}
|
|
3215
|
+
return 0;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
2954
3218
|
async function cmdWhoami() {
|
|
2955
3219
|
const cfg = resolveConfig();
|
|
2956
3220
|
const wantAdmin = flags.admin === true;
|
|
2957
3221
|
const au = process.env.MEMHOUSE_ADMIN_USER;
|
|
2958
3222
|
const ap = process.env.MEMHOUSE_ADMIN_PASSWORD;
|
|
2959
3223
|
const usingAdminEnv = wantAdmin && au;
|
|
3224
|
+
// `--admin` with nothing to resolve is worth saying out loud. Silently falling back to
|
|
3225
|
+
// the member credential makes `whoami --admin` and `whoami` print the same thing, and
|
|
3226
|
+
// the reader is left thinking they asked a question the program never heard.
|
|
2960
3227
|
const who = usingAdminEnv ? { ...cfg, user: au, password: ap || '' } : cfg;
|
|
2961
3228
|
|
|
2962
3229
|
if (!who.url || !who.user) {
|
|
@@ -2990,10 +3257,16 @@ async function cmdWhoami() {
|
|
|
2990
3257
|
console.log(JSON.stringify({
|
|
2991
3258
|
ok: true, url: who.url, user: server, db: who.db, source, role, ...caps,
|
|
2992
3259
|
grants_readable: grants.length > 0,
|
|
3260
|
+
admin_env_present: Boolean(au),
|
|
3261
|
+
admin_requested: wantAdmin,
|
|
2993
3262
|
}, null, 2));
|
|
2994
3263
|
return 0;
|
|
2995
3264
|
}
|
|
2996
3265
|
console.log(ok(`${server} at ${who.url} — ${role}`));
|
|
3266
|
+
if (wantAdmin && !usingAdminEnv) {
|
|
3267
|
+
console.log(warn('--admin asked for MEMHOUSE_ADMIN_USER / MEMHOUSE_ADMIN_PASSWORD; neither is set,'));
|
|
3268
|
+
console.log(' so this is the ordinary configured credential.');
|
|
3269
|
+
}
|
|
2997
3270
|
console.log(` credential from: ${source}`);
|
|
2998
3271
|
console.log(` house: ${who.db || '(none set)'}`);
|
|
2999
3272
|
console.log(` may create users: ${caps.canMintUsers ? 'yes' : 'no'}`);
|
|
@@ -3212,7 +3485,7 @@ async function cmdInvite() {
|
|
|
3212
3485
|
console.log(` hand it to ${name} over a channel you trust (croc, a password manager — not chat).`);
|
|
3213
3486
|
console.log(` they run: memhouse install --env ${path.basename(out)}`);
|
|
3214
3487
|
console.log(` then they should rotate the password you now both know: memhouse passwd`);
|
|
3215
|
-
console.log(` once installed, they are a member — sharing works both ways: /mem:
|
|
3488
|
+
console.log(` once installed, they are a member — sharing works both ways: /mem:access ${name}`);
|
|
3216
3489
|
return 0;
|
|
3217
3490
|
}
|
|
3218
3491
|
|
|
@@ -3524,6 +3797,7 @@ async function cmdUninstall() {
|
|
|
3524
3797
|
}
|
|
3525
3798
|
break;
|
|
3526
3799
|
case 'reset': process.exitCode = await cmdReset(); break;
|
|
3800
|
+
case 'share': process.exitCode = await cmdShare(); break;
|
|
3527
3801
|
case 'whoami': process.exitCode = await cmdWhoami(); break;
|
|
3528
3802
|
case 'invite': process.exitCode = await cmdInvite(); break;
|
|
3529
3803
|
case 'passwd': process.exitCode = await cmdPasswd(); break;
|
|
@@ -58,8 +58,8 @@ mark. Then tell them:
|
|
|
58
58
|
|
|
59
59
|
- where the dashboard is (`http://localhost:{{PORT}}`) and that `memhouse start` runs it
|
|
60
60
|
- that `memhouse search <terms>` searches every past session from every editor
|
|
61
|
-
- that `memhouse plugins install claude` gives their agents /mem:
|
|
62
|
-
/mem:
|
|
61
|
+
- that `memhouse plugins install claude` gives their agents /mem:recall, /mem:sql,
|
|
62
|
+
/mem:house, /mem:access and /mem:admin — offer to run it
|
|
63
63
|
- that you can now search their history yourself, and offer to try one query
|
|
64
64
|
|
|
65
65
|
If `doctor` reports a failure, say which line failed and what memhouse suggested. Do not
|
|
@@ -115,8 +115,8 @@ memhouse plugins install claude
|
|
|
115
115
|
```
|
|
116
116
|
|
|
117
117
|
Installs the memhouse plugin into `<claude-config>/skills/mem`, which loads next
|
|
118
|
-
session as `mem@skills-dir` and exposes `/mem:
|
|
119
|
-
`/mem:
|
|
118
|
+
session as `mem@skills-dir` and exposes `/mem:recall`, `/mem:sql`, `/mem:house`,
|
|
119
|
+
`/mem:access` and `/mem:admin`. They read the connection from
|
|
120
120
|
`$MEMHOUSE_HOME/env` (default
|
|
121
121
|
`~/.memhouse/env`), with exported `MEMHOUSE_*` vars taking precedence, and refuse to run
|
|
122
122
|
rather than guessing a URL when neither is set. `memhouse plugins list` shows what is
|
|
@@ -23,12 +23,18 @@ work that is not in your current context ("that session where…", "how did I so
|
|
|
23
23
|
X before", "did we ever…", "what was I working on last week"), search memhouse
|
|
24
24
|
FIRST. Only say you don't know after a search comes back empty.
|
|
25
25
|
|
|
26
|
-
**How to query.** Prefer the installed skills when present:
|
|
27
|
-
|
|
28
|
-
`/mem:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
**How to query.** Prefer the installed skills when present:
|
|
27
|
+
|
|
28
|
+
- `/mem:recall` — find past sessions and answer from them, with citations. This is the
|
|
29
|
+
one you want for almost every "have I seen this before" question.
|
|
30
|
+
- `/mem:sql` — read-only SQL when the answer is a number: spend, model mix, tool
|
|
31
|
+
rankings, activity over time.
|
|
32
|
+
- `/mem:house` — what the system is, whether it is shipping, what it holds, and who can
|
|
33
|
+
read it.
|
|
34
|
+
- `/mem:access` — bring someone onto the server, or grant a housemate read-only access.
|
|
35
|
+
- `/mem:admin` — server-wide administration; needs an administrator credential.
|
|
36
|
+
|
|
37
|
+
Without skills, query directly.
|
|
32
38
|
|
|
33
39
|
**Table names.** The house's rooms are three plain, shared tables — `messages`,
|
|
34
40
|
`sessions`, `tool_calls` — in the connection's database. Everyone in the house writes
|
|
@@ -66,12 +72,12 @@ the retained epochs are the only place that content still exists.
|
|
|
66
72
|
**Rules.**
|
|
67
73
|
- Memory is READ-ONLY for you: never INSERT/ALTER/DROP the ROOMS (sessions, messages,
|
|
68
74
|
tool_calls) — ingestion belongs to the memhouse shipper alone. The one exception is
|
|
69
|
-
membership administration through the `memhouse` CLI: `/mem:
|
|
70
|
-
`/mem:
|
|
75
|
+
membership administration through the `memhouse` CLI: `/mem:access` mints a user and
|
|
76
|
+
`/mem:access` grants read access, both via the tool, not by you writing to the rooms.
|
|
71
77
|
- Quote retrieved content as *the user's past sessions*, and cite the session_id
|
|
72
78
|
when the user may want to dig deeper.
|
|
73
79
|
- Other users' HOUSES (other databases on the same server) are readable only when
|
|
74
|
-
shared with this credential (`/mem:
|
|
80
|
+
shared with this credential (`/mem:access` on their side). Query a shared house by
|
|
75
81
|
qualified names — `polat.messages` — with the same CUR filter. An empty result
|
|
76
82
|
means "nothing visible", not "nothing ever happened".
|
|
77
83
|
- Do not paste credentials from `{{ENV_FILE}}` into responses, commits, or logs.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mem",
|
|
3
|
-
"description": "Conversation memory for AI agents over the memhouse typed ClickHouse store:
|
|
4
|
-
"version": "0.
|
|
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.17.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|