memhouse 0.16.0 → 0.17.1
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 +270 -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 +1 -0
- package/memhouse/house/house.js +151 -1
- package/memhouse/server/queries.js +271 -137
- package/memhouse/share.js +64 -0
- package/memhouse/shipper/ship.js +32 -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
|
@@ -399,6 +399,10 @@ Data ship one incremental pass (--full | --loop [sec])
|
|
|
399
399
|
resume <session-id> print the command that reopens that session in its editor
|
|
400
400
|
sessions-query print the session rollup SQL for this credential
|
|
401
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)
|
|
402
406
|
whoami which credential is in play, and what it may do
|
|
403
407
|
(--json; --admin reads MEMHOUSE_ADMIN_*)
|
|
404
408
|
status daemons, connection, counts, freshness (--json)
|
|
@@ -716,7 +720,7 @@ ${rooms.trim()}
|
|
|
716
720
|
-- The whole database, WITH GRANT OPTION. ALL on your own house reaches nothing outside it
|
|
717
721
|
-- — the database is the boundary — and it lets the shipper create and evolve its own
|
|
718
722
|
-- tables. The grant option makes the member the real owner: they can hand on any of their
|
|
719
|
-
-- own data (\`/mem:
|
|
723
|
+
-- own data (\`/mem:access\` still opens only a read-only SELECT window, but the owner is not
|
|
720
724
|
-- boxed into read-only sharing of their own house). Scoped to this db, and no CREATE USER
|
|
721
725
|
-- rides with it — a member still cannot mint accounts or reach another house.
|
|
722
726
|
GRANT ALL ON ${db}.* TO ${member} WITH GRANT OPTION;
|
|
@@ -731,9 +735,9 @@ GRANT ALL ON ${db}.* TO ${member} WITH GRANT OPTION;
|
|
|
731
735
|
-- trust model, flagged by \`memhouse doctor\`, and re-asserted on every \`ship --ensure-schema\`.
|
|
732
736
|
GRANT ALTER USER ON ${member} TO ${member};
|
|
733
737
|
|
|
734
|
-
-- 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
|
|
735
739
|
-- ClickHouse (names only — no passwords, no data) so a member can find who to share with.
|
|
736
|
-
-- 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.
|
|
737
741
|
GRANT SHOW USERS ON *.* TO ${member};
|
|
738
742
|
|
|
739
743
|
-- \`memhouse relocate\` runs FROM the destination, pulling the source over remoteSecure() —
|
|
@@ -808,7 +812,7 @@ async function adminBootstrap(cfg, admin) {
|
|
|
808
812
|
password = generatePassword();
|
|
809
813
|
// For a local install the password must be SHOWN — it is the user's only copy. For
|
|
810
814
|
// an invite it must NOT: the caller writes it to the credential file, and printing
|
|
811
|
-
// 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
|
|
812
816
|
// memhouse itself ships. admin.quiet is the invite path.
|
|
813
817
|
if (!admin.quiet) {
|
|
814
818
|
console.log('');
|
|
@@ -839,7 +843,7 @@ async function adminBootstrap(cfg, admin) {
|
|
|
839
843
|
// create and evolve the rooms (--ensure-schema below).
|
|
840
844
|
try {
|
|
841
845
|
// ALL, WITH GRANT OPTION: the database is theirs, so they may do anything with their
|
|
842
|
-
// 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
|
|
843
847
|
// grants SELECT), but the owner is not boxed into read-only sharing of their own house.
|
|
844
848
|
// Scoped to their db: the grant option reaches nothing outside it, and no CREATE USER
|
|
845
849
|
// comes with it, so a member still cannot mint accounts or touch another house.
|
|
@@ -851,12 +855,12 @@ async function adminBootstrap(cfg, admin) {
|
|
|
851
855
|
// self-rotation.
|
|
852
856
|
try { await q(`GRANT ALTER USER ON ${admin.member} TO ${admin.member}`, { database: '' }); }
|
|
853
857
|
catch { /* admin without access-management: passwd stays admin-assisted for this member */ }
|
|
854
|
-
// 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
|
|
855
859
|
// ClickHouse (names only — no passwords, no data) so a member can find who to share
|
|
856
|
-
// 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.
|
|
857
861
|
// Best-effort like ALTER USER above — an admin without access-management just skips it.
|
|
858
862
|
try { await q(`GRANT SHOW USERS ON *.* TO ${admin.member}`, { database: '' }); }
|
|
859
|
-
catch { /* no access-management: /mem:
|
|
863
|
+
catch { /* no access-management: /mem:house section 4 stays admin-only for this member */ }
|
|
860
864
|
// REMOTE: `memhouse relocate` runs FROM the destination, pulling the source over
|
|
861
865
|
// remoteSecure() — a table function ClickHouse gates behind its own access type,
|
|
862
866
|
// separate from any GRANT ALL on a database. Without it every relocate a member runs
|
|
@@ -1143,14 +1147,29 @@ async function cmdInstall({ interactive }) {
|
|
|
1143
1147
|
// shipper or an installed service is still pointed at the old house, and rewriting the
|
|
1144
1148
|
// file orphans it with no error anywhere.
|
|
1145
1149
|
const prior = readEnvFile();
|
|
1146
|
-
|
|
1147
|
-
|
|
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) {
|
|
1148
1153
|
console.log(bad(`${ENV_FILE} already points at ${prior.MEMHOUSE_URL} / ${prior.MEMHOUSE_DB || '?'}`));
|
|
1149
1154
|
console.log(` installing over it would leave any running shipper or service on the old house.`);
|
|
1150
1155
|
console.log(' memhouse setup --url … --db … (move deliberately)');
|
|
1151
1156
|
console.log(' memhouse install --force … (overwrite anyway)');
|
|
1152
1157
|
return 1;
|
|
1153
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
|
+
}
|
|
1154
1173
|
// Preflight WITHOUT selecting the house — on a fresh standalone ClickHouse the
|
|
1155
1174
|
// database doesn't exist yet, and selecting it would fail before we can create it.
|
|
1156
1175
|
try { await ch(cfg, 'SELECT 1', { database: '' }); }
|
|
@@ -1224,6 +1243,28 @@ async function cmdInstall({ interactive }) {
|
|
|
1224
1243
|
// config file behind every failed attempt, and the next command reads it as truth.
|
|
1225
1244
|
writeEnvFile(cfg);
|
|
1226
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
|
+
}
|
|
1227
1268
|
// Mint this machine's identity here rather than leaving it to whatever runs first.
|
|
1228
1269
|
// Installing is the moment a machine joins the member's rooms, and the id is what every
|
|
1229
1270
|
// later `WHERE host = …` depends on — so it is worth naming once, out loud, at the point
|
|
@@ -1286,9 +1327,9 @@ function printGettingStarted(cfg) {
|
|
|
1286
1327
|
console.log(' memhouse start dashboard + shipper loop (background daemons)');
|
|
1287
1328
|
console.log(` -> http://localhost:${cfg.port || 4640} browse, search, and analyze every session`);
|
|
1288
1329
|
console.log(' memhouse service install or: ship at login, no terminal needed');
|
|
1289
|
-
console.log(' memhouse plugins install claude give your agents /mem:
|
|
1290
|
-
console.log(' /mem:
|
|
1291
|
-
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');
|
|
1292
1333
|
console.log(' memhouse search <terms> find a past conversation right now');
|
|
1293
1334
|
console.log(' memhouse doctor every line a check mark = healthy');
|
|
1294
1335
|
console.log(' The house keeps shipping as you work; nothing else to do.');
|
|
@@ -1348,7 +1389,7 @@ async function cmdOnboard() {
|
|
|
1348
1389
|
const targets = claudeTargets();
|
|
1349
1390
|
if (targets.length) {
|
|
1350
1391
|
console.log('');
|
|
1351
|
-
console.log(`Claude Code skills: ${
|
|
1392
|
+
console.log(`Claude Code skills: ${skillNames().map((n) => `/mem:${n}`).join(', ')}`);
|
|
1352
1393
|
const chosen = await chooseTargets(targets, 'Install into');
|
|
1353
1394
|
for (const t of chosen) console.log(ok(`installed skills into ${short(installPluginInto(t.dir))}`));
|
|
1354
1395
|
if (chosen.length) console.log(' they load next time that Claude Code starts');
|
|
@@ -2245,7 +2286,7 @@ async function cmdUpdate() {
|
|
|
2245
2286
|
// A pilot rarely has one. `~/.claude` is the stock install, `CLAUDE_CONFIG_DIR` points at
|
|
2246
2287
|
// whichever they are running right now, and Kommander-style playbooks live under
|
|
2247
2288
|
// `~/.claude-playbooks/<name>[/playbook]`, each a complete config directory with its own
|
|
2248
|
-
// skills/. Installing into one and calling it done leaves /mem:
|
|
2289
|
+
// skills/. Installing into one and calling it done leaves /mem:recall missing from
|
|
2249
2290
|
// every other instance the pilot uses — silently, because a missing skill does not announce
|
|
2250
2291
|
// itself, it just never appears.
|
|
2251
2292
|
//
|
|
@@ -2281,7 +2322,7 @@ const isPluginInstalled = (dir) => fs.existsSync(path.join(dir, PLUGIN_MARK));
|
|
|
2281
2322
|
|
|
2282
2323
|
function installPluginInto(dir) {
|
|
2283
2324
|
// The plugin was named `memhouse` until 0.10.0. A leftover copy under the old name
|
|
2284
|
-
// 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
|
|
2285
2326
|
// of them stale forever. Remove it only when it is provably OURS (it carries our
|
|
2286
2327
|
// plugin.json); a directory someone else named `memhouse` is not ours to delete.
|
|
2287
2328
|
const legacy = path.join(dir, 'skills', 'memhouse');
|
|
@@ -2297,7 +2338,7 @@ function installPluginInto(dir) {
|
|
|
2297
2338
|
const dst = path.join(dir, 'skills', 'mem');
|
|
2298
2339
|
// REPLACE, not overlay. cpSync over an existing install refreshes the skills and
|
|
2299
2340
|
// leaves anything else standing — a machine that once had a build with extra skills
|
|
2300
|
-
// kept offering /mem:replay and /mem:
|
|
2341
|
+
// kept offering /mem:replay and /mem:house forever, stale, beside the real ones.
|
|
2301
2342
|
// Ownership means OUR MANIFEST, checked by name — "any plugin.json" would have deleted
|
|
2302
2343
|
// an unrelated plugin that happened to pick the same directory name.
|
|
2303
2344
|
const dstManifest = path.join(dst, '.claude-plugin', 'plugin.json');
|
|
@@ -2351,15 +2392,27 @@ async function chooseTargets(targets, verb) {
|
|
|
2351
2392
|
//
|
|
2352
2393
|
// Claude Code gives a skill a namespace only when it arrives inside a plugin: a directory
|
|
2353
2394
|
// under <config>/skills/ containing .claude-plugin/plugin.json loads as
|
|
2354
|
-
// `mem@skills-dir` and its skills become /mem:
|
|
2395
|
+
// `mem@skills-dir` and its skills become /mem:recall,
|
|
2355
2396
|
// /mem:sql. Copied in flat, the same three files register as unrelated top-level
|
|
2356
2397
|
// skills named after their folders — which is what this used to do, while plugin.json sat
|
|
2357
2398
|
// unread one directory away claiming the colon form. Driving a real Claude Code is what
|
|
2358
|
-
// 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
|
+
|
|
2359
2412
|
async function cmdPlugins() {
|
|
2360
2413
|
const sub = positional[0] || 'list';
|
|
2361
2414
|
const pluginSrc = path.join(DELIVERY, 'plugin');
|
|
2362
|
-
const names =
|
|
2415
|
+
const names = skillNames(); // SKILL.md-bearing dirs only — `reference/` is not a skill
|
|
2363
2416
|
const invocations = names.map((n) => `/mem:${n}`).join(', ');
|
|
2364
2417
|
// --target overrides the discovery rather than joining it: given one, that is the only
|
|
2365
2418
|
// directory touched.
|
|
@@ -2949,7 +3002,7 @@ async function cmdNightly() {
|
|
|
2949
3002
|
* machine's config is never touched and nothing local ships. The server half is exactly
|
|
2950
3003
|
* adminBootstrap (create user if absent, create house, GRANT ALL + SELECT WITH GRANT
|
|
2951
3004
|
* OPTION, async pin, then verify AS THE MEMBER), so an invited member is
|
|
2952
|
-
* indistinguishable from one minted by a local admin install — /mem:
|
|
3005
|
+
* indistinguishable from one minted by a local admin install — /mem:access works for
|
|
2953
3006
|
* them on day one.
|
|
2954
3007
|
*
|
|
2955
3008
|
* The output file IS a credential. The header says so, the handoff advice names safe
|
|
@@ -2968,6 +3021,200 @@ async function cmdNightly() {
|
|
|
2968
3021
|
* can ask "is there an admin credential in this environment?" without inventing a place
|
|
2969
3022
|
* to store one. Nothing here prints or persists a password.
|
|
2970
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
|
+
|
|
2971
3218
|
async function cmdWhoami() {
|
|
2972
3219
|
const cfg = resolveConfig();
|
|
2973
3220
|
const wantAdmin = flags.admin === true;
|
|
@@ -3238,7 +3485,7 @@ async function cmdInvite() {
|
|
|
3238
3485
|
console.log(` hand it to ${name} over a channel you trust (croc, a password manager — not chat).`);
|
|
3239
3486
|
console.log(` they run: memhouse install --env ${path.basename(out)}`);
|
|
3240
3487
|
console.log(` then they should rotate the password you now both know: memhouse passwd`);
|
|
3241
|
-
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}`);
|
|
3242
3489
|
return 0;
|
|
3243
3490
|
}
|
|
3244
3491
|
|
|
@@ -3550,6 +3797,7 @@ async function cmdUninstall() {
|
|
|
3550
3797
|
}
|
|
3551
3798
|
break;
|
|
3552
3799
|
case 'reset': process.exitCode = await cmdReset(); break;
|
|
3800
|
+
case 'share': process.exitCode = await cmdShare(); break;
|
|
3553
3801
|
case 'whoami': process.exitCode = await cmdWhoami(); break;
|
|
3554
3802
|
case 'invite': process.exitCode = await cmdInvite(); break;
|
|
3555
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.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|