memhouse 0.18.6 → 0.18.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/memhouse.js
CHANGED
|
@@ -28,6 +28,7 @@ const {
|
|
|
28
28
|
SCHEMA_VERSION, MIN_WRITER_SCHEMA, MIGRATIONS, META_TYPES, createStatement,
|
|
29
29
|
} = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
|
|
30
30
|
const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
|
|
31
|
+
const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
|
|
31
32
|
const { consumeSecretChunk } = require(path.join(REPO_ROOT, 'memhouse', 'secret-input'));
|
|
32
33
|
const { capabilitiesFrom } = require(path.join(REPO_ROOT, 'memhouse', 'capabilities'));
|
|
33
34
|
const { unknownFlags, allowedFlags, suggestFlag } = require(path.join(REPO_ROOT, 'memhouse', 'flags'));
|
|
@@ -1612,6 +1613,7 @@ async function cmdStatus() {
|
|
|
1612
1613
|
daemons: { shipper: pidOf('shipper'), dashboard: pidOf('dashboard') },
|
|
1613
1614
|
dashboard_url: pidOf('dashboard') ? `http://localhost:${runningPort(cfg)}` : null,
|
|
1614
1615
|
shipper: shipperHealth(),
|
|
1616
|
+
update: null, // filled below from the registry; null when current, offline, or a checkout build
|
|
1615
1617
|
// Which machine this is, in the rooms' own terms. All of a member's machines write
|
|
1616
1618
|
// into one set of rooms, so `WHERE host = ...` is how the pilot separates this laptop
|
|
1617
1619
|
// from the other one — and they cannot type it if nothing ever prints it.
|
|
@@ -1628,6 +1630,10 @@ async function cmdStatus() {
|
|
|
1628
1630
|
out.messages = Number(m[0]?.msgs || 0);
|
|
1629
1631
|
out.freshest = m[0]?.freshest || null;
|
|
1630
1632
|
out.fleet = await fleetState(cfg);
|
|
1633
|
+
// Nobody runs `update --check`. A member sat four days behind a release that fixed the
|
|
1634
|
+
// very thing they were about to hit, with nothing on any screen saying so.
|
|
1635
|
+
try { out.update = chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }); }
|
|
1636
|
+
catch { /* offline: say nothing rather than guess */ }
|
|
1631
1637
|
} catch (e) { out.connected = false; out.error = netReason(e); }
|
|
1632
1638
|
|
|
1633
1639
|
if (JSON_OUT) { console.log(JSON.stringify(out, null, 2)); return out.connected ? 0 : 1; }
|
|
@@ -1673,6 +1679,7 @@ async function cmdStatus() {
|
|
|
1673
1679
|
: fail ? warn(`shipper: running — ${out.shipper.via} — but ${fail}`)
|
|
1674
1680
|
: ok(`shipper: running — ${out.shipper.via}`));
|
|
1675
1681
|
}
|
|
1682
|
+
if (out.update) console.log(warn(`update available: ${out.update.target} on '${out.update.channel}' — you have ${PKG.version} — run: memhouse update`));
|
|
1676
1683
|
console.log(out.daemons.dashboard ? ok(`dashboard: running (pid ${out.daemons.dashboard})`) : warn('dashboard: not running'));
|
|
1677
1684
|
if (out.daemons.dashboard) console.log(` dashboard → http://localhost:${runningPort(cfg)}`);
|
|
1678
1685
|
// Exit non-zero when the house is unreachable. `status` is what a health check, a
|
|
@@ -2022,6 +2029,12 @@ async function cmdDoctor() {
|
|
|
2022
2029
|
const moved = await adoptMovedHouse(cfg, { fix: false });
|
|
2023
2030
|
if (moved === 'moved') add(false, `rooms moved to mem.${physicalRoom('messages', cfg.user).replace(/_messages$/, '')}_* — this env still says MEMHOUSE_DB=${cfg.db}`, 'memhouse update (rewrites the env and restarts the shipper)');
|
|
2024
2031
|
} catch { /* connection problems are reported above */ }
|
|
2032
|
+
{
|
|
2033
|
+
// The same shape doctor gives everything else: what is wrong, and the command.
|
|
2034
|
+
let up = null;
|
|
2035
|
+
try { up = chan.behind({ version: PKG.version, pinned: cfg.channel || null, tags: await chan.fetchTags() }); } catch { /* offline */ }
|
|
2036
|
+
if (up) add(false, `memhouse ${PKG.version} — ${up.target} is out on '${up.channel}'`, 'memhouse update');
|
|
2037
|
+
}
|
|
2025
2038
|
const sh = shipperHealth();
|
|
2026
2039
|
const shFail = require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).describeFailure(sh.lastPass);
|
|
2027
2040
|
add(sh.running && !shFail, `shipper${sh.via ? ` — ${sh.via}` : ''}${shFail ? ` — ${shFail}` : ''}`,
|
|
@@ -2162,7 +2175,6 @@ function installKind() {
|
|
|
2162
2175
|
// Each of those has cost a real machine real sessions. This command owns all three.
|
|
2163
2176
|
async function cmdUpdate() {
|
|
2164
2177
|
const { kind, root } = installKind();
|
|
2165
|
-
const chan = require(path.join(REPO_ROOT, 'memhouse', 'channel'));
|
|
2166
2178
|
const cfg0 = resolveConfig();
|
|
2167
2179
|
// --channel pins for this run AND every later one: the point of pinning is that the next
|
|
2168
2180
|
// person to type `memhouse update` does not have to remember it.
|
package/memhouse/DESIGN.md
CHANGED
|
@@ -140,6 +140,20 @@ Config file: `~/.memhouse/config.json` (`hiddenProjects`, future prefs).
|
|
|
140
140
|
- Incremental shipping: read existing `(session_id, last_updated_at, message_count)`
|
|
141
141
|
(FINAL) and skip chats that haven't grown/changed; a full re-ship must remain
|
|
142
142
|
safe (ReplacingMergeTree collapses).
|
|
143
|
+
- **A session that is re-shipped sends only its tail.** Collapsing at FINAL makes a
|
|
144
|
+
re-ship *safe*, not *free*: a growing session that re-sent its whole transcript every
|
|
145
|
+
pass accumulated one full copy of itself per pass, and because every read carries
|
|
146
|
+
`final: 1`, readers pay to collapse all of them. Measured on a real 1,230-session
|
|
147
|
+
house: 1,226,770 stored message rows for 685,649 real ones. So once `decideEpoch` has
|
|
148
|
+
reused the epoch — which already proves nothing in the overlap changed — `tailRows`
|
|
149
|
+
drops every row whose stored `line_hash` matches the incoming one, and every tool call
|
|
150
|
+
matching on `(tool_name, args)`. A seq the house is missing has no stored hash, so a
|
|
151
|
+
pass that died mid-flush still repairs itself.
|
|
152
|
+
`tailSafe` withholds the tail path for the three shipped columns `line_hash` does not
|
|
153
|
+
cover and cannot derive: `ts` when the adapter supplies none (it is interpolated as
|
|
154
|
+
`seq / (total - 1)`, so every stored row's ts moves as the session grows), `host`, and
|
|
155
|
+
`folder`/`project`. Those sessions re-ship whole, as does `--full`, which stays the
|
|
156
|
+
repair hammer.
|
|
143
157
|
- **The shipper is insert-only, and nothing may reintroduce a destructive verb.**
|
|
144
158
|
`ReplacingMergeTree` collapses same-key rows but cannot remove a row the new parse no
|
|
145
159
|
longer produces, so a shrunken re-parse leaves a stale `seq` tail. The shipper used to
|
package/memhouse/channel.js
CHANGED
|
@@ -60,4 +60,27 @@ async function fetchTags(fetchImpl = globalThis.fetch) {
|
|
|
60
60
|
} catch { return null; }
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Is this install behind its channel? Pure — the caller supplies the tags, so nothing here
|
|
65
|
+
* reaches the network and `status` stays offline-safe.
|
|
66
|
+
*
|
|
67
|
+
* Only a strictly newer target counts: a prerelease, a version ahead of the tag, or a
|
|
68
|
+
* registry that did not answer all return null, because "unknown" must never read as
|
|
69
|
+
* "you are behind" on a health screen someone gates on.
|
|
70
|
+
*
|
|
71
|
+
* @returns {{target: string, channel: string}|null}
|
|
72
|
+
*/
|
|
73
|
+
function behind({ version, pinned = null, tags = null }) {
|
|
74
|
+
if (!tags) return null;
|
|
75
|
+
const pick = pickChannel({ version, pinned, tags });
|
|
76
|
+
if (!pick.target || pick.target === version) return null;
|
|
77
|
+
const n = (v) => String(v).split('-')[0].split('.').map(Number);
|
|
78
|
+
const [a, b] = [n(pick.target), n(version)];
|
|
79
|
+
for (let i = 0; i < 3; i++) {
|
|
80
|
+
if ((a[i] || 0) > (b[i] || 0)) return { target: pick.target, channel: pick.channel };
|
|
81
|
+
if ((a[i] || 0) < (b[i] || 0)) return null;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { behind, pickChannel, fetchTags, isPrerelease, lineOf };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mem",
|
|
3
3
|
"description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: recall past sessions and answer from them, run analytics, check the house, bring people in, and administer the server. Skills install as /mem:recall, /mem:sql, /mem:house, /mem:access, and /mem:admin.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.8",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
package/memhouse/shipper/ship.js
CHANGED
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
// memhouse is an accumulator, not a mirror: absence of a session is never a signal,
|
|
23
23
|
// and no "sync" or "prune" feature may ever be built here.
|
|
24
24
|
// - Re-shipping is always safe: ReplacingMergeTree(ingested_at) collapses to
|
|
25
|
-
// latest-wins at FINAL.
|
|
25
|
+
// latest-wins at FINAL. Safe is not free, though — see tailRows: a re-ship sends
|
|
26
|
+
// only the rows the house does not already hold byte for byte, because collapsing
|
|
27
|
+
// duplicates is work every reader pays for on every request.
|
|
28
|
+
// Keyed (session_id, user_id, origin, epoch, seq) on messages
|
|
26
29
|
// and (…, epoch, idx) on tool_calls, so an imported row and a shipped one at the same
|
|
27
30
|
// seq are two rows, and so are two parses of the same session. sessions is
|
|
28
31
|
// (session_id, user_id) with NO origin and NO epoch — one metadata row per session is
|
|
@@ -104,6 +107,16 @@ function chTs(ms) {
|
|
|
104
107
|
`${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
// Whether the adapter gave this message its own timestamp, or the shipper has to
|
|
111
|
+
// interpolate one. The distinction matters beyond accuracy: an interpolated ts is a
|
|
112
|
+
// function of `seq / (total - 1)`, so EVERY already-stored row's ts moves when the
|
|
113
|
+
// session grows. That is what forces a session with no per-message timestamps to
|
|
114
|
+
// re-ship whole rather than tail-only. See rowsForChat's `tsInterpolated` and tailSafe.
|
|
115
|
+
function hasOwnTs(msg) {
|
|
116
|
+
const at = Number(msg && msg._ts);
|
|
117
|
+
return Number.isFinite(at) && at > 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
107
120
|
// When a message was actually sent.
|
|
108
121
|
//
|
|
109
122
|
// Prefer the adapter's own `_ts` (epoch ms). It is optional by contract — `getMessages`
|
|
@@ -124,7 +137,7 @@ function messageTs(chat, seq, total, msg) {
|
|
|
124
137
|
// Seconds or milliseconds, depending on the format: goose stores seconds, Claude Code
|
|
125
138
|
// and opencode milliseconds. Real epoch-ms is > 1e12 and real epoch-seconds ~1.7e9, so
|
|
126
139
|
// the split point is unambiguous for any date this side of 1973.
|
|
127
|
-
if (
|
|
140
|
+
if (hasOwnTs(msg)) return chTs(at < 1e11 ? at * 1000 : at);
|
|
128
141
|
const start = chat.createdAt || chat.lastUpdatedAt || Date.now();
|
|
129
142
|
const end = chat.lastUpdatedAt || chat.createdAt || start;
|
|
130
143
|
if (total <= 1) return chTs(start);
|
|
@@ -747,7 +760,7 @@ async function assertWriterSupported(client, rooms) {
|
|
|
747
760
|
// must be decidable WITHOUT calling getMessages on every chat.
|
|
748
761
|
async function loadExisting(client, rooms) {
|
|
749
762
|
const rs = await client.query({
|
|
750
|
-
query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions_raw} FINAL WHERE user_id = currentUser()`,
|
|
763
|
+
query: `SELECT session_id, last_updated_at, message_count, extra, host, folder FROM ${rooms.sessions_raw} FINAL WHERE user_id = currentUser()`,
|
|
751
764
|
format: 'JSONEachRow',
|
|
752
765
|
});
|
|
753
766
|
// Actual message rows per session: an interrupted re-ship (a crash mid-flush on a
|
|
@@ -848,6 +861,11 @@ async function loadExisting(client, rooms) {
|
|
|
848
861
|
maxSeq: msgs.maxSeq,
|
|
849
862
|
maxIdx: tools.maxIdx,
|
|
850
863
|
hasRows: msgEpoch.has(r.session_id) || toolEpoch.has(r.session_id),
|
|
864
|
+
// Denormalized onto every message and tool_call row, and NOT covered by
|
|
865
|
+
// line_hash. A change in either means the stored rows are stale in a way the
|
|
866
|
+
// hash cannot see, so the session must re-ship whole. See tailRows.
|
|
867
|
+
host: String(r.host || ''),
|
|
868
|
+
folder: String(r.folder || ''),
|
|
851
869
|
});
|
|
852
870
|
}
|
|
853
871
|
return map;
|
|
@@ -903,8 +921,13 @@ function rowsForChat(chat, host) {
|
|
|
903
921
|
const msgRows = [];
|
|
904
922
|
const toolRows = [];
|
|
905
923
|
let idx = 0; // session-wide tool-call index
|
|
924
|
+
// Set as soon as one message has no timestamp of its own. One is enough: the
|
|
925
|
+
// interpolation denominator is the SESSION's length, so a single interpolated row
|
|
926
|
+
// means this session's stored timestamps drift the next time it grows.
|
|
927
|
+
let tsInterpolated = false;
|
|
906
928
|
for (let seq = 0; seq < total; seq++) {
|
|
907
929
|
const m = messages[seq];
|
|
930
|
+
if (!hasOwnTs(m)) tsInterpolated = true;
|
|
908
931
|
const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '';
|
|
909
932
|
const text = truncate(raw, TEXT_MAX);
|
|
910
933
|
const ts = messageTs(chat, seq, total, m);
|
|
@@ -969,7 +992,7 @@ function rowsForChat(chat, host) {
|
|
|
969
992
|
// message half; without the tool half a pass that dies between the messages insert and
|
|
970
993
|
// the tool_calls insert leaves a session that looks finished forever. See loadExisting.
|
|
971
994
|
session.extra.toolCallCount = toolRows.length;
|
|
972
|
-
return { session, msgRows, toolRows };
|
|
995
|
+
return { session, msgRows, toolRows, tsInterpolated };
|
|
973
996
|
}
|
|
974
997
|
|
|
975
998
|
/**
|
|
@@ -1042,6 +1065,78 @@ function decideEpoch(stored, incoming) {
|
|
|
1042
1065
|
return { epoch, reason: null };
|
|
1043
1066
|
}
|
|
1044
1067
|
|
|
1068
|
+
/**
|
|
1069
|
+
* Whether this session's already-stored rows can be left alone, or have to be rewritten.
|
|
1070
|
+
*
|
|
1071
|
+
* `line_hash` deliberately covers CONTENT only — session_id, seq, role, model, the four
|
|
1072
|
+
* token counts, text. Three shipped columns sit outside it and are not derived from
|
|
1073
|
+
* anything inside it, so a row can be stale in a way no hash comparison can see:
|
|
1074
|
+
*
|
|
1075
|
+
* ts. Interpolated across [createdAt, lastUpdatedAt] as `seq / (total - 1)` for the
|
|
1076
|
+
* adapters that record no per-message time. The denominator is the session's length,
|
|
1077
|
+
* so every stored row's ts moves the moment the session grows. Adapters that DO supply
|
|
1078
|
+
* `_ts` (Claude Code, opencode, goose, …) are exempt — their timestamps are facts, not
|
|
1079
|
+
* functions of the parse.
|
|
1080
|
+
*
|
|
1081
|
+
* host. The machine that shipped the row. Two machines shipping one session (a synced
|
|
1082
|
+
* home directory) must keep overwriting each other, as they do today.
|
|
1083
|
+
*
|
|
1084
|
+
* folder / project. Denormalized onto every message and tool_call row; a moved or
|
|
1085
|
+
* renamed project has to reach the rows that carry it.
|
|
1086
|
+
*
|
|
1087
|
+
* `source` cannot change (it is the session_id prefix), `is_subagent` is derived from
|
|
1088
|
+
* `text`, and messages' `extra` is always empty — so those three need no check.
|
|
1089
|
+
*
|
|
1090
|
+
* When this returns false the session ships whole, exactly as it always has. Pure.
|
|
1091
|
+
*/
|
|
1092
|
+
function tailSafe(prev, rows, host) {
|
|
1093
|
+
if (rows.tsInterpolated) return false;
|
|
1094
|
+
if (String(prev.host || '') !== String(host || '')) return false;
|
|
1095
|
+
return String(prev.folder || '') === String((rows.session && rows.session.folder) || '');
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Drop the rows the house already holds, byte for byte, and keep only what is new.
|
|
1100
|
+
*
|
|
1101
|
+
* A session that is still being written grows a few messages at a time, but the shipper
|
|
1102
|
+
* re-parses and re-sends the WHOLE transcript every pass. ReplacingMergeTree accepts all
|
|
1103
|
+
* of it — every already-stored row lands again under its own key as another version — so
|
|
1104
|
+
* an actively used session accumulates one full copy of itself per pass. Measured on a
|
|
1105
|
+
* real 1,230-session house: 1,226,770 stored rows for 685,649 real ones, 1.79 copies of
|
|
1106
|
+
* the average row and seven copies of the worst. Every read carries `final=1`, so the
|
|
1107
|
+
* dashboard pays for all of them on every request.
|
|
1108
|
+
*
|
|
1109
|
+
* The versions are not merely redundant, they are IDENTICAL: same key, same content.
|
|
1110
|
+
* decideEpoch has already established that (it forks the session to a new epoch the
|
|
1111
|
+
* moment anything in the overlap differs), so by the time this runs, a row whose stored
|
|
1112
|
+
* line_hash equals the incoming one cannot differ in any column line_hash covers.
|
|
1113
|
+
*
|
|
1114
|
+
* Pure, and exported, for the same reason decideEpoch is: it decides what does and does
|
|
1115
|
+
* not reach the house, and it is unit tested without a server.
|
|
1116
|
+
*
|
|
1117
|
+
* PRECONDITION, enforced by the caller: only valid when decideEpoch reused the epoch AND
|
|
1118
|
+
* the columns line_hash does NOT cover are stable for this session — see
|
|
1119
|
+
* `tsInterpolated`, `host` and `folder` in runShip. A gap in the overlap (a seq the house
|
|
1120
|
+
* is missing, from a pass that died mid-flush) has no stored hash, so it is kept and the
|
|
1121
|
+
* next pass repairs itself.
|
|
1122
|
+
*
|
|
1123
|
+
* @param {object} stored { hashes: Map<seq,string>, tools: Map<idx,{tool_name,args}> }
|
|
1124
|
+
* @param {object} incoming { msgRows, toolRows } as built by rowsForChat.
|
|
1125
|
+
* @returns {{ msgRows: object[], toolRows: object[] }} the subset worth sending.
|
|
1126
|
+
*/
|
|
1127
|
+
function tailRows(stored, incoming) {
|
|
1128
|
+
const hashes = stored.hashes || new Map();
|
|
1129
|
+
const tools = stored.tools || new Map();
|
|
1130
|
+
return {
|
|
1131
|
+
msgRows: incoming.msgRows.filter((r) => hashes.get(r.seq) !== String(r.line_hash)),
|
|
1132
|
+
// Tool calls carry no hash — same comparison decideEpoch makes.
|
|
1133
|
+
toolRows: incoming.toolRows.filter((r) => {
|
|
1134
|
+
const was = tools.get(r.idx);
|
|
1135
|
+
return !was || was.tool_name !== r.tool_name || was.args !== r.args;
|
|
1136
|
+
}),
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1045
1140
|
/**
|
|
1046
1141
|
* What the house currently holds for ONE session, at its current epoch — the input
|
|
1047
1142
|
* decideEpoch needs to tell an append from a rewrite.
|
|
@@ -1159,6 +1254,7 @@ async function runShip(client, opts = {}) {
|
|
|
1159
1254
|
|
|
1160
1255
|
const seen = new Set(); // adapters must not double-ship a session id within a pass
|
|
1161
1256
|
let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0, bumped = 0, withheld = 0;
|
|
1257
|
+
let msgRowsDeduped = 0, toolRowsDeduped = 0;
|
|
1162
1258
|
for (const chat of chats) {
|
|
1163
1259
|
if (chat.encrypted) continue;
|
|
1164
1260
|
// Same canonical '<source>:<adapter-local id>' as rowsForChat: dedup and
|
|
@@ -1235,6 +1331,9 @@ async function runShip(client, opts = {}) {
|
|
|
1235
1331
|
// default), so a shorter re-parse is at least as likely to mean "the source lost it"
|
|
1236
1332
|
// as "the adapter was fixed". The house is supposed to outlive the source.
|
|
1237
1333
|
let epoch = 0;
|
|
1334
|
+
// What actually gets sent. Defaults to the whole parse; narrowed to the tail below
|
|
1335
|
+
// when — and only when — every row it drops is already in the house unchanged.
|
|
1336
|
+
let send = rows;
|
|
1238
1337
|
if (prev && prev.hasRows) {
|
|
1239
1338
|
const stored = await loadStoredParse(client, rooms, id, rooms.user, prev.epoch);
|
|
1240
1339
|
const d = decideEpoch({ ...prev, ...stored }, rows);
|
|
@@ -1242,6 +1341,12 @@ async function runShip(client, opts = {}) {
|
|
|
1242
1341
|
if (d.reason) {
|
|
1243
1342
|
bumped++;
|
|
1244
1343
|
console.log(`[memhouse] ${id} → epoch ${epoch} (${d.reason}); the previous parse is kept`);
|
|
1344
|
+
} else if (!full && tailSafe(prev, rows, host)) {
|
|
1345
|
+
// Same epoch, and nothing outside line_hash's reach has moved: every row the
|
|
1346
|
+
// house already holds would land again byte-identical. Send the tail only.
|
|
1347
|
+
send = tailRows(stored, rows);
|
|
1348
|
+
msgRowsDeduped += rows.msgRows.length - send.msgRows.length;
|
|
1349
|
+
toolRowsDeduped += rows.toolRows.length - send.toolRows.length;
|
|
1245
1350
|
}
|
|
1246
1351
|
} else if (prev) {
|
|
1247
1352
|
epoch = toInt(prev.epoch);
|
|
@@ -1249,8 +1354,8 @@ async function runShip(client, opts = {}) {
|
|
|
1249
1354
|
rows.session.epoch = epoch;
|
|
1250
1355
|
await push('sessions', scrub(rows.session));
|
|
1251
1356
|
sessions++;
|
|
1252
|
-
for (const r of
|
|
1253
|
-
for (const r of
|
|
1357
|
+
for (const r of send.msgRows) { r.epoch = epoch; await push('messages', scrub(r)); msgRows++; }
|
|
1358
|
+
for (const r of send.toolRows) { r.epoch = epoch; await push('tool_calls', scrub(r)); toolRows++; }
|
|
1254
1359
|
}
|
|
1255
1360
|
for (const table of Object.keys(batches)) await flush(table);
|
|
1256
1361
|
// The dashboard's tables, recomputed from what this pass just wrote. Skipped on a pass
|
|
@@ -1259,7 +1364,7 @@ async function runShip(client, opts = {}) {
|
|
|
1259
1364
|
// Anything that only failed while reading messages — the sink is reset by the
|
|
1260
1365
|
// next getAllChats(), so unreported here means never reported at all.
|
|
1261
1366
|
reportAdapterErrors(warned);
|
|
1262
|
-
return { sessions, skipped, msgRows, toolRows, unreadable, bumped, withheld };
|
|
1367
|
+
return { sessions, skipped, msgRows, toolRows, unreadable, bumped, withheld, msgRowsDeduped, toolRowsDeduped };
|
|
1263
1368
|
}
|
|
1264
1369
|
|
|
1265
1370
|
// Warn once per adapter per pass. `warned` carries across the two call sites so one
|
|
@@ -1365,7 +1470,12 @@ async function main() {
|
|
|
1365
1470
|
+ `${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}`
|
|
1366
1471
|
+ `${r.withheld ? `, ${r.withheld} withheld-empty` : ''}`
|
|
1367
1472
|
+ `${r.bumped ? `, ${r.bumped} kept an earlier parse` : ''}) → ` +
|
|
1368
|
-
`${r.msgRows} msg rows, ${r.toolRows} tool rows
|
|
1473
|
+
`${r.msgRows} msg rows, ${r.toolRows} tool rows` +
|
|
1474
|
+
// What the tail-only path saved. Worth printing: it is the difference between a
|
|
1475
|
+
// house that grows with the conversation and one that grows with the passes.
|
|
1476
|
+
`${r.msgRowsDeduped || r.toolRowsDeduped
|
|
1477
|
+
? ` (${r.msgRowsDeduped + r.toolRowsDeduped} unchanged rows not re-sent)` : ''}` +
|
|
1478
|
+
` in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
1369
1479
|
} catch (e) {
|
|
1370
1480
|
failed = true;
|
|
1371
1481
|
var adoptedCredential = false;
|
|
@@ -1431,7 +1541,7 @@ async function main() {
|
|
|
1431
1541
|
}
|
|
1432
1542
|
}
|
|
1433
1543
|
|
|
1434
|
-
module.exports = { runShip, ensureSchema, refreshStats, templateColumns, decideEpoch };
|
|
1544
|
+
module.exports = { runShip, ensureSchema, refreshStats, templateColumns, decideEpoch, tailRows, tailSafe };
|
|
1435
1545
|
|
|
1436
1546
|
if (require.main === module) {
|
|
1437
1547
|
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.8",
|
|
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": {
|