memhouse 0.18.10 → 0.18.11
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/CHANGELOG.md +127 -0
- package/README.md +2 -2
- package/bin/memhouse.js +99 -43
- package/docs/guides/storage.md +8 -5
- package/docs/guides/uninstall.md +4 -2
- package/docs/releases/README.md +1 -0
- package/docs/releases/v0.18.11.md +191 -0
- package/editors/claude.js +68 -10
- package/memhouse/DESIGN.md +9 -2
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/delivery/plugin/reference/HOUSE.md +18 -6
- package/memhouse/delivery/plugin/skills/access/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/house/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +34 -14
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +1 -1
- package/memhouse/flags.js +28 -1
- package/memhouse/host.js +11 -6
- package/memhouse/house/HOUSE.md +1 -1
- package/memhouse/house/house.js +27 -1
- package/memhouse/invite-target.js +37 -0
- package/memhouse/server/queries.js +9 -4
- package/memhouse/shipper/ship.js +148 -31
- package/package.json +1 -1
- package/public/THIRD_PARTY_NOTICES.txt +2114 -0
- package/ui/vite.config.js +4 -1
package/memhouse/shipper/ship.js
CHANGED
|
@@ -66,7 +66,7 @@ const selfSnap = selfUpdate.snapshot(__filename);
|
|
|
66
66
|
const {
|
|
67
67
|
resolveRooms, READ_SETTINGS, ROOM_TYPES, META_TYPES, SCHEMA_VERSION, MEMBER_PIN, createStatement,
|
|
68
68
|
SUPPORTED_SCHEMAS, keyProblem, legacyTextIndexDialect, isTextIndexGrammarRefusal, textIndexDialectFor,
|
|
69
|
-
statCreateStatements, statRefreshStatements, statTableNames,
|
|
69
|
+
statCreateStatements, statRefreshStatements, statTableNames, statsNeedRefresh,
|
|
70
70
|
} = require('../house/house');
|
|
71
71
|
// Rooms plus the house's own record of itself. Every table the template declares, which
|
|
72
72
|
// is what the column healer and the drift warning have to cover — a column added to
|
|
@@ -109,7 +109,7 @@ function chTs(ms) {
|
|
|
109
109
|
|
|
110
110
|
// Whether the adapter gave this message its own timestamp, or the shipper has to
|
|
111
111
|
// interpolate one. The distinction matters beyond accuracy: an interpolated ts is a
|
|
112
|
-
// function of `
|
|
112
|
+
// function of `pos / (total - 1)` (position in the parse), so EVERY already-stored row's ts moves when the
|
|
113
113
|
// session grows. That is what forces a session with no per-message timestamps to
|
|
114
114
|
// re-ship whole rather than tail-only. See rowsForChat's `tsInterpolated` and tailSafe.
|
|
115
115
|
function hasOwnTs(msg) {
|
|
@@ -127,12 +127,12 @@ function hasOwnTs(msg) {
|
|
|
127
127
|
// and the search skill reported those to the user as fact.
|
|
128
128
|
//
|
|
129
129
|
// Fall back to interpolating across [createdAt, lastUpdatedAt] for the adapters that
|
|
130
|
-
// genuinely have nothing per message — monotonic
|
|
130
|
+
// genuinely have nothing per message — monotonic by position, so sessions_v started/ended
|
|
131
131
|
// still line up with the session bounds (same approximation as agency/ingest.js;
|
|
132
132
|
// documented in DESIGN.md). Real timestamps are NOT necessarily monotonic by seq — a
|
|
133
133
|
// folded subagent transcript is appended after its parent's turns but ran during them.
|
|
134
134
|
// Nothing depends on that: ordering is by `seq`, and started/ended are min/max.
|
|
135
|
-
function messageTs(chat,
|
|
135
|
+
function messageTs(chat, pos, total, msg) {
|
|
136
136
|
const at = Number(msg && msg._ts);
|
|
137
137
|
// Seconds or milliseconds, depending on the format: goose stores seconds, Claude Code
|
|
138
138
|
// and opencode milliseconds. Real epoch-ms is > 1e12 and real epoch-seconds ~1.7e9, so
|
|
@@ -141,7 +141,9 @@ function messageTs(chat, seq, total, msg) {
|
|
|
141
141
|
const start = chat.createdAt || chat.lastUpdatedAt || Date.now();
|
|
142
142
|
const end = chat.lastUpdatedAt || chat.createdAt || start;
|
|
143
143
|
if (total <= 1) return chTs(start);
|
|
144
|
-
|
|
144
|
+
// `pos` is the message's position in the parse, NOT its seq: a folded subagent's seq
|
|
145
|
+
// is sparse (see subagentSeq) and would put its interpolated time far past the session.
|
|
146
|
+
return chTs(start + Math.round((end - start) * (pos / (total - 1))));
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
/**
|
|
@@ -875,6 +877,42 @@ async function loadExisting(client, rooms) {
|
|
|
875
877
|
// m._toolCalls: matches '[tool-call: Name(' and '[tool-call: Name]' forms.
|
|
876
878
|
const TOOL_CALL_RE = /\[tool-call: ([^(\]]+)/g;
|
|
877
879
|
|
|
880
|
+
// Where a folded subagent's rows are numbered. A session's own turns keep the dense
|
|
881
|
+
// 0, 1, 2, … they always had; subagent number `slot` (see getMessages in
|
|
882
|
+
// editors/claude.js) owns the block
|
|
883
|
+
//
|
|
884
|
+
// seq = SUBAGENT_SEQ_BASE + slot * SUBAGENT_SEQ_STRIDE + turn
|
|
885
|
+
//
|
|
886
|
+
// and its tool calls the same block of `idx`, counted within that subagent. So a row's
|
|
887
|
+
// seq depends only on the subagent it belongs to and its position inside it — never on
|
|
888
|
+
// how long the parent is, how long any other subagent is, or how many there are.
|
|
889
|
+
//
|
|
890
|
+
// It used to be the row's position in the whole parse, with subagents folded in after the
|
|
891
|
+
// parent. One new parent turn then moved every subagent row down by one; decideEpoch saw
|
|
892
|
+
// stored rows whose line_hash (which covers seq) no longer matched, forked the session to
|
|
893
|
+
// a new epoch, and re-sent it whole — on every pass, for every session that had ever
|
|
894
|
+
// spawned a subagent. Measured on a real house: 1.07 M message rows in 24 h against 10.2 M
|
|
895
|
+
// in total, most of them full copies of five sessions, one stored 60 times in a day.
|
|
896
|
+
//
|
|
897
|
+
// Decimal so a seq reads at a glance (1,003,000,042 = subagent 3, turn 42), and sized for
|
|
898
|
+
// UInt32: 1e9 parent turns, 32,949 subagents of 100,000 turns each (the largest sessions
|
|
899
|
+
// today hold ~33 k parent lines and 35 subagents). A parse that does not fit falls back to
|
|
900
|
+
// positional numbering, which is correct and merely re-ships whole, as before.
|
|
901
|
+
const SUBAGENT_SEQ_BASE = 1000000000;
|
|
902
|
+
const SUBAGENT_SEQ_STRIDE = 100000;
|
|
903
|
+
const SUBAGENT_SLOTS = Math.floor((2 ** 32 - SUBAGENT_SEQ_BASE) / SUBAGENT_SEQ_STRIDE);
|
|
904
|
+
|
|
905
|
+
// The block a folded subagent turn belongs to, or null for the session's own turns and for
|
|
906
|
+
// adapters that fold nothing.
|
|
907
|
+
function subagentSlot(m) {
|
|
908
|
+
const a = m && m._agent;
|
|
909
|
+
return a && Number.isInteger(a.slot) && Number.isInteger(a.turn) ? a.slot : null;
|
|
910
|
+
}
|
|
911
|
+
function subagentSeq(slot, n) {
|
|
912
|
+
if (slot < 0 || slot >= SUBAGENT_SLOTS || n < 0 || n >= SUBAGENT_SEQ_STRIDE) return null;
|
|
913
|
+
return SUBAGENT_SEQ_BASE + slot * SUBAGENT_SEQ_STRIDE + n;
|
|
914
|
+
}
|
|
915
|
+
|
|
878
916
|
// Build the typed rows for one chat. Returns null when the chat is unreadable
|
|
879
917
|
// (adapter threw — e.g. a partially-written or locked session file): the caller
|
|
880
918
|
// must write NOTHING for it, so the next incremental pass retries. A chat that
|
|
@@ -891,6 +929,13 @@ function rowsForChat(chat, host) {
|
|
|
891
929
|
adapterErrorSink.record(chat.source, e, chat.composerId);
|
|
892
930
|
return null;
|
|
893
931
|
}
|
|
932
|
+
// The adapter asked to wait a pass: what it read is not yet the session's settled shape
|
|
933
|
+
// (a subagent with no timestamped line — see editors/claude.js). Not an error; the next
|
|
934
|
+
// pass reads it again.
|
|
935
|
+
if (messages._defer) {
|
|
936
|
+
console.log(`[memhouse] ${chat.source}:${chat.composerId} held for one pass — ${messages._defer}`);
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
894
939
|
|
|
895
940
|
const source = chat.source;
|
|
896
941
|
// Canonical globally-unique session id: '<source>:<adapter-local id>'.
|
|
@@ -918,19 +963,47 @@ function rowsForChat(chat, host) {
|
|
|
918
963
|
extra: { bubbleCount: chat.bubbleCount || 0 },
|
|
919
964
|
};
|
|
920
965
|
|
|
966
|
+
// Stable numbering first; positional if the parse does not fit it (see subagentSeq).
|
|
967
|
+
const built = buildRows(chat, messages, { id, source, host, folder, project }, true)
|
|
968
|
+
|| buildRows(chat, messages, { id, source, host, folder, project }, false);
|
|
969
|
+
// What a complete ship of this session looks like, recorded ON the session row so the
|
|
970
|
+
// next pass can tell "finished" from "got part way". message_count already carries the
|
|
971
|
+
// message half; without the tool half a pass that dies between the messages insert and
|
|
972
|
+
// the tool_calls insert leaves a session that looks finished forever. See loadExisting.
|
|
973
|
+
session.extra.toolCallCount = built.toolRows.length;
|
|
974
|
+
return { session, ...built };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// The message and tool_call rows for one parse. `stable` numbers folded subagent rows by
|
|
978
|
+
// their slot (subagentSeq) and returns null if any of them does not fit; otherwise every
|
|
979
|
+
// row is numbered by its position, the pre-0.18.10 scheme. For a session with no subagents
|
|
980
|
+
// the two are identical.
|
|
981
|
+
function buildRows(chat, messages, { id, source, host, folder, project }, stable) {
|
|
982
|
+
const total = messages.length;
|
|
921
983
|
const msgRows = [];
|
|
922
984
|
const toolRows = [];
|
|
923
|
-
let
|
|
985
|
+
let seqNext = 0; // the session's own turns, dense from 0
|
|
986
|
+
let idx = 0; // the session's own tool calls, dense from 0
|
|
987
|
+
const subIdx = new Map(); // slot → tool calls seen so far in that subagent
|
|
924
988
|
// Set as soon as one message has no timestamp of its own. One is enough: the
|
|
925
989
|
// interpolation denominator is the SESSION's length, so a single interpolated row
|
|
926
990
|
// means this session's stored timestamps drift the next time it grows.
|
|
927
991
|
let tsInterpolated = false;
|
|
928
|
-
for (let
|
|
929
|
-
const m = messages[
|
|
992
|
+
for (let pos = 0; pos < total; pos++) {
|
|
993
|
+
const m = messages[pos];
|
|
994
|
+
const slot = stable ? subagentSlot(m) : null;
|
|
995
|
+
let seq;
|
|
996
|
+
if (slot === null) {
|
|
997
|
+
if (stable && seqNext >= SUBAGENT_SEQ_BASE) return null;
|
|
998
|
+
seq = seqNext++;
|
|
999
|
+
} else {
|
|
1000
|
+
seq = subagentSeq(slot, m._agent.turn);
|
|
1001
|
+
if (seq === null) return null;
|
|
1002
|
+
}
|
|
930
1003
|
if (!hasOwnTs(m)) tsInterpolated = true;
|
|
931
1004
|
const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '';
|
|
932
1005
|
const text = truncate(raw, TEXT_MAX);
|
|
933
|
-
const ts = messageTs(chat,
|
|
1006
|
+
const ts = messageTs(chat, pos, total, m);
|
|
934
1007
|
const row = {
|
|
935
1008
|
session_id: id,
|
|
936
1009
|
seq,
|
|
@@ -973,10 +1046,18 @@ function rowsForChat(chat, host) {
|
|
|
973
1046
|
}
|
|
974
1047
|
for (const tc of calls) {
|
|
975
1048
|
const args = JSON.stringify(tc.args);
|
|
1049
|
+
let at;
|
|
1050
|
+
if (slot === null) at = idx++;
|
|
1051
|
+
else {
|
|
1052
|
+
const n = subIdx.get(slot) || 0;
|
|
1053
|
+
subIdx.set(slot, n + 1);
|
|
1054
|
+
at = subagentSeq(slot, n);
|
|
1055
|
+
if (at === null) return null;
|
|
1056
|
+
}
|
|
976
1057
|
toolRows.push({
|
|
977
1058
|
session_id: id,
|
|
978
1059
|
seq,
|
|
979
|
-
idx:
|
|
1060
|
+
idx: at,
|
|
980
1061
|
source,
|
|
981
1062
|
host,
|
|
982
1063
|
tool_name: tc.name,
|
|
@@ -987,12 +1068,7 @@ function rowsForChat(chat, host) {
|
|
|
987
1068
|
});
|
|
988
1069
|
}
|
|
989
1070
|
}
|
|
990
|
-
|
|
991
|
-
// next pass can tell "finished" from "got part way". message_count already carries the
|
|
992
|
-
// message half; without the tool half a pass that dies between the messages insert and
|
|
993
|
-
// the tool_calls insert leaves a session that looks finished forever. See loadExisting.
|
|
994
|
-
session.extra.toolCallCount = toolRows.length;
|
|
995
|
-
return { session, msgRows, toolRows, tsInterpolated };
|
|
1071
|
+
return { msgRows, toolRows, tsInterpolated };
|
|
996
1072
|
}
|
|
997
1073
|
|
|
998
1074
|
/**
|
|
@@ -1037,26 +1113,43 @@ function decideEpoch(stored, incoming) {
|
|
|
1037
1113
|
|
|
1038
1114
|
const maxSeq = Number.isFinite(stored.maxSeq) ? stored.maxSeq : -1;
|
|
1039
1115
|
const maxIdx = Number.isFinite(stored.maxIdx) ? stored.maxIdx : -1;
|
|
1040
|
-
|
|
1041
|
-
|
|
1116
|
+
const hashes = stored.hashes || new Map();
|
|
1117
|
+
const tools = stored.tools || new Map();
|
|
1118
|
+
|
|
1119
|
+
// SHRINK is "the house holds a row this parse does not produce", checked per key. It
|
|
1120
|
+
// used to be `parsed - 1 < maxSeq`, which is the same question only while seq is dense
|
|
1121
|
+
// 0..N-1; a folded subagent's rows are numbered from a sparse block (subagentSeq), so a
|
|
1122
|
+
// vanished subagent or a stale pre-0.18.10 numbering leaves stored keys inside the range
|
|
1123
|
+
// that nothing overwrites. The max comparison stays for a caller holding no hashes.
|
|
1124
|
+
const orphans = (have, rows, key, max) => {
|
|
1125
|
+
const want = new Set(rows.map((r) => r[key]));
|
|
1126
|
+
let missing = 0;
|
|
1127
|
+
for (const k of have.keys()) if (!want.has(k)) missing++;
|
|
1128
|
+
const inMax = rows.reduce((a, r) => Math.max(a, r[key]), -1);
|
|
1129
|
+
// How many the house holds, for the log line. `max + 1` is a count only while keys are
|
|
1130
|
+
// dense; with subagent blocks max is ~1e9, so it said "1000000123 messages stored".
|
|
1131
|
+
return { missing, beyond: max > inMax, stored: have.size || max + 1 };
|
|
1132
|
+
};
|
|
1133
|
+
const mo = orphans(hashes, incoming.msgRows, 'seq', maxSeq);
|
|
1134
|
+
if (mo.missing || mo.beyond) {
|
|
1135
|
+
return bump(`${mo.stored} messages stored, ${incoming.msgRows.length} parsed${mo.missing ? `, ${mo.missing} of them not in the new parse` : ''}`);
|
|
1042
1136
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1137
|
+
const to = orphans(tools, incoming.toolRows, 'idx', maxIdx);
|
|
1138
|
+
if (to.missing || to.beyond) {
|
|
1139
|
+
return bump(`${to.stored} tool calls stored, ${incoming.toolRows.length} parsed${to.missing ? `, ${to.missing} of them not in the new parse` : ''}`);
|
|
1045
1140
|
}
|
|
1046
1141
|
|
|
1047
1142
|
// A seq the house does not hold is a gap, not a disagreement — there is nothing there
|
|
1048
1143
|
// to destroy, so it is not a reason to fork the session.
|
|
1049
|
-
const hashes = stored.hashes || new Map();
|
|
1050
1144
|
for (const row of incoming.msgRows) {
|
|
1051
|
-
if (row.seq > maxSeq)
|
|
1145
|
+
if (row.seq > maxSeq) continue;
|
|
1052
1146
|
const was = hashes.get(row.seq);
|
|
1053
1147
|
if (was !== undefined && was !== String(row.line_hash)) {
|
|
1054
1148
|
return bump(`message ${row.seq} was rewritten`);
|
|
1055
1149
|
}
|
|
1056
1150
|
}
|
|
1057
|
-
const tools = stored.tools || new Map();
|
|
1058
1151
|
for (const row of incoming.toolRows) {
|
|
1059
|
-
if (row.idx > maxIdx)
|
|
1152
|
+
if (row.idx > maxIdx) continue;
|
|
1060
1153
|
const was = tools.get(row.idx);
|
|
1061
1154
|
if (was !== undefined && (was.tool_name !== row.tool_name || was.args !== row.args)) {
|
|
1062
1155
|
return bump(`tool call ${row.idx} was rewritten`);
|
|
@@ -1072,7 +1165,7 @@ function decideEpoch(stored, incoming) {
|
|
|
1072
1165
|
* token counts, text. Three shipped columns sit outside it and are not derived from
|
|
1073
1166
|
* anything inside it, so a row can be stale in a way no hash comparison can see:
|
|
1074
1167
|
*
|
|
1075
|
-
* ts. Interpolated across [createdAt, lastUpdatedAt] as `
|
|
1168
|
+
* ts. Interpolated across [createdAt, lastUpdatedAt] as `pos / (total - 1)` (position in the parse, not seq) for the
|
|
1076
1169
|
* adapters that record no per-message time. The denominator is the session's length,
|
|
1077
1170
|
* so every stored row's ts moves the moment the session grows. Adapters that DO supply
|
|
1078
1171
|
* `_ts` (Claude Code, opencode, goose, …) are exempt — their timestamps are facts, not
|
|
@@ -1084,8 +1177,11 @@ function decideEpoch(stored, incoming) {
|
|
|
1084
1177
|
* folder / project. Denormalized onto every message and tool_call row; a moved or
|
|
1085
1178
|
* renamed project has to reach the rows that carry it.
|
|
1086
1179
|
*
|
|
1087
|
-
* `source` cannot change (it is the session_id prefix)
|
|
1088
|
-
* `text`,
|
|
1180
|
+
* `source` cannot change (it is the session_id prefix) and `is_subagent` is derived from
|
|
1181
|
+
* `text`, so neither needs a check. Messages' `extra` carries a folded subagent's
|
|
1182
|
+
* `agent` stamp; its id and turn are fixed by the row's seq, and a description the parent
|
|
1183
|
+
* supplies only later (a background fork named by its task notification) is not worth
|
|
1184
|
+
* re-shipping a session for.
|
|
1089
1185
|
*
|
|
1090
1186
|
* When this returns false the session ships whole, exactly as it always has. Pure.
|
|
1091
1187
|
*/
|
|
@@ -1169,6 +1265,24 @@ async function loadStoredParse(client, rooms, id, uid, epoch) {
|
|
|
1169
1265
|
return { hashes, tools };
|
|
1170
1266
|
}
|
|
1171
1267
|
|
|
1268
|
+
/**
|
|
1269
|
+
* How old the newest stats generation is, in ms. Infinity when the table is empty (a TTL
|
|
1270
|
+
* emptied it, or no refresh ever landed); null when it cannot be read at all, so a house
|
|
1271
|
+
* without the tables is not refreshed on every idle pass. Cheap: count() is metadata and
|
|
1272
|
+
* refreshed_at leads the sorting key.
|
|
1273
|
+
*/
|
|
1274
|
+
async function statsAgeMs(client, rooms) {
|
|
1275
|
+
try {
|
|
1276
|
+
const t = statTableNames(rooms.member).session_stats;
|
|
1277
|
+
const rs = await client.query({
|
|
1278
|
+
query: `SELECT count() AS n, dateDiff('second', max(refreshed_at), now64(3)) AS age FROM ${t}`,
|
|
1279
|
+
format: 'JSONEachRow',
|
|
1280
|
+
});
|
|
1281
|
+
const [r] = await rs.json();
|
|
1282
|
+
return Number(r.n) > 0 ? Number(r.age) * 1000 : Infinity;
|
|
1283
|
+
} catch { return null; }
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1172
1286
|
/**
|
|
1173
1287
|
* Recompute the three stat tables — one generation, one INSERT each, all stamped with the
|
|
1174
1288
|
* same refreshed_at. Runs at the end of every ship pass, which is exactly the cadence a
|
|
@@ -1358,9 +1472,12 @@ async function runShip(client, opts = {}) {
|
|
|
1358
1472
|
for (const r of send.toolRows) { r.epoch = epoch; await push('tool_calls', scrub(r)); toolRows++; }
|
|
1359
1473
|
}
|
|
1360
1474
|
for (const table of Object.keys(batches)) await flush(table);
|
|
1361
|
-
// The dashboard's tables, recomputed from what this pass just wrote.
|
|
1362
|
-
//
|
|
1363
|
-
|
|
1475
|
+
// The dashboard's tables, recomputed from what this pass just wrote. A pass that shipped
|
|
1476
|
+
// nothing leaves an exact generation alone — unless it is getting old: the tables carry a
|
|
1477
|
+
// one-day TTL, and a quiet house used to age out of its dashboard entirely (G11).
|
|
1478
|
+
if (statsNeedRefresh({ shipped: sessions, ageMs: sessions > 0 ? 0 : await statsAgeMs(client, rooms) })) {
|
|
1479
|
+
await refreshStats(client, rooms);
|
|
1480
|
+
}
|
|
1364
1481
|
// Anything that only failed while reading messages — the sink is reset by the
|
|
1365
1482
|
// next getAllChats(), so unreported here means never reported at all.
|
|
1366
1483
|
reportAdapterErrors(warned);
|
|
@@ -1541,7 +1658,7 @@ async function main() {
|
|
|
1541
1658
|
}
|
|
1542
1659
|
}
|
|
1543
1660
|
|
|
1544
|
-
module.exports = { runShip, ensureSchema, refreshStats, templateColumns, decideEpoch, tailRows, tailSafe };
|
|
1661
|
+
module.exports = { runShip, ensureSchema, refreshStats, templateColumns, decideEpoch, tailRows, tailSafe, rowsForChat, buildRows, SUBAGENT_SEQ_BASE, SUBAGENT_SEQ_STRIDE };
|
|
1545
1662
|
|
|
1546
1663
|
if (require.main === module) {
|
|
1547
1664
|
main().catch((e) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memhouse",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.11",
|
|
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": {
|