memhouse 0.20.0 → 0.20.2
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 +121 -0
- package/bin/memhouse.js +209 -30
- package/docs/guides/install.md +3 -0
- package/docs/guides/invite.md +7 -0
- package/docs/guides/search-and-resume.md +30 -0
- package/docs/guides/share.md +5 -0
- package/docs/releases/README.md +2 -0
- package/docs/releases/v0.20.1.md +114 -0
- package/docs/releases/v0.20.2.md +134 -0
- package/memhouse/codex-plugin.js +194 -0
- package/memhouse/delivery/INVITATION.md.tpl +1 -1
- package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
- package/memhouse/delivery/codex/SKILL.md +50 -0
- package/memhouse/delivery/plugin/reference/HOUSE.md +7 -9
- package/memhouse/delivery/plugin/skills/access/SKILL.md +1 -1
- package/memhouse/flags.js +1 -1
- package/memhouse/help.js +37 -0
- package/memhouse/house/q-cli.js +14 -1
- package/memhouse/house/records-to-profile.js +14 -3
- package/memhouse/house/schema-choice.js +15 -6
- package/memhouse/house/schema-profile-views.sql.tpl +53 -0
- package/memhouse/house/schema-profile.js +46 -6
- package/memhouse/house/schema-profile.sql.tpl +8 -1
- package/memhouse/invitation.js +8 -2
- package/memhouse/plugin-copies.js +13 -1
- package/memhouse/release-hint.js +24 -0
- package/memhouse/share.js +36 -4
- package/memhouse/shipper/capture.js +39 -9
- package/memhouse/shipper/log-adapter.js +18 -7
- package/memhouse/shipper/normalise-run.js +8 -2
- package/memhouse/version-notice.js +66 -0
- package/package.json +3 -3
- package/public/assets/{index-CQSJ3nhV.js → index-CcNnhfM3.js} +1 -1
- package/public/index.html +1 -1
- package/ui/package-lock.json +264 -318
- package/ui/package.json +5 -4
- package/ui/src/lib/dashboard-token.js +7 -2
package/memhouse/flags.js
CHANGED
|
@@ -43,7 +43,7 @@ const COMMAND_FLAGS = {
|
|
|
43
43
|
update: ['check', 'migrate', 'no-install', 'channel'],
|
|
44
44
|
reset: ['all-origins'],
|
|
45
45
|
invite: ['url', 'db', 'out', 'admin-user', 'admin-password-file', 'adopt', 'print-sql',
|
|
46
|
-
'allow-local', 'member-password', 'member-password-file', 'schema'],
|
|
46
|
+
'allow-local', 'member-password', 'member-password-file', 'schema', 'expires'],
|
|
47
47
|
passwd: ['password', 'password-file', 'admin-user', 'admin-password-file', 'print-sql', 'member'],
|
|
48
48
|
nightly: ['out'],
|
|
49
49
|
migrate: ['dry-run', 'v3-prepare', 'v3-convert', 'v3-reconcile', 'v3-shares', 'v3-switch', 'member', 'batch-rows', 'force-after-dedupe',
|
package/memhouse/help.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// `memhouse <command> --help`: that command's entries from the full help (bin/memhouse.js HELP),
|
|
2
|
+
// not the whole page. The full help stays the one text: an entry starts in column 13 (after the
|
|
3
|
+
// section label or 13 spaces), and its continuation lines are indented further. Pure.
|
|
4
|
+
|
|
5
|
+
const COL = 13;
|
|
6
|
+
|
|
7
|
+
/** The entries of `help`: [{ words, lines }], `words` being the command names the entry is for. */
|
|
8
|
+
function entries(help) {
|
|
9
|
+
const out = [];
|
|
10
|
+
let cur = null;
|
|
11
|
+
for (const line of String(help).split('\n')) {
|
|
12
|
+
const m = /^([A-Za-z]*)( +)(\S.*)$/.exec(line);
|
|
13
|
+
if (m && m[1].length + m[2].length === COL) {
|
|
14
|
+
// `start | stop | status …` is one entry for three commands.
|
|
15
|
+
const name = m[3].split(/\s{2,}/)[0];
|
|
16
|
+
const words = /^[a-z][a-z-]*( \| [a-z][a-z-]*)+/.test(name)
|
|
17
|
+
? name.split(/\s*\|\s*/).map((w) => w.split(/\s+/)[0]).filter(Boolean)
|
|
18
|
+
: [name.split(/\s+/)[0]];
|
|
19
|
+
cur = { words, lines: [`${' '.repeat(COL)}${m[3]}`] };
|
|
20
|
+
out.push(cur);
|
|
21
|
+
} else if (cur && /^ {20,}\S/.test(line)) {
|
|
22
|
+
cur.lines.push(line);
|
|
23
|
+
} else {
|
|
24
|
+
cur = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The help for `cmd`, or null when no entry names it (the caller prints the full help). */
|
|
31
|
+
function helpFor(help, cmd, version) {
|
|
32
|
+
const mine = entries(help).filter((e) => e.words.includes(cmd));
|
|
33
|
+
if (!mine.length) return null;
|
|
34
|
+
return [`memhouse ${version} — ${cmd}`, '', ...mine.flatMap((e) => e.lines), '', 'Every command: memhouse --help'].join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { entries, helpFor };
|
package/memhouse/house/q-cli.js
CHANGED
|
@@ -741,6 +741,19 @@ function hasFormat(sql) {
|
|
|
741
741
|
return /\bFORMAT\s+[A-Za-z0-9_]+\s*$/i.test(s);
|
|
742
742
|
}
|
|
743
743
|
|
|
744
|
+
/**
|
|
745
|
+
* What `memhouse room-sql` sends: the statement as written, and the output format as the
|
|
746
|
+
* default_format setting. A FORMAT appended to the statement fails where the statement takes
|
|
747
|
+
* none (ClickHouse 25.11 parses no FORMAT after a bare SHOW ROW POLICIES); a setting applies to
|
|
748
|
+
* every statement, and a FORMAT the statement names itself still wins. --json picks the format,
|
|
749
|
+
* so a statement that names one is refused there.
|
|
750
|
+
*/
|
|
751
|
+
function roomSqlRequest(sql, { json = false } = {}) {
|
|
752
|
+
const body = String(sql).trim().replace(/;\s*$/, '');
|
|
753
|
+
if (json && hasFormat(body)) throw new QueryInputError('--json picks the format itself — drop the FORMAT clause');
|
|
754
|
+
return { body, settings: { default_format: json ? 'JSONEachRow' : 'PrettyCompact' } };
|
|
755
|
+
}
|
|
756
|
+
|
|
744
757
|
/** Refuse what is not a read before it leaves the machine (the server's readonly=2 refuses it too). */
|
|
745
758
|
function assertRead(sql) {
|
|
746
759
|
const s = String(sql).replace(/--[^\n]*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
|
@@ -858,6 +871,6 @@ module.exports = {
|
|
|
858
871
|
renderSchemaBlocks,
|
|
859
872
|
NAMES, ALIASES, FILTER_FLAGS, QueryInputError,
|
|
860
873
|
schemaOf, httpTransport, fmtTop, fmtInner, parseWhen, filtersFrom, parseRef, parseQ, runQ, renderQ, allQFlags, canonicalName,
|
|
861
|
-
resolveRef, hitSpec, lookupSpec, withIso, rewriteRooms, hasFormat, assertRead, statsV3, renderStats, statusV3, statusSpec, machinesFolded,
|
|
874
|
+
resolveRef, hitSpec, lookupSpec, withIso, rewriteRooms, hasFormat, roomSqlRequest, assertRead, statsV3, renderStats, statusV3, statusSpec, machinesFolded,
|
|
862
875
|
sessionsQueryV3, resumeSpec, table,
|
|
863
876
|
};
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
// record without content_sha and cmp_sha) on every row; on a part row, the sha of the part object
|
|
10
10
|
// as it sits in its message's parts, with message_content_sha the message's. A message's header
|
|
11
11
|
// row (part_id '', kind 'none') carries the message's sha in both.
|
|
12
|
+
//
|
|
13
|
+
// native and units (harness-sessions-sql v1.1, sessions-model §2.7): a record's `native` as
|
|
14
|
+
// canonical JSON text, its `units` as (source_key, generation, unit_key) tuples. A part row carries
|
|
15
|
+
// its message's (message_native, message_units) and its own part_native. A record without them
|
|
16
|
+
// (an adapter older than sessions-model v1.4) leaves the columns out, so the server's defaults
|
|
17
|
+
// ('' and []) stand and a row mapped before them keeps its digest.
|
|
12
18
|
|
|
13
19
|
const crypto = require('crypto');
|
|
14
20
|
|
|
@@ -30,6 +36,10 @@ const str = (v) => (v == null ? '' : String(v));
|
|
|
30
36
|
const nb = (v) => (typeof v === 'boolean' ? v : null);
|
|
31
37
|
const nn = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null);
|
|
32
38
|
const tsOrNull = (v) => (v && Number.isFinite(Date.parse(v)) ? v : null);
|
|
39
|
+
/** A record's native as its column holds it: canonical JSON, or undefined (the column's default) when absent. */
|
|
40
|
+
const nativeText = (v) => (v != null && typeof v === 'object' ? jcs(v) : undefined);
|
|
41
|
+
/** A record's units as named tuples, or undefined (the column's default) when the record states none. */
|
|
42
|
+
const unitsOf = (v) => (Array.isArray(v) ? v.map(([sourceKey, generation, unitKey]) => ({ source_key: str(sourceKey), generation: Number(generation) || 0, unit_key: str(unitKey) })) : undefined);
|
|
33
43
|
|
|
34
44
|
/** The part columns of one part. */
|
|
35
45
|
function partColumns(p) {
|
|
@@ -39,7 +49,7 @@ function partColumns(p) {
|
|
|
39
49
|
call_id: '', tool_name: '', args: '', server: '', is_error: null, result_status: '', output: '', output_ref: '', output_ref_sha256: '', output_ref_bytes: null,
|
|
40
50
|
output_len: null, truncated: null, exit_code: null, media_type: '', inline_b64: '', ref: '', bytes_len: null, opaque: null, signature_len: null,
|
|
41
51
|
replaces_form: '', replaces_value: '', error_kind: '', error_message: '', http_status: null, level: '', native_type: '',
|
|
42
|
-
part_source: str(p.source), part_state: str(p.state),
|
|
52
|
+
part_source: str(p.source), part_state: str(p.state), part_native: nativeText(p.native),
|
|
43
53
|
};
|
|
44
54
|
switch (p.kind) {
|
|
45
55
|
case 'text': case 'notice': case 'summary': case 'reasoning':
|
|
@@ -99,7 +109,8 @@ function profileRows(records, key, { firstSeen = () => '1970-01-01T00:00:00.000Z
|
|
|
99
109
|
source: str(m.source), to_user: nb(m.to_user), to_model: nb(m.to_model), status: str(m.status), state: str(m.state), on_active_branch: nb(m.on_active_branch),
|
|
100
110
|
ts, requested_model: str(m.requested_model), served_model: str(m.served_model), effort: str(m.effort), response_id: str(m.response_id),
|
|
101
111
|
message_aliases: Array.isArray(m.aliases) ? m.aliases.map((a) => ({ id: str(a.id), kind: str(a.kind) })) : [],
|
|
102
|
-
cmp_sha: str(m.cmp_sha), message_content_sha: msgSha,
|
|
112
|
+
cmp_sha: str(m.cmp_sha), message_content_sha: msgSha, message_native: nativeText(m.native), message_units: unitsOf(m.units),
|
|
113
|
+
first_seen: firstSeen(str(m.message_id)),
|
|
103
114
|
};
|
|
104
115
|
const ps = Array.isArray(m.parts) ? m.parts : [];
|
|
105
116
|
ps.forEach((p, i) => {
|
|
@@ -159,7 +170,7 @@ function profileRows(records, key, { firstSeen = () => '1970-01-01T00:00:00.000Z
|
|
|
159
170
|
mode: str(s.mode), kind: str(s.kind), reuse: str(s.reuse), ts_source: str(s.ts_source),
|
|
160
171
|
aliases: Array.isArray(s.aliases) ? s.aliases.map((a) => ({ id: str(a.id), kind: str(a.kind) })) : [],
|
|
161
172
|
fingerprint: s.fingerprint && Array.isArray(s.fingerprint.cmp_shas) ? s.fingerprint.cmp_shas.map(String) : [],
|
|
162
|
-
setup: s.setup == null ? null : jcs(s.setup), content_sha: recordSha(s),
|
|
173
|
+
setup: s.setup == null ? null : jcs(s.setup), native: nativeText(s.native), units: unitsOf(s.units), content_sha: recordSha(s),
|
|
163
174
|
} : null;
|
|
164
175
|
return { parts, usage, links, lifecycle, session, calls: [...calls.values()].filter((c) => c.call_id && c.tool_name !== undefined) };
|
|
165
176
|
}
|
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
const sqlStr = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
8
8
|
|
|
9
|
-
// ClickHouse's AUTHENTICATION_FAILED
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// ClickHouse's AUTHENTICATION_FAILED, and REQUIRED_PASSWORD (a user that needs a password was
|
|
10
|
+
// given none). Told by the code, which the HTTP wrappers carry as `chCode` from the
|
|
11
|
+
// X-ClickHouse-Exception-Code header (bin/memhouse.js ch(), q-cli.js httpTransport), never by its
|
|
12
|
+
// prose: a credential the server refused is not a house whose tables are unreadable.
|
|
12
13
|
const AUTHENTICATION_FAILED = 516;
|
|
13
|
-
|
|
14
|
+
const REQUIRED_PASSWORD = 194;
|
|
15
|
+
const AUTH_CODES = new Set([AUTHENTICATION_FAILED, REQUIRED_PASSWORD]);
|
|
16
|
+
function isAuthFailure(e) { return AUTH_CODES.has(Number(e && e.chCode)); }
|
|
14
17
|
/** What every caller says when it is one; the caller adds what to set. */
|
|
15
18
|
function authFailureText(user, url) {
|
|
16
19
|
return `could not authenticate${user ? ` as '${user}'` : ''}${url ? ` at ${url}` : ''}`;
|
|
@@ -88,7 +91,13 @@ function devLayoutRefusal(det, member) {
|
|
|
88
91
|
return `the house holds ${member}_turns, the layout of memhouse 0.20's development builds, which harness-sessions-sql's profile replaces — its admin recaptures or converts '${member}' first (docs/guides/upgrade-to-0.20.md)${notice ? `. ${notice}` : ''}`;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
/**
|
|
95
|
+
* @param {{user?:string, url?:string}} [who] the credential the detection connected as, to name a refusal
|
|
96
|
+
*/
|
|
97
|
+
function chooseSchema(stated, det, member, who = {}) {
|
|
98
|
+
// A credential the server refused saw nothing: say that, rather than reading the house as new
|
|
99
|
+
// and letting the next step fail on the same credential.
|
|
100
|
+
if (det.authFailed) return { refuse: `${authFailureText(who.user, who.url)} — check the user and password, then run this again`, auth: true };
|
|
92
101
|
// Checked before the member's own tables: an --ensure-schema that laid the profile beside
|
|
93
102
|
// <m>_turns must not make the development layout look converted.
|
|
94
103
|
const dev = devLayoutRefusal(det, member);
|
|
@@ -109,4 +118,4 @@ function chooseSchema(stated, det, member) {
|
|
|
109
118
|
return { schema: '3' }; // a new house
|
|
110
119
|
}
|
|
111
120
|
|
|
112
|
-
module.exports = { detectHouseSchema, chooseSchema, importsNotice, importWritesProfile, devLayoutRefusal, isAuthFailure, authFailureText, AUTHENTICATION_FAILED };
|
|
121
|
+
module.exports = { detectHouseSchema, chooseSchema, importsNotice, importWritesProfile, devLayoutRefusal, isAuthFailure, authFailureText, AUTHENTICATION_FAILED, REQUIRED_PASSWORD };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- memhouse's reading views over one member's profile tables: harness-sessions-sql v1.1 §6
|
|
2
|
+
-- (schema/clickhouse-views.sql), each reading the member's own {db}.{m}_* tables. Rendered by
|
|
3
|
+
-- schema-profile.js with {db} and {m}, after the tables (schema-profile.sql.tpl). Views are not
|
|
4
|
+
-- tables: they are never in the column heal or the key check.
|
|
5
|
+
-- * CREATE OR REPLACE, so a house created before a definition changed gets the new one at its
|
|
6
|
+
-- next ensure (install, update, migrate).
|
|
7
|
+
-- * SQL SECURITY INVOKER: a read runs with the reader's own grants and row policies (share.js),
|
|
8
|
+
-- never the creator's, so a view shows a reader no row the tables would not.
|
|
9
|
+
-- * {m}_session_turns_view, not {m}_session_turns: a table name ending in _turns reads as a 0.18
|
|
10
|
+
-- member's turns room (share.js memberCollisions).
|
|
11
|
+
|
|
12
|
+
-- §6, session_turns (plan): one row per turn of one session. A turn starts at a message a person
|
|
13
|
+
-- sent in the main conversation (role 'user', source 'user', agent_id '') and runs to the next
|
|
14
|
+
-- one; messages before the first prompt are turn 0. The session key is the view's parameters, so
|
|
15
|
+
-- the filter on the sort-key prefix comes before FINAL:
|
|
16
|
+
-- SELECT * FROM {db}.{m}_session_turns_view(host = '…', harness = '…', harness_path = '…',
|
|
17
|
+
-- session_id = '…', incarnation = '')
|
|
18
|
+
CREATE OR REPLACE VIEW {db}.{m}_session_turns_view SQL SECURITY INVOKER AS
|
|
19
|
+
WITH
|
|
20
|
+
parts AS (
|
|
21
|
+
SELECT * FROM {db}.{m}_session_parts FINAL
|
|
22
|
+
WHERE host = {host:String} AND harness = {harness:String}
|
|
23
|
+
AND harness_path = {harness_path:String}
|
|
24
|
+
AND session_id = {session_id:String} AND incarnation = {incarnation:String}
|
|
25
|
+
AND is_deleted = 0 AND removed = 0
|
|
26
|
+
AND state = 'current' AND part_state IN ('', 'current')
|
|
27
|
+
AND ifNull(on_active_branch, true)
|
|
28
|
+
),
|
|
29
|
+
messages AS (
|
|
30
|
+
SELECT message_id,
|
|
31
|
+
min(order_key) AS order_key,
|
|
32
|
+
toUInt32(any(role) = 'user' AND any(source) = 'user' AND any(agent_id) = '') AS is_prompt
|
|
33
|
+
FROM parts
|
|
34
|
+
GROUP BY message_id
|
|
35
|
+
),
|
|
36
|
+
numbered AS (
|
|
37
|
+
SELECT message_id, is_prompt,
|
|
38
|
+
sum(is_prompt) OVER (ORDER BY order_key, message_id
|
|
39
|
+
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS turn
|
|
40
|
+
FROM messages
|
|
41
|
+
)
|
|
42
|
+
SELECT n.turn AS turn,
|
|
43
|
+
anyIf(p.message_id, n.is_prompt = 1) AS prompt_message_id,
|
|
44
|
+
min(p.ts) AS started_at,
|
|
45
|
+
max(p.ts) AS ended_at,
|
|
46
|
+
uniqExact(p.message_id) AS messages,
|
|
47
|
+
countIf(p.kind = 'tool_call') AS tool_calls,
|
|
48
|
+
countIf(p.kind = 'tool_result' AND ifNull(p.is_error, false)) AS tool_errors,
|
|
49
|
+
arraySort(groupUniqArrayIf(p.served_model, p.served_model != '')) AS served_models
|
|
50
|
+
FROM parts AS p
|
|
51
|
+
INNER JOIN numbered AS n USING (message_id)
|
|
52
|
+
GROUP BY n.turn
|
|
53
|
+
ORDER BY n.turn;
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
// option, nothing in <db>_archive granted to anyone else
|
|
9
9
|
//
|
|
10
10
|
// The DDL lives in schema-profile.sql.tpl, rendered with {db} and {m}; every statement is
|
|
11
|
-
// CREATE … IF NOT EXISTS.
|
|
11
|
+
// CREATE … IF NOT EXISTS. The reading views (harness-sessions-sql v1.1 §6) live apart, in
|
|
12
|
+
// schema-profile-views.sql.tpl, as CREATE OR REPLACE VIEW: they are not tables, so they stay out
|
|
13
|
+
// of templateColumns, the column heal and the key check. Transport-free like schema-v3.js: every
|
|
14
|
+
// entry point takes `q`.
|
|
12
15
|
|
|
13
16
|
const fs = require('fs');
|
|
14
17
|
const path = require('path');
|
|
@@ -16,6 +19,7 @@ const { assertUsableName, legacyTextIndexDialect, isTextIndexGrammarRefusal, tex
|
|
|
16
19
|
const { stripComments, qFromClient, DDL_SETTINGS } = require('./schema-v3');
|
|
17
20
|
|
|
18
21
|
const TEMPLATE = path.join(__dirname, 'schema-profile.sql.tpl');
|
|
22
|
+
const VIEWS_TEMPLATE = path.join(__dirname, 'schema-profile-views.sql.tpl');
|
|
19
23
|
|
|
20
24
|
// The columns retyped in harness-sessions-sql v1, which ensureProfileSchema brings an older table to:
|
|
21
25
|
// adapter_ver UInt32 -> LowCardinality(String), child_stored Bool -> Nullable(Bool), orig_sha256
|
|
@@ -47,19 +51,43 @@ const SERVER_COLUMNS = ['writer', 'ingested_at', 'search_ngram', 'search_word',
|
|
|
47
51
|
|
|
48
52
|
function archiveDb(db) { return `${db}_archive`; }
|
|
49
53
|
|
|
50
|
-
/**
|
|
54
|
+
/** The reading views, by their name after `<m>_` (schema-profile-views.sql.tpl). */
|
|
55
|
+
const VIEW_TYPES = ['session_turns_view'];
|
|
56
|
+
|
|
57
|
+
/** Every fully-qualified name of one member's profile tables and views. */
|
|
51
58
|
function profileNames(db, member) {
|
|
52
59
|
assertUsableName(db, 'house');
|
|
53
60
|
assertUsableName(member, 'member');
|
|
54
61
|
const out = { db, archive: archiveDb(db), member };
|
|
55
62
|
for (const t of CONVERSATION_TYPES) out[t] = `${db}.${member}_${t}`;
|
|
56
63
|
for (const t of CAPTURE_TYPES) out[t] = `${archiveDb(db)}.${member}_${t}`;
|
|
64
|
+
for (const t of VIEW_TYPES) out[t] = `${db}.${member}_${t}`;
|
|
57
65
|
out.meta = `${db}.${member}_meta`;
|
|
58
66
|
out.events = `${db}.${member}_events`;
|
|
59
67
|
return out;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
function readTemplate() { return fs.readFileSync(TEMPLATE, 'utf-8'); }
|
|
71
|
+
function readViewsTemplate() { return fs.readFileSync(VIEWS_TEMPLATE, 'utf-8'); }
|
|
72
|
+
|
|
73
|
+
/** The view statements for one member, in template order: [{ type, fq, sql }]. Each reads the member's own house. */
|
|
74
|
+
function renderViews(tpl, { db, member }) {
|
|
75
|
+
assertUsableName(db, 'house');
|
|
76
|
+
assertUsableName(member, 'member');
|
|
77
|
+
const body = stripComments(tpl).replaceAll('{db}', db).replaceAll('{m}', member);
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const raw of body.split(';')) {
|
|
80
|
+
const sql = raw.trim();
|
|
81
|
+
if (!sql) continue;
|
|
82
|
+
const m = /^CREATE OR REPLACE VIEW ([A-Za-z0-9_]+)\.([A-Za-z0-9_]+) SQL SECURITY INVOKER AS\b/.exec(sql);
|
|
83
|
+
if (!m) throw new Error(`schema-profile views template: unexpected statement: ${sql.slice(0, 80)}`);
|
|
84
|
+
if (m[1] !== db) throw new Error(`schema-profile views template: '${m[1]}.${m[2]}' is outside the house`);
|
|
85
|
+
const type = m[2].startsWith(`${member}_`) ? m[2].slice(member.length + 1) : m[2];
|
|
86
|
+
if (!VIEW_TYPES.includes(type)) throw new Error(`schema-profile views template declares an unknown view '${m[2]}'`);
|
|
87
|
+
out.push({ type, fq: `${m[1]}.${m[2]}`, sql });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
63
91
|
|
|
64
92
|
/** The CREATE statements for one member, in template order: [{ type, database, table, fq, sql }]. */
|
|
65
93
|
function renderStatements(tpl, { db, member }) {
|
|
@@ -156,12 +184,12 @@ function msg(e) { return e && e.message ? e.message : String(e); }
|
|
|
156
184
|
* Create one member's profile tables, idempotently, then verify them against PROFILE_KEYS.
|
|
157
185
|
* As ensureV3Schema: a privilege denial is counted and skipped, a lost create race is success.
|
|
158
186
|
*/
|
|
159
|
-
async function ensureProfileSchema(q, { db, member, createDatabases = false, grantCapture = false, tpl = null } = {}, ui = null) {
|
|
187
|
+
async function ensureProfileSchema(q, { db, member, createDatabases = false, grantCapture = false, tpl = null, viewsTpl = null } = {}, ui = null) {
|
|
160
188
|
const n = profileNames(db, member);
|
|
161
189
|
const say = (m) => ui && ui.ok && ui.ok(m);
|
|
162
190
|
const version = String(((await q.rows('SELECT version() AS v', { final: 0 }))[0] || {}).v || '');
|
|
163
191
|
const dialect = textIndexDialectFor(version);
|
|
164
|
-
const out = { version, dialect, created: [], denied: [], unhealed: [], grants: [], grant_failures: [], tables: {}, ok: false };
|
|
192
|
+
const out = { version, dialect, created: [], denied: [], unhealed: [], grants: [], grant_failures: [], views: [], view_failures: [], tables: {}, ok: false };
|
|
165
193
|
if (createDatabases) {
|
|
166
194
|
for (const d of [n.db, n.archive]) {
|
|
167
195
|
try { await q.sql(`CREATE DATABASE IF NOT EXISTS ${d}`, DDL_SETTINGS); } catch (e) {
|
|
@@ -257,6 +285,18 @@ async function ensureProfileSchema(q, { db, member, createDatabases = false, gra
|
|
|
257
285
|
}
|
|
258
286
|
}
|
|
259
287
|
if (out.retyped.length) say(`profile tables for ${member}: retyped ${out.retyped.join(', ')}`);
|
|
288
|
+
// The reading views, over session_parts as healed above. A view is a reader's convenience, not
|
|
289
|
+
// something the capture writes: a refusal goes to `denied`, any other failure to view_failures,
|
|
290
|
+
// and neither makes the tables not ok. Not over a session_parts that is missing or lacks a column.
|
|
291
|
+
if (out.created.includes(n.session_parts) && !lacking.has('session_parts')) {
|
|
292
|
+
for (const v of renderViews(viewsTpl || readViewsTemplate(), { db, member })) {
|
|
293
|
+
try { await q.sql(v.sql, DDL_SETTINGS); out.views.push(v.fq); } catch (e) {
|
|
294
|
+
const m = msg(e);
|
|
295
|
+
if (isDenied(m)) out.denied.push(`CREATE VIEW ${v.fq}`); else out.view_failures.push(`${v.fq}: ${m.split('\n')[0]}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (out.views.length) say(`reading views for ${member}: ${out.views.join(', ')}`);
|
|
299
|
+
}
|
|
260
300
|
if (grantCapture) {
|
|
261
301
|
for (const g of captureGrants(db, member)) {
|
|
262
302
|
try { await q.sql(g, DDL_SETTINGS); out.grants.push(g); } catch (e) {
|
|
@@ -284,7 +324,7 @@ async function ensureProfileSchema(q, { db, member, createDatabases = false, gra
|
|
|
284
324
|
}
|
|
285
325
|
|
|
286
326
|
module.exports = {
|
|
287
|
-
TEMPLATE, CONVERSATION_TYPES, CAPTURE_TYPES, PROFILE_TYPES, PROFILE_KEYS, SERVER_COLUMNS,
|
|
288
|
-
archiveDb, profileNames, readTemplate, renderStatements, templateColumns, templateKeys, rowColumns, insertColumns,
|
|
327
|
+
TEMPLATE, VIEWS_TEMPLATE, CONVERSATION_TYPES, CAPTURE_TYPES, PROFILE_TYPES, VIEW_TYPES, PROFILE_KEYS, SERVER_COLUMNS,
|
|
328
|
+
archiveDb, profileNames, readTemplate, readViewsTemplate, renderStatements, renderViews, templateColumns, templateKeys, rowColumns, insertColumns,
|
|
289
329
|
keyProblem, captureGrants, ensureProfileSchema, qFromClient,
|
|
290
330
|
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
-- memhouse's house tables as harness-sessions-sql's ClickHouse profile (step B1, tables and keys). The
|
|
2
|
-
-- tables, columns, keys and engines are harness-sessions-sql's schema/clickhouse.sql (v1.
|
|
2
|
+
-- tables, columns, keys and engines are harness-sessions-sql's schema/clickhouse.sql (v1.1: v1's,
|
|
3
|
+
-- with the native and units columns of §4.2 and §4.3; its reading view is
|
|
4
|
+
-- schema-profile-views.sql.tpl);
|
|
3
5
|
-- memhouse adds, beside them:
|
|
4
6
|
-- * the member prefix {m}_ and the archive database {db}_archive (spec §3: physical names);
|
|
5
7
|
-- * on session_parts: first_seen (a first-seen time, never written into ts), folder, project,
|
|
@@ -48,6 +50,8 @@ CREATE TABLE IF NOT EXISTS {db}.{m}_session_parts (
|
|
|
48
50
|
message_aliases Array(Tuple(id String, kind LowCardinality(String))),
|
|
49
51
|
cmp_sha FixedString(64),
|
|
50
52
|
message_content_sha FixedString(64),
|
|
53
|
+
message_native String DEFAULT '', -- v1.1: the message's native fields (sessions-model §2.7), canonical JSON; '' when none
|
|
54
|
+
message_units Array(Tuple(source_key String, generation UInt32, unit_key String)), -- v1.1: the units the message came from
|
|
51
55
|
kind LowCardinality(String),
|
|
52
56
|
text String DEFAULT '' CODEC(ZSTD(3)),
|
|
53
57
|
call_id String DEFAULT '',
|
|
@@ -78,6 +82,7 @@ CREATE TABLE IF NOT EXISTS {db}.{m}_session_parts (
|
|
|
78
82
|
native_type LowCardinality(String) DEFAULT '',
|
|
79
83
|
part_source LowCardinality(String) DEFAULT '',
|
|
80
84
|
part_state LowCardinality(String) DEFAULT '',
|
|
85
|
+
part_native String DEFAULT '', -- v1.1: the part's own native fields, canonical JSON; '' when none
|
|
81
86
|
content_sha FixedString(64),
|
|
82
87
|
adapter LowCardinality(String),
|
|
83
88
|
adapter_ver LowCardinality(String),
|
|
@@ -131,6 +136,8 @@ CREATE TABLE IF NOT EXISTS {db}.{m}_sessions (
|
|
|
131
136
|
aliases Array(Tuple(id String, kind LowCardinality(String))),
|
|
132
137
|
fingerprint Array(FixedString(64)),
|
|
133
138
|
setup Nullable(String),
|
|
139
|
+
native String DEFAULT '', -- v1.1: the session's native fields (sessions-model §2.7), canonical JSON; '' when none
|
|
140
|
+
units Array(Tuple(source_key String, generation UInt32, unit_key String)), -- v1.1: the units the session record came from
|
|
134
141
|
content_sha FixedString(64),
|
|
135
142
|
adapter LowCardinality(String),
|
|
136
143
|
adapter_ver LowCardinality(String),
|
package/memhouse/invitation.js
CHANGED
|
@@ -84,13 +84,19 @@ function render(tpl, values) {
|
|
|
84
84
|
* says. `schema` is the layout the invite provisioned ('3', anything else for the 0.18 rooms);
|
|
85
85
|
* `install` is step 1's block and `version` what `memhouse --version` should print.
|
|
86
86
|
*/
|
|
87
|
-
|
|
87
|
+
/** When the invite's password stops working, or '' for an invite with no expiry. */
|
|
88
|
+
function expiryLine(expires, inviter) {
|
|
89
|
+
return expires ? `**This invitation expires at ${expires} (UTC).** Its password stops working then; install before that, or ask ${inviter} for a new one.\n\n` : '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function invitationPage({ out, name, url, db, schema, version, install, inviter, template, expires = null }) {
|
|
88
93
|
const file = path.basename(out);
|
|
89
94
|
const text = render(template === undefined ? readTemplate() : template, {
|
|
90
95
|
NAME: name, FILE: file, FILE_ARG: shellWord(file), URL: url, DB: db, VERSION: version,
|
|
91
96
|
INSTALL: install, INVITER: inviter, JOIN: joinStep(file), OWNED: ownedTables({ db, name, schema }),
|
|
97
|
+
EXPIRY: expiryLine(expires, inviter),
|
|
92
98
|
});
|
|
93
99
|
return { path: path.join(path.dirname(out), PAGE), file, text };
|
|
94
100
|
}
|
|
95
101
|
|
|
96
|
-
module.exports = { PAGE, TEMPLATE, readTemplate, shellWord, ownedTables, joinStep, render, invitationPage };
|
|
102
|
+
module.exports = { PAGE, TEMPLATE, readTemplate, shellWord, ownedTables, joinStep, expiryLine, render, invitationPage };
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Only copies BOUND TO THIS HOME are touched: a machine can run two instances on two versions,
|
|
12
12
|
// and another instance's copy belongs to that instance's binary. A copy is never downgraded.
|
|
13
|
+
// The Codex copy (memhouse/codex-plugin.js) is refreshed the same way, and only where
|
|
14
|
+
// `plugins install codex` put it: its skill carries memhouse's ownership file and this home.
|
|
13
15
|
// From a git checkout nothing happens unless MEMHOUSE_REFRESH_SKILLS=1: a checkout run with a
|
|
14
16
|
// real MEMHOUSE_HOME would otherwise copy work-in-progress skills over the installed ones.
|
|
15
17
|
|
|
@@ -144,7 +146,7 @@ function fromCheckout(repoRoot) {
|
|
|
144
146
|
* stamp already names this version. Never throws; returns what it refreshed.
|
|
145
147
|
* @returns {{refreshed: {dir: string, have: string|null}[], skipped: string|null, errors: string[]}}
|
|
146
148
|
*/
|
|
147
|
-
function refreshIfStale({ home, version, deliveryDir, repoRoot, env = process.env, targets = null, log = () => {} }) {
|
|
149
|
+
function refreshIfStale({ home, version, deliveryDir, repoRoot, env = process.env, targets = null, codexHome = null, log = () => {} }) {
|
|
148
150
|
const res = { refreshed: [], skipped: null, errors: [] };
|
|
149
151
|
try {
|
|
150
152
|
if (fromCheckout(repoRoot) && env.MEMHOUSE_REFRESH_SKILLS !== '1') { res.skipped = 'checkout'; return res; }
|
|
@@ -155,6 +157,16 @@ function refreshIfStale({ home, version, deliveryDir, repoRoot, env = process.en
|
|
|
155
157
|
try { installInto(s.dir, { deliveryDir, version }); res.refreshed.push(s); log(`/mem skills in ${s.dir} refreshed for ${version}${s.have ? ` (were ${s.have})` : ''} — reload them in Claude Code: /reload-plugins`); }
|
|
156
158
|
catch (e) { res.errors.push(`${s.dir}: ${String(e.message || e).split('\n')[0]}`); }
|
|
157
159
|
}
|
|
160
|
+
const cxp = require('./codex-plugin');
|
|
161
|
+
const cx = cxp.staleCopy({ codexHome: codexHome || cxp.codexHome(env), home, version, compareVersions });
|
|
162
|
+
if (cx) {
|
|
163
|
+
try {
|
|
164
|
+
// The block is rewritten only where it still stands: one taken out by hand stays out.
|
|
165
|
+
cxp.install({ codexHome: cx.codexHome, home: cx.home, bin: cx.bin, version, deliveryDir, onlyWhereInstalled: true });
|
|
166
|
+
res.refreshed.push({ dir: cx.codexHome, have: cx.have, codex: true });
|
|
167
|
+
log(`the memhouse skill and block in ${cx.codexHome} refreshed for ${version}${cx.have ? ` (were ${cx.have})` : ''} — they load in the next Codex session`);
|
|
168
|
+
} catch (e) { res.errors.push(`${cx.codexHome}: ${String(e.message || e).split('\n')[0]}`); }
|
|
169
|
+
}
|
|
158
170
|
// Stamp only when every copy is current, so a failure is tried again on the next run.
|
|
159
171
|
if (!res.errors.length && fs.existsSync(home)) { try { fs.writeFileSync(stamp, `${version}\n`); } catch { /* home not writable: check again next run */ } }
|
|
160
172
|
} catch (e) { res.errors.push(String(e.message || e).split('\n')[0]); }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// What doctor says about a gap that a newer release might close (an unpriced model): whether a
|
|
2
|
+
// newer release exists on the channel this install follows, from the registry's dist-tags.
|
|
3
|
+
// Pure: the tags are given (bin/memhouse.js reads them once per doctor).
|
|
4
|
+
|
|
5
|
+
const chan = require('./channel');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {{version:string, pinned?:string|null, tags:Record<string,string>|null}} p
|
|
9
|
+
* @returns {{known:boolean, up:{target:string,channel:string}|null, channel:string|null}}
|
|
10
|
+
* `known` only when the registry answered and this install follows a tag on it.
|
|
11
|
+
*/
|
|
12
|
+
function releaseState({ version, pinned = null, tags = null }) {
|
|
13
|
+
if (!tags) return { known: false, up: null, channel: null };
|
|
14
|
+
const channel = chan.pickChannel({ version, pinned, tags }).channel;
|
|
15
|
+
return { known: !!channel, up: chan.behind({ version, pinned, tags }), channel };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pricingHint(state, version) {
|
|
19
|
+
if (state.up) return `memhouse update installs ${state.up.target} from '${state.up.channel}', which may price it`;
|
|
20
|
+
if (state.known) return `this is the latest release on '${state.channel}' (${version}) and it has no price for it: its cost stays unknown until a release adds one`;
|
|
21
|
+
return 'prices ship with each release: a newer one may add them (memhouse update), and on the latest one their cost stays unknown until a release does';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { releaseState, pricingHint };
|
package/memhouse/share.js
CHANGED
|
@@ -195,9 +195,11 @@ function memberCollisions(member, tables) {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
// The names memhouse gives a member's tables in the house, as the part after `<m>_`: its tables,
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
198
|
+
// its reading views (schema-profile.js VIEW_TYPES: session_turns_view, named so it does not end in
|
|
199
|
+
// _turns, which memberCollisions reads as a 0.18 member's room), the copies migrate keeps of a
|
|
200
|
+
// 0.18 room while it moves one (`<room>__migrating`) and after (`<room>_pre_epoch`), and the
|
|
201
|
+
// scratch table share's probe makes and drops (`_mh_probe_<pid>`).
|
|
202
|
+
const MEMBER_TABLES = [...new Set([...V3_TABLES, ...LEGACY_ROOMS, ...DEV_ROOMS, 'meta', 'events', 'session_stats', 'session_model_stats', 'session_tool_stats', 'session_turns_view',
|
|
201
203
|
...LEGACY_ROOMS.flatMap((r) => [`${r}_pre_epoch`, `${r}__migrating`])])];
|
|
202
204
|
const isMemberTable = (rest) => MEMBER_TABLES.includes(rest) || /^_mh_probe_\d+$/.test(rest);
|
|
203
205
|
const startsMemberTable = (p) => [...MEMBER_TABLES, '_mh_probe_1'].some((n) => n.startsWith(`${p}_`));
|
|
@@ -716,5 +718,35 @@ function parseShareRecord(value) {
|
|
|
716
718
|
return m ? { granted: true, scopeRaw: m[1] ? m[1].trim() : null } : { granted: false, scopeRaw: null };
|
|
717
719
|
}
|
|
718
720
|
|
|
721
|
+
/**
|
|
722
|
+
* One account's grants from membersAccess, told apart for a reader: its own tables, the tables
|
|
723
|
+
* of each member it reads (a share), and anything else (a hand-made grant, a wildcard reaching
|
|
724
|
+
* past its own name). A table belongs to the member whose name is left after the longest
|
|
725
|
+
* memhouse table name (MEMBER_TABLES) is taken off its end. `policies` are the row policies the
|
|
726
|
+
* reading credential can list ([{ short_name, table }]); null when it cannot, and then a share's
|
|
727
|
+
* scope is unknown.
|
|
728
|
+
* @returns {{ own: string[], shares: {from:string, tables:string[], scoped:boolean|null}[], other: string[] }}
|
|
729
|
+
*/
|
|
730
|
+
function describeAccess({ u, list }, { policies = null } = {}) {
|
|
731
|
+
const own = []; const other = []; const shared = new Map();
|
|
732
|
+
for (const label of list || []) {
|
|
733
|
+
const bare = String(label).replace(/ \(through role [^)]*\)$/, '');
|
|
734
|
+
const name = bare.includes('.') ? bare.slice(bare.lastIndexOf('.') + 1) : bare;
|
|
735
|
+
if (name === `${u}_*` || name === `${u}_`) { own.push(label); continue; }
|
|
736
|
+
const type = [...MEMBER_TABLES, 'session_units', 'session_sources'].filter((t) => name.endsWith(`_${t}`) && name.length > t.length + 1).sort((a, b) => b.length - a.length)[0];
|
|
737
|
+
const owner = type ? name.slice(0, -(type.length + 1)) : null;
|
|
738
|
+
if (owner === u) own.push(label);
|
|
739
|
+
else if (owner && !bare.includes('.') && V3_TABLES.concat(LEGACY_ROOMS).includes(type)) {
|
|
740
|
+
if (!shared.has(owner)) shared.set(owner, []);
|
|
741
|
+
shared.get(owner).push(label);
|
|
742
|
+
} else other.push(label);
|
|
743
|
+
}
|
|
744
|
+
const shares = [...shared.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([from, tables]) => ({
|
|
745
|
+
from, tables,
|
|
746
|
+
scoped: policies ? policies.some((p) => String(p.short_name).startsWith(`mh_share_${u}_`) && String(p.table).startsWith(`${from}_`)) : null,
|
|
747
|
+
}));
|
|
748
|
+
return { own, shares, other };
|
|
749
|
+
}
|
|
750
|
+
|
|
719
751
|
module.exports = { SCOPE_COLUMNS, parseScope, scopePredicate, policyName, V3_TABLES, scopePredicateV3, shareV3Plan, revokeV3Plan, parseShareRecord,
|
|
720
|
-
assertName, ident, qual, memberTargets, moveSharePlan, wildcardRevoke, memberCollisions, wildcardOnRevoke, holdsMemberGrant, collidingUsers, ownPolicies, showPoliciesRevokes, wildcardReachText, wildcardAdminLine, shareGate, selectTargets, rolesGranted, reachThroughRoles, revokeRoleReport, revokeCheck, classifyShare, legacyPolicyDrops, readLiveShares, membersGrantsSQL, MEMBERS_ROLE_GRANTS_SQL, membersAccess, roleDifferences, LEGACY_ROOMS };
|
|
752
|
+
assertName, ident, qual, memberTargets, moveSharePlan, wildcardRevoke, memberCollisions, wildcardOnRevoke, holdsMemberGrant, collidingUsers, ownPolicies, showPoliciesRevokes, wildcardReachText, wildcardAdminLine, shareGate, selectTargets, rolesGranted, reachThroughRoles, revokeRoleReport, revokeCheck, classifyShare, legacyPolicyDrops, readLiveShares, membersGrantsSQL, MEMBERS_ROLE_GRANTS_SQL, membersAccess, describeAccess, roleDifferences, LEGACY_ROOMS };
|
|
@@ -445,6 +445,18 @@ async function tombstonesFor(ctx, fc, vanished, rev, { removed = false } = {}) {
|
|
|
445
445
|
}
|
|
446
446
|
return out;
|
|
447
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* An adapter upgrade's rows, at a rev above every row the house holds for the file. An unchanged
|
|
450
|
+
* file re-derives at revOf(fgen, upto), the rev its last pass wrote, and at an equal rev the new
|
|
451
|
+
* version's row would win by insertion order alone. The raise is a rev or two; the next append's
|
|
452
|
+
* revOf moves on by the length of what it read.
|
|
453
|
+
*/
|
|
454
|
+
async function aboveHouse(ctx, ref, agentId, agentFileIds, rev, res) {
|
|
455
|
+
const floor = BigInt(await ctx.house.maxRev(ref, agentId, agentFileIds)) + 1n;
|
|
456
|
+
if (BigInt(rev) >= floor) return { rev, turns: res.turns, calls: res.calls };
|
|
457
|
+
const up = String(floor);
|
|
458
|
+
return { rev: up, turns: res.turns.map((r) => ({ ...r, rev: up })), calls: res.calls.map((r) => ({ ...r, rev: up })) };
|
|
459
|
+
}
|
|
448
460
|
/** Keys ({ t: [[message_id, part_id, ts]], c: [[call_id, ts]] }) the new derivation no longer produces. */
|
|
449
461
|
function vanishedFrom(old, res) {
|
|
450
462
|
const have = { t: new Set(res.turns.map(base.turnKey)), c: new Set(res.calls.map(base.callKey)) };
|
|
@@ -571,7 +583,10 @@ async function captureFile(ctx, f, H, C, budget) {
|
|
|
571
583
|
const fbIds = (fb) => Object.fromEntries(Object.entries(fb || {}).filter(([k]) => k.startsWith('id:')));
|
|
572
584
|
const freshState = { ...base.initialState(ad.adapter), fb: rewrite ? fbIds(state && state.fb) : ((state && state.fb) || {}) };
|
|
573
585
|
const res = ad.derive({ file: fc, records, state: freshState });
|
|
574
|
-
const
|
|
586
|
+
const at = reAdapter
|
|
587
|
+
? await aboveHouse(ctx, refOf(f, ctx.host), f.agent_id || '', ctx.agentFileIds(f), String(base.revOf(newFgen, newUpto)), res)
|
|
588
|
+
: { rev: String(base.revOf(newFgen, newUpto)), turns: res.turns, calls: res.calls };
|
|
589
|
+
const rev = at.rev;
|
|
575
590
|
const tomb = rewrite && reAdapter ? await retireBoth(ctx, f, H, state, fc, oldKeys, res, rev)
|
|
576
591
|
: await tombstonesFor(ctx, fc, vanishedFrom(oldKeys, res), rev, { removed: !!rewrite });
|
|
577
592
|
// A new generation is archived whole; an upgrade archives only what lies past `upto`.
|
|
@@ -580,8 +595,8 @@ async function captureFile(ctx, f, H, C, budget) {
|
|
|
580
595
|
// an upgrade that read past the cursor appended to the one it had.
|
|
581
596
|
if (rewrite) out.archive.push(manifestRow(f, newFgen, { progress: newUpto, continuity: 'reset', units: unitsOf(out.archive) }, ctx.host, base.chTs(now)));
|
|
582
597
|
else if (newUpto > upto) out.archive.push(manifestRow(f, newFgen, { progress: newUpto, continuity: 'appended', units: unitsOf(out.archive) }, ctx.host, base.chTs(now)));
|
|
583
|
-
out.turns = [...
|
|
584
|
-
out.calls = [...
|
|
598
|
+
out.turns = [...at.turns, ...tomb.turns];
|
|
599
|
+
out.calls = [...at.calls, ...tomb.calls];
|
|
585
600
|
const digest = prefixDigest(fd, newUpto);
|
|
586
601
|
out.files = filesRow(f, ctx, {
|
|
587
602
|
attr, fgen: newFgen, upto: newUpto, reparse_from: res.reparse_from, state: res.state, adapter_ver: ad.deriveVersion,
|
|
@@ -1238,9 +1253,10 @@ async function upgradeSweep(ctx, sessions, stats) {
|
|
|
1238
1253
|
const res = ad.derive({ file: fc, records: recs, state: { ...base.initialState(ad.adapter), fb: (state && state.fb) || {} } });
|
|
1239
1254
|
const S = sessions.get(skey(harness, H.session_id, H.harness_path, H.incarnation));
|
|
1240
1255
|
const agentIds = S ? [...S.files.values()].filter((x) => x.role === 'agent').map((x) => x.agent_id) : [];
|
|
1241
|
-
const
|
|
1242
|
-
await ctx.house.
|
|
1243
|
-
await ctx.house.insert('
|
|
1256
|
+
const at = await aboveHouse(ctx, ref, f.agent_id || '', agentIds, String(base.revOf(H.fgen, H.upto)), res);
|
|
1257
|
+
const tomb = await tombstonesFor(ctx, fc, vanishedFrom(await ctx.house.fileKeys(ref, f.agent_id || '', agentIds), res), at.rev);
|
|
1258
|
+
await ctx.house.insert('turns', [...at.turns, ...tomb.turns]);
|
|
1259
|
+
await ctx.house.insert('calls', [...at.calls, ...tomb.calls]);
|
|
1244
1260
|
const row = { ...H, reparse_from: res.reparse_from, derive_state: JSON.stringify(res.state), adapter_ver: ad.deriveVersion, contrib: JSON.stringify(res.contrib), host: ctx.host };
|
|
1245
1261
|
delete row.ver;
|
|
1246
1262
|
await ctx.house.insert('files', [row]);
|
|
@@ -1553,6 +1569,11 @@ function storeWhere(ref) {
|
|
|
1553
1569
|
return `harness = ${sqlStr(ref.harness)} AND host = ${sqlStr(ref.host)} AND harness_path = ${sqlStr(ref.harness_path)}`
|
|
1554
1570
|
+ ` AND session_id = ${sqlStr(ref.session_id)} AND incarnation = ${sqlStr(ref.incarnation || '')}`;
|
|
1555
1571
|
}
|
|
1572
|
+
/** WHERE clause for one file of a session: a sub-agent's by its id, the main file as every other id. */
|
|
1573
|
+
function fileWho(agentId, agentFileIds) {
|
|
1574
|
+
return agentId ? `agent_id = ${sqlStr(agentId)}`
|
|
1575
|
+
: (agentFileIds.length ? `agent_id NOT IN (${agentFileIds.map(sqlStr).join(', ')})` : '1');
|
|
1576
|
+
}
|
|
1556
1577
|
const STORE_TUPLE = '(harness, host, harness_path, session_id, incarnation)';
|
|
1557
1578
|
const storeTuple = (ref) => `(${[ref.harness, ref.host, ref.harness_path, ref.session_id, ref.incarnation || ''].map(sqlStr).join(', ')})`;
|
|
1558
1579
|
/** Split a column list at its top-level commas. */
|
|
@@ -1774,17 +1795,25 @@ class HttpHouse {
|
|
|
1774
1795
|
}
|
|
1775
1796
|
/** The keys this file's shipped rows hold in the conversation layer (adapter upgrade only). */
|
|
1776
1797
|
async fileKeys(ref, agentId, agentFileIds) {
|
|
1777
|
-
const who = agentId ? `agent_id = ${sqlStr(agentId)}`
|
|
1778
|
-
: (agentFileIds.length ? `agent_id NOT IN (${agentFileIds.map(sqlStr).join(', ')})` : '1');
|
|
1779
1798
|
// Removed history (removed = 1) is never a candidate: it is what the source no longer has, not a
|
|
1780
1799
|
// duplicate of the new derivation, and an upgrade must not erase it.
|
|
1781
|
-
const where = `${storeWhere(ref)} AND ${
|
|
1800
|
+
const where = `${storeWhere(ref)} AND ${fileWho(agentId, agentFileIds)} AND is_deleted = 0 AND removed = 0`;
|
|
1782
1801
|
const t = await this.query(`SELECT message_id, part_id, toString(ts) AS ts FROM ${this.t.turns} FINAL
|
|
1783
1802
|
WHERE ${where} AND origin = 'ship' AND NOT startsWith(message_id, 'legacy:')`);
|
|
1784
1803
|
const c = await this.query(`SELECT call_id, toString(ts) AS ts FROM ${this.t.calls} FINAL
|
|
1785
1804
|
WHERE ${where} AND NOT startsWith(call_id, 'legacy:')`);
|
|
1786
1805
|
return { t: t.map((r) => [r.message_id, r.part_id, r.ts]), c: c.map((r) => [r.call_id, r.ts]) };
|
|
1787
1806
|
}
|
|
1807
|
+
/** The highest rev this file's shipped rows hold, tombstones and removed versions included (adapter upgrade only). */
|
|
1808
|
+
async maxRev(ref, agentId, agentFileIds) {
|
|
1809
|
+
const where = `${storeWhere(ref)} AND ${fileWho(agentId, agentFileIds)}`;
|
|
1810
|
+
const [t] = await this.query(`SELECT toString(max(rev)) AS rev FROM ${this.t.turns}
|
|
1811
|
+
WHERE ${where} AND origin = 'ship' AND NOT startsWith(message_id, 'legacy:')`);
|
|
1812
|
+
const [c] = await this.query(`SELECT toString(max(rev)) AS rev FROM ${this.t.calls}
|
|
1813
|
+
WHERE ${where} AND NOT startsWith(call_id, 'legacy:')`);
|
|
1814
|
+
const a = BigInt((t && t.rev) || 0); const b = BigInt((c && c.rev) || 0);
|
|
1815
|
+
return String(a > b ? a : b);
|
|
1816
|
+
}
|
|
1788
1817
|
/**
|
|
1789
1818
|
* Every `legacy:` row of one session in EVERY version — no FINAL, so tombstoned rows come back
|
|
1790
1819
|
* too (dedupe-v3 takes the newest per key: a tombstone is a full copy, it still has its text).
|
|
@@ -1957,6 +1986,7 @@ class DryHouse {
|
|
|
1957
1986
|
async firstCaptured() { return new Map(); }
|
|
1958
1987
|
async rowsByKey() { return []; }
|
|
1959
1988
|
async fileKeys() { return { t: [], c: [] }; }
|
|
1989
|
+
async maxRev() { return '0'; }
|
|
1960
1990
|
async hasRemoved() { return false; }
|
|
1961
1991
|
async legacyRowsAll() { return { turns: [], calls: [] }; }
|
|
1962
1992
|
async capturedRows() { return { turns: [], calls: [] }; }
|