Skip to content

Commit da780da

Browse files
authored
mmc-trajectory: 退役记录索引、落下来鲜度层、总览条按像素列聚合,并从段格式里去掉两个只写不读的字段 (#21)
* Give the session list a validator, and stop one clock driving two fetches Three of the remaining poll-cost items, in the order they compound. countRolesInPrefix counted the list's eight counters with five matchAll passes over the bounded prefix — a match object per hit, thousands of them per session per rebuild. It now runs four test-driven scans that read each value out of the text by position. The scans carry /g and test mutates lastIndex, so each resets it first: the loops break when a value has no closing quote, which is the normal case for a truncated prefix and leaves the cursor mid-string on module state shared across calls. verify-count-roles.mjs holds the capturing implementation it replaced as a reference and requires the two to agree over hand-written edges and over every session prefix in the store it is pointed at, so the rewrite is held to being the same function and not merely a faster one. /api/sessions had no validator while /api/trajectory got one first. sessionsValidator is built from the same values the payload is built from, in the same request: the visible slice's dir|sizeBytes|lastActiveAt — exactly what describeSession keys its descriptor cache on — plus the titles, which no file stat can see, plus the limit, because ?limit=10 and ?limit=20 are different payloads and must not satisfy one another. The 304 is decided before any descriptor is built, and an unchanged list answers from the bytes the previous request already built. The soundness bound is the descriptor cache's and not a new one: a manifest.json appearing after the walk listed a session would change startedAt without moving the tag, and that is already invisible for up to SESSION_CACHE_TTL_MS to the cache this reuses. verify-list-cache.mjs pins the TTL relationship that says so. One POLL_INTERVAL drove both fetches and it was wrong for both. The list is a cached walk and a cached title read and now usually a 304; the trajectory reparses a lineage, rebuilds a payload that measured 18,924 rows, and reopens the runtime database on every request. They are two ticks now, with the expensive one slower, and the list wakes the trajectory when it has evidence: a 200 means something in the visible slice moved, a 304 means nothing on screen can be stale. So the slow tick is a floor on latency rather than a delay applied to every change. The list keeps the fast tick for a reader-visible reason too: the picker renders 「刚刚」 against a render-time clock, so a slower tick makes that label sit still and then jump. That ordering is asserted, not just argued in a comment. The ledger build guard moves with the trajectory, where it belongs — it exists because a table mid-build must not be replaced mid-build, and the list never renders the ledger. Read points that moved with it: verify-list-cache.mjs measures the cache against the list's own cadence rather than the page's, client-check's poll assertion no longer passes just because SESSION_POLL_INTERVAL contains POLL_INTERVAL as a substring, and the list body cache joins the others in dispose. 63/63 guards, 33 mutations across the three new controls. * Make every nameable session openable, not just the 30 in the picker `collectSessionEntries` did two jobs with one return value: it walked the store, and it returned `entries.slice(0, MAX_SESSION_LIST)`. Because the slice was the last statement, the only handle the service had on a session directory was "one of the 30 most recently touched" — and resolving an explicit `?session=<id>` scanned exactly those 30. Measured against a real store: 146 candidate directories, 30 exposed, and 116 of them answering 404 to their own id. Inside the window 200/200; a sample outside 404/24. No leakage at the boundary, which is what a single truncation point looks like. A session the reader could name but not open is a correctness defect, and the runtime database cannot paper over it: the six compacted sessions on that machine have no `local_runtime_sessions` row at all, so the `latest` branch's `relativeDir` fallback has nothing to find. The traversal now yields two products and the two callers take different ones. `recentEntries` is sorted and capped, and it is still the only thing the picker is built from. `byDecodedId` is every candidate keyed by the id decoded from its own name; the walk already stat'ed `messages.jsonl` for each candidate, so the index costs no I/O the walk was not doing anyway. Three things the index deliberately does not get to do on its own: * A decoded name is a claim. `readSessionIdentity` prefers `manifest.sessionId`, so an index hit still reads the identity of the one directory it picked and compares before serving. * Two directories can decode to one id. `Map.set` would keep whichever came last and make the answer depend on `readdir` order, so duplicates are kept as a list and the manifests resolve them. * A name that does not decode is still a real session. It gets no fast path; it does not become "does not exist". `MAX_SESSION_CANDIDATES` stays a resource guard, but it stops being a correctness boundary by saying so. When the walk stops early the route answers `session_scan_budget_exhausted`, which is a different answer from `session_not_found` and only one of the two is true. The client seeds `state.sessionId` from the host id. `sessionParam()` already preferred `hostSessionId`, so a host-pinned page rendered 「跟随最新(自动切换)」 above content pinned to a different conversation that it would never leave. That contradiction predates this change, but the reachability fix turns it from a visible error into a silent one. The picker is untouched: still 30 candidates, still `?limit=20`, still a server cap of 50. Widening it is a UI decision and is not taken here. Guards: `verify-session-reachability.mjs` holds 31 assertions over a synthetic 40-session store and a 405-session store that runs the walk out of budget, and `mutate-session-reachability.py` breaks each of them in the shipped source and requires the guard to report it by name — 15 mutations, all caught. Suite is 65/65. * Stop one growing session from pinning four copies of its own payload trajectoryCacheKey carries the active file's size and mtime, so a session that is still being written to mints a new key every time it grows. Every miss then stores another complete body -- ?since= included, because the cache entry and /raw both need the whole thing -- and the eviction was a plain FIFO over keys. Four versions of the SAME (session, slice) therefore sat there at once, three of them unreachable: /raw resolves the current key, and the page's next poll produces a newer one. Measured on a 9.1 MB session polled every 8s: four bodies, 49.6 MB, after four polls. On this machine's 68 MB body that is three dead copies. An entry now carries the family it answers for -- the session directory plus the requested slice -- and storing a new one drops its predecessor. Everything that decides whether the answer went stale stays in the key; everything that decides what the answer IS stays in the family. trajectoryCacheFamily is exported and used by both the key and the cache, so the two copies of that expression cannot drift. Different ?generation= slices stay separate families and keep their own entries, still bounded by the same limit. Reading still refreshes recency. verify-trajectory-cache.mjs: 30 assertions, including that the key still carries size and lineage -- a stale entry must never become servable just because the family matched. mutate-trajectory-cache.py: 10 mutations, each breaking a different part of the mechanism, because a bug collapsing two of them would still be caught by the wrong assertion. Two anchors had to move, and the mutations stayed: the raw-lazy set call grew its family argument, and createTrajectoryCache now has a clear() of its own, which made the parsed-row cache's clear() anchor ambiguous. That harness reports an ambiguous anchor as unresolved rather than guessing, so the anchor grew the bytes that make it specific. Suite 67/67. * Let the parsed-row assertions measure what they were measuring verify-parsed-rows.mjs built its stand-in session as `{ session: {...}, sizeBytes: 0, truncated: false }`, then spread it into the per-file result with `...other` AFTER the loop had computed both numbers. The guard replaced its own measurements with two constants one line before comparing them: every assertion about total size and the truncated flag was asserting the literal back at itself. Those are exactly the two quantities the route derives by walking the store -- how many bytes the generation actually holds, and whether the cap cut it. The guard was therefore free to disagree with the route about either without any assertion noticing. The stand-in now carries `session` and nothing else. Nothing else needed to change: the computed values survive to be compared, which is the point. 53 assertions and all 12 mutations behave the same as before, so what the guard was actually pinning is unchanged -- it just stops pinning a constant. Found while measuring what parsed rows are worth to hold in memory: the row cache's value was being judged partly by a guard that could not disagree with itself. * Stop holding a session's parsed rows open just to answer 原文 The body was already serialized by the time /raw was asked for, and the entry kept the whole parsed object graph behind it so that one record could be handed back. Measured on this machine's own store: 377 MB of heap held while one 71.8 MB session was open, against 304 MB once the rows were not retained -- and on a session with a 20.5 MB source, 31 MB against 11. Polling does not change either way: 304 of 304 answered 304, at 1.6 ms each. The parsed-rows cache could not have been the fix, because it was never a cache for the file that mattered. Its key is path+size+mtime, and handleTrajectory only reaches the parse after the trajectory key -- carrying that same size and mtime -- has already missed. Every store was followed by a miss. Sealed generations are the opposite case and stay cached, because for those the stat really is constant. So the active generation is parsed every time and retained never, and what the entry keeps instead is, per record: the file, the byte range of its line, and a SHA-256 of that line. 36,527 locators cost 5 MB against the object graph's 73. `parseMessagesJsonl` reads the bytes `readHead` already has and reports where each line sits, which also stops a 68 MB JSONL being decoded into a 135 MB JS string before it is parsed at all. /raw seeks to the range, refuses anything whose length or digest does not match, and answers 409 raw_stale rather than a plausible message from the wrong line. That case is narrow -- the entry is keyed on size and mtime, so only a rewrite preserving both reaches it, and no sealed generation on this store has ever been rewritten -- but nothing above the file level can see it, and a wrong message under the right record's name is worse than a hint. The page puts the validator in its own raw cache key for the same reason: a record index is a position within one version of one body, and a body that has moved on would otherwise keep its old messages. The cost is on re-parses only, and it scales with record count rather than file size: 574 records cost 25 ms, 36,527 cost 342 ms. 304s are unaffected. Reads now answer one line at a time out of a file rather than out of memory, and a record the page has already opened is kept decoded for the entry, 32 at a time. verify-raw-locators.mjs: 57 assertions, over the three things the seam can get wrong -- the positions being byte offsets rather than character indices (the fixture carries a CJK line, where the two part company at the line's end and agree at its start), the digest meaning something under a same-length rewrite, and the route actually keeping locators instead of rows. mutate-raw-locators.py: 20 mutations. Four existing mutation controls had anchors this change invalidated, and the anchors moved rather than the mutations: two named the old cache-entry line, one named a comment that was re-wrapped, two named `empty` and the inline-parse call site. mutate-review16's own anchor had to move with them, which is the third time a re-wrap has cost a detector its anchor. Also verified, and it is the part worth keeping: no sealed generation on this store has ever been rewritten (52 of them, checked against their own catalog's byteLength and mtime), and the lineage at a session root is the active file alone -- rotated generations live under snapshots/ rather than beside messages.jsonl, so the retention this removes was the active file's and nothing else. Suite 69/69. * Merge stored copies into one message, and decide a turn's opener over the lineage A message can be stored more than once across a lineage: the active file may re-record one a sealed snapshot already holds, and sealing a generation can rewrite old tool results in place. Neither is a row that can simply be dropped — one is a second copy of one version, the other a second version of one message — so the route now merges them and keeps both. buildMessageIdentity() folds every occurrence into one identity and records the content versions it was written in; selectIdentityView() then picks the rows one request projects. The whole lineage shows each message once, at its first version; a generation shows that generation's own stored context. A row with no usable id is never keyed together with another. Two readings of "the same message" disagreed about one field. contentFingerprint() dropped history_artifact, because the newer generation's opener carries it while nothing about the message changed; equalOutsideContent() counted it, so whether a rewrite read as a declared trim depended on which row happened to carry the generation's annotation. It now ignores it too. Checked against the session store on this machine, the two comparisons disagree about none of the pairs compared — the change is behaviour-preserving on the data that exists, and it stops depending on annotation placement in future data. A turn's opener is decided over the whole lineage rather than the projected slice. Deciding it from the slice is what let a real steer become the first visible prompt of its own turn whenever the opener's generation was filtered away: it counted as dialogue instead of a redirect, and its turn was filed as neither prompted nor closed. Turns now report openerInView / openerOutOfView so those two facts stay apart — a view can hold a turn's steer without holding the row that opened it. stats.messagesAll counts logical messages over the reachable lineage instead of summing the per-file counts the catalog recorded, so a message stored twice is one message and a generation slice no longer shrinks the session total. The physical count moves to stats.messageRowsAll, and each generation reports the messages it stores as logicalMessageCount. The picker reads that, and the generation banner now says which generation is still growing — the picker can pin the live one, which the previous wording promised would never change. * Pin the identity rules with assertions and mutations verify-message-identity.mjs covers the merge, the two views, the four declared- trim predicates, the turn opener, and the three numbers the payload derives from them; mutate-message-identity.py breaks each one at a time. The declared-trim section hangs the generation's declaration on that generation's own opener row, which is where the store puts it. With it on the changed row instead, every negative case failed at the "equal outside content" predicate for a reason unrelated to the case under test — so two of the four predicates could be deleted and the section still passed. A positive control in the same shape keeps that from recurring. One mutation anchor did not do what its label says, which is worth recording: it replaced a precondition inside a comparison function with `if (false) return false;`. A never-taken branch does not disable the work below it, so the mutation was never observed and read as a live miss. Anchors belong on the line that decides the answer. mutate-steer.py gains the two ways a steer can come back: deciding the opener from the projected slice, and treating an opener the view does not hold as no opener at all. The generation picker's count and the banner sentence are new behaviour, so mutate-generation-counts.py breaks each: reading the catalog's per-file row count again, printing a count the option carries but the label omits, taking the whole-context number from the slice, and describing the live generation as a sealed snapshot. Three existing anchors are re-pointed rather than repaired, because the shipped lines they matched moved: the steer rule, the orphan filter that now attaches a per-generation count, and the orphan-matching line it sits next to. * Follow a session's active generation, and key rows and time spans by record Two record-addressing defects, both visible on stored sessions that keep more than one generation of the same conversation. An unmarked activity file was skipped A session's cross-generation information lives in exactly one place: its history-catalog.json, whose activeGeneration names the generation still taking writes. The lineage walk instead decided the active generation from the file alone, so an active file carrying no generation marker was read as generation 0 and the whole rest of the chain was dropped. One session in 139 catalogued sessions is affected: its chain resolved to a single file, and every earlier turn of that conversation was invisible. With the fallback in place the same chain resolves to generations 0 and 1 — 752 rows become 383 unique messages, 228 identical copies and 141 copies whose tool results were trimmed by the runtime, and 39 turn openers. The fallback only applies where it has to. A file that declares its generation is still taken at its word, so a catalog entry that disagrees with a file is still resolved in the file's favour, and a file that declares a generation other than the one sought is still refused. The catalog names which files to look at; it does not become the authority on what they contain. Rows and time spans were keyed by message, not by record The first column renders a record's index, and index is what a row means when it is scrolled to, pinned to, or brushed on the overview. Those three all went through the message-level id instead. A message split across records by a tool-result trim therefore resolved to whichever record happened to be first, so the scroll anchor, the overview selection and the time brush disagreed with each other and with the row the reader was looking at. Measured on that session, 228 messages have more than one record, so this is not a corner case. The overview now keys each span by the record it was built from, and its hit test returns the span rather than an id. That is what lets a click keep its message-level meaning — the same message's records highlight together, which is what selection is for — while hover and time brushing stay on the single record under the pointer. Selection is unchanged on purpose. * Pin the generation fallback and the record-level keys with assertions and mutations A fallback that is never exercised resolves nothing, so both fixes ship with the evidence that they are load-bearing. verify-generations.mjs grows by 5 assertion groups covering the cases the fallback has to distinguish: an unmarked active file resolves to the catalogued generation, an unmarked sealed snapshot resolves to the generation it was sought at, a file that declares its generation still overrides the catalog, a fork still pointing at a generation other than the one sought is still refused, and a catalog with no active entry still emits the chain. 129 assertions, all passing. verify-message-identity.mjs already carries 16 mutations and client-check.mjs grows by 9: the compaction band and the row both key by record, the overview model pushes a record-keyed id, selection reads spans by record, and the hit test hands back a span so the click and the hover read different fields off it. The old assertion on the compaction band pinned an expression rather than the behaviour and is updated to pin the key. 186 assertions, all passing. mutate-generation-counts.py grows from 5 to 11 mutations and mutate-steer.py from 5 to 8, so both fixes are broken and caught by name: dropping the catalogued-generation fallback, letting a declaration override the catalog in the wrong direction, accepting a fork, and reverting each of the record-level keys back to the message-level id. All 72 guards pass. * Describe the ledger twice: once as an index on disk, once as searchable text Two derived artifacts under the runtime's data dir, each with its own guard, neither wired to the route yet. They are here because the next change is going to need them, and because building them first is how their shapes get argued against real numbers instead of against an intention. The index: what a session's ledger looks like, laid out so reading a window or counting a kind costs a lookup rather than a re-parse of every generation the session has stored. The primary key is the record's own index, which is already dense and sorted on disk (measured gapless 1..36527 on a 44-generation session), so the file IS the sorted array a binary search wants. Four structures are built on load: message id to its records, generation to its records, kind to its count, and — the one that costs a sort — a side array of timestamps in time order. That last one is not optional: timestamps along the ledger are nearly but not quite monotonic (32 adjacent inversions in 25,428 rows on a real session, the largest a 141.8 s step backwards against a 98.9 h span), so bisecting the primary order would silently drop whatever fell in a gap. The guard holds a fixture with constructed inversions and shows, as a control, that bisecting the primary order on that same data loses records and the side array loses none. What a record does not carry is the bulk: the projected text fields are left out and read back from the source line on demand, which is the same move the existing lazy raw read already makes. The denylist is a denylist rather than an allowlist on purpose — a hand-copied allowlist lost eleven fields the page actually reads before it was measured, and a field this file forgot is a row that renders blank with nothing to say why. Two of them survive as booleans: whether there was error text, and the byte range of the line a record came from, which is what a search hit in a sealed generation is mapped back through. Nothing in either artifact is a machine path. A record names its file by an index into the manifest's own file table, and the resolved path is stripped before the manifest is written. Both are published with the manifest written last, so an interrupted write leaves a directory that reads as "no artifact" rather than as half of one. Neither is consumed unless its bytes match the digest the manifest was written with, its schema and projection versions are the ones this build speaks, and the source it was built from is still the source on disk. The search segment: the five fields a reader can search, stored once per sealed generation. Those five fields are measured at 36.7 MB for the worst session, against 8.6 MB for the index, so they are kept apart: the ordinary read never opens the search surface, and a search never pays for the index's own metadata twice. A sealed generation cannot change, which is what makes its segment something written once; the active generation is being appended to and is therefore never stored. The surface is the page's own search text under one stated normalisation — runs of whitespace become a single space, ends trimmed — applied to the surface and to the needle alike, so what the reader typed cannot decide the answer. One record is one line, which is not a formatting choice: the page joins the five fields with a newline, and a surface that kept that separator produced five lines per record, so a scan's line number stopped being the record ordinal its locators were numbered by and a hit resolved to whichever record happened to sit at that line. The guard pins that with an assertion on the ordinal rather than on the string, because string equality cannot see it — both sides shift together. And a scan may not cross a record boundary. One needle tested against each line alone; the guard constructs a needle that belongs to neither of two records and shows the joined blob would have matched it. All 76 guards pass: 84 assertions and 17 mutations on the index, 92 assertions and 17 mutations on the segments. * Search the whole lineage instead of the loaded window The page decided 「does this record contain the needle」 by scanning five fields of the payload it happened to be holding. So 「只搜已加载部分」 was the contract rather than a choice, and it missed everything the window did not hold. Search now asks the server, which scans the same projection the payload was built from, and the page asks a set. Server - /api/trajectory/search?session=&q=&generation= walks the lineage through the one walk handleTrajectory uses, so both endpoints see the same records - a hit is the record being displayed: hits resolve through the identity layer and the view's version choice, never through stored lines, so a hit can never name a row the ledger is not drawing - the cap bounds what is sent, not what is counted: a thousand hits is a thousand hits - a needle shorter than two characters is a 400, not an empty answer - indexes and one search segment per sealed generation are published after the response, best-effort; the active generation is never published, because it is still being written Client - recordSearchText is gone; matchesQuery asks the server's hit set - an unanswered needle filters nothing and the count line says 正在搜索. Four states are now distinguishable: 命中 N 条 / 未找到 / 搜索中 / 搜索失败. 「没搜到」 and 「没搜成」 used to be the same empty table - one AbortController plus a sequence gate, so only the newest request writes - a changed payload re-asks, because the answer names indexes of the payload that was there before - clearing the filters drops the answer with the needle The overview strip now follows the answer instead of holding a predicate of its own, and can reach a per-turn rule. Both are pinned, each by a mutation: the strip previously kept its own copy of the whole decision, and it drifted the moment the search moved to the server. verify-raw-locators' release assertion was accepting a substring of the new handler's `view.rows.length = 0;`. It now looks inside handleTrajectory and pins that the `rows` being released is `view.rows`. Tests: `node tests/run.mjs` on this tree -> 80/80 guards passed. New: verify-search- endpoint 62 assertions, mutate-overview 2 mutations. * Stop sending the record bodies; send four flags and ask when one is opened `text`, `thinking`, `arguments` and `result` were 59.1% of every response body, and the page held all of it to draw a ledger row and — once in a while, for one record — four inspector tabs. Measured over this machine's whole session store, projecting every record the way the route does: text 8.9% thinking 13.2% arguments 9.3% result 26.6% error 1.0% Measured again over the twelve largest sessions, 6568 records, before and after the strip: 13.66 MB -> 5.57 MB, 59.2% removed Server - push() measures four presence flags, once, and the values stay on the records. The search reads them in this process, so stripping at the projection would have broken search to save the page memory; slimPayloadForWire() runs at the serialisation boundary instead, and the ?since= delta is built from the stripped payload, so a poll carries no more bulk than the first load. - /api/trajectory/record?session=&index=&generation= answers for one record. It re-projects the single stored line that record came from and picks by rowPos — a line can expand into several records (its text, each thinking block, each tool call), and a wrong position shows the reader another block's reasoning as if it were this one. - It does not answer with `kind`. Re-projecting one line reproduces every value but disagrees about kind for an opener (measured: 1 of 6568; a steer row alone has no turn to have been interrupted by). The page already has the record and its kind, and kind is the field a reader would act on. - `error` is gone. It was the same expression as `result`, set only when isError was already true, so `isError || error` could never have disagreed with `isError`, and every failed tool result was serialised twice. - The row summary is one server-derived field. The page used to assemble it from `summary || text || toolName || title`; over this store the `text` step decided nothing and the other two decided it for 5 of 1287 records, all system rows. - The line read and its digest check moved into readRecordRow(). Two endpoints need them, and two copies is two answers to 「are these still the bytes that record was built from」. Client - the four tabs and the error block read the detail; availability comes from the flag - 空答复 is decided by hasText === false, strictly. A record from a payload without the flag has neither flag nor body, and calling that broken would mark it for no reason. - the handoff fetches the detail alongside the raw message, and says so when the error text did not arrive rather than printing 「失败」 with nothing after it Also fixed, found while doing it - make-payload.mjs picked the fixture session by mtime. It was not a rule, it was a timestamp, and it had changed the answer without anyone deciding it should: the committed payload still carried `raw` and `rawOmitted`, fields the projection stopped producing long ago, and every guard reading it was asserting against a payload the server cannot produce. It now picks deterministically and refuses to record without a tool error, a checkpoint, an open turn and a body-less reply — the four the guards read it for. - client-check asserted 「exactly two .innerHTML in the page」 under a label about jsonColorize. A count fails the moment a third legitimate call is added and passes just as happily when one of the two is deleted and the other becomes an injection. It is a property now. Tests: `node tests/run.mjs` on this tree. New: verify-record-detail, 139 assertions, and mutate-record-detail, 17 mutations each caught by name. * Say which numbering a tail was cut from, or renumbering lands it in the wrong turn The cursor is `record.index` and a delta is cut at `index > since`. That rests on numbering only ever growing — and the identity layer took that away. A message arriving that is a newer version of an earlier one suppresses that earlier copy, and every record after it shifts down by one. A tail cut at the old number then lands in the middle of the wrong turn: no error, no empty table, just records in the wrong place. The `since > upTo` fallback covers a page that has run PAST the server. It does not cover a numbering that moved underneath it. - Putting the etag in the cursor key does not work: the etag changes on every ordinary growth (size, mtime, lineage), so every incremental poll would fall back to the whole payload and the incremental path would stop existing. - Forcing a whole payload on every generation change covers only one of the two ways numbering moves. The other happens without any generation changing at all. So the tail says what it assumed. `delta.basedOn` is the id of the record the cursor pointed at, read while the tail is being cut — which walks those records anyway. The page compares it against the record it holds at that index: same id, nothing before the cursor moved, the tail belongs to this payload; different, the numbering moved, and the only honest answer is the whole payload. Two things about the shape: - The check runs BEFORE the merge and returns early. Checking afterwards would leave records in the wrong turn with nothing to undo them. - A delta with no `basedOn` is refused rather than trusted. This page cannot tell such a tail from a correct one, and 「probably fine」 is the guess the rule exists to prevent. `since === 0` passes: there is nothing before the cursor that could have moved. Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. verify-incremental grew 5 assertions and mutate-incremental 5 mutations (24 total), each caught by name. * Give the time-range guard a window it can still prove something with `tests/verify-time-range.mjs` is not in the default run: it needs a booted service and a multi-generation session. That is also why nothing had run it since the session it was calibrated against stopped existing. Booting the runtime against the live store and running it found two failures, and neither was a fault in the range filter itself. One was the wall-clock window, which was a literal: 23:50 to 00:20 on fixed October dates. Once the session that belonged to was compacted away, both sides of the comparison the section exists to make were 0, so it passed while comparing nothing, and only the assertion written to catch exactly that noticed. A literal window is the one input in that file that can silently stop being evidence, and nothing about the code can tell you when it has. The window is now derived from the payload: the last row before a local midnight and the first row after it, five minutes of padding, floored and ceiled to whole minutes. It is still required to cross a local midnight, since that is the part that makes each bound a subtraction the machine's distance from Greenwich gets to corrupt. - Reached from the rows rather than from a fixed width, because the reader steps away. On the session served here the last row before the one usable midnight was 41 minutes early, so a +/-10 minute window found nothing and reported the reader's habits as a fault in the arithmetic. The first midnight inside the span turned out to have no rows within an hour on either side, being in the middle of a 17 hour gap. - If no midnight qualifies, the file fails by name and stops, rather than carrying on with nothing chosen. A guard that limps past its own precondition ends up reporting the store's habits as faults in the arithmetic, which is the failure this section just had. The other was 清除筛选 no longer dropping the range in the function that clears it. The reset moved into `resetBaseFilters()` and the assertion kept reading `clearFilters()`, so it went red on a change that broke nothing — the same mistake the comment above it warns about, one indirection later. It now follows the hop, and pins the hop itself: checking only the order would pass on a 清除筛选 that had stopped calling the reset at all, which is a filter the reader can neither see nor turn off. Neither edit is a relaxation. Both assertions hold the same thing they held before, and the second gained a check it did not have. 151 assertions against the live service, green. The teeth were then checked by hand, because this file cannot have a mutation control inside the suite: `run.mjs` promises that nothing in it needs a running Mini App, and this one cannot be checked at all without one — it derives its own fixture from `GET /api/trajectory`. Six mutations against a throwaway copy of the package taken outside the tree, so no shipped byte was touched, each required to fail with the assertion that owns the rule rather than a neighbour: the bound read as UTC, the session-start subtraction dropped, one comparison in `recordInRange()` flipped, `resetBaseFilters()` unhooked from `clearFilters()`, `clearTimeRange()` unhooked from `resetBaseFilters()`, and the two clears in the wrong order. Six caught by name, the last three producing exactly one named failure each. The client was byte-identical before and after. `tests/README.md` claimed a control for this file, `mutate-time-range.py`, that has never existed here — not in HEAD, not anywhere in the history. A file that names a control nobody can run is a pointer with nothing behind it, so the claim is replaced by what is actually true, including that the hand-check is a one-off and nothing re-runs it. Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. Separately, against a booted runtime on this machine's store: `node tests/verify-time-range.mjs <port>` -> 151 assertions, 0 failed, plus the six mutations above. * Answer a search from what is already on disk, and stop losing matches on the way A search was the one route that still rebuilt the whole lineage: `collectLineage`, then the identity view, then `buildTrajectoryPayload` over every row of every generation, then a scan. The index and the per-generation segments that already hold the searchable text were written on every request and read by nothing. On the largest session measured on this machine that was 3.7 seconds to type a word. before cold 3309.8 ms warm median 3667.3 ms warm max 3793.7 ms after cold 1730.3 ms warm median 1689.0 ms warm max 1703.5 ms Same session, same probe, artifacts directory emptied first. The median and the maximum landing on top of each other is the signature of the artifact path answering every time; before, the spread was the rebuild's. A live 20.4 MB session went 616 ms -> 208 ms. The remaining 1.69 s is the index itself: 34.2 MB of records.jsonl, read and parsed per search. The projection is gone; the index read is what is left, and sharding it per generation is the next thing and is not in here. - `handleSearch` tries the artifacts before anything expensive. `indexStore.read` checks every generation's source identity and the projection version, and the identity decisions baked into the index are a pure function of exactly those — so no second intersection against the current view is done. That extra step is the kind that silently disagrees with the thing it checks. - The ACTIVE generation gets a segment too, which reverses §8.8.1 rule 5. It was right that such a segment would be stale before it was written, and it still is; what changed is that nothing has to trust it. `sameSource` reads the generation's own size, so an appended file invalidates its own segment and only its own, and an untrusted artifact makes the search fall back. Without one there would be nothing to scan on a session still being written — the case a reader is most likely to search. - A generation with no records gets an empty segment rather than none. "No segment" and "no records" would otherwise report the same thing, and a session holding one empty generation would fall back forever, for a generation that could never match anything. - Missing anything falls back WHOLE. `total` is how many records in the session matched, so answering from 44 of 45 generations reports a smaller number and says nothing about being wrong. - A generation read short by our own cap now carries `truncated` in its source, and both `sameSource` and `sameSources` compare it. `size` is the whole file whether or not the read covered it, so before this a truncated generation's artifact was indistinguishable from a complete one. Two things this got wrong first, both worth more than the feature: **The segments were being built from the index's records.** The index drops `text`/`thinking`/`arguments`/`result` to stay small, and three of the five searchable fields are in that set — so a segment built that way searches two of five, and a reader finds less with nothing reporting it. Measured: needle "needle", 14 matches through the artifacts, 45 through the rebuild. Both plausible. The surface is now built from the payload's records with the index supplying only fileIdx and the byte range. **And the guard was green throughout.** It compares the two paths needle for needle on a fixture, which passed — because in that fixture every match happened to sit in `summary`. The guard now asserts directly that a record's segment line contains its own `result` while the index does not, and finds that record on purpose rather than hoping one is there. Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. verify-search-endpoint 63 -> 91 assertions and mutate-search-endpoint 15 -> 18, mutate-search-segments 17 -> 18, mutate-trajectory-index 17 -> 18, each caught by name. The five invariants are in the design note: same answer as the rebuild, fall back whole, segments only for generations in the lineage, a truncated generation is not trusted, and `total` is the count rather than the cap. * Put a search hit's answer on the segment's own line, and take the record index off that path A hit has to answer with something the reader can be sent to: the record's ledger index, its turn, its kind and its summary. Those lived in a second file read in step with the segment, and resolving a hit meant going back to the session's whole record index — 34.2 MB of JSONL for the largest session measured on this machine, parsed on every search. Each segment line now carries that answer, so the search path reads the segments and nothing else. The locators file goes with it: it said where each record came from, which nothing that answers a search needs, and those fields are on the line now. A hit the scan named but the reader cannot resolve falls back rather than being skipped, since `total` is how many records in the session matched and a smaller number says nothing about being wrong. The reader takes the whole scan in ONE call. Resolving one ordinal at a time walked the segment from byte zero per hit: correct answers, and about eighteen gigabytes of walking for a common needle, which measured SLOWER than the index read it replaced. Nothing about values can see that, so the guard counts the walk instead — a boxed string with counted per-line operations, an upper bound so a correct rewrite that walks some other way passes. Two more assertions cover the same rule: no per-ordinal reader is exported for a caller to reach for by mistake, and the answers come back in the order asked rather than the order they sit. - search-segments.mjs: `readSegmentHit` -> `readSegmentHits`; `buildSegment` writes `<compact json>\t<searchable text>` so a needle can never match into the answer; the scan tests only what is after the TAB. `SEGMENT_SCHEMA_VERSION` 1 -> 2. - server.mjs: `searchFromArtifacts` reads the segments and sorts by `index`. `collectSearchHitsFromArtifacts` is gone — it existed to let the artifact path use the index. - trajectory-index.mjs is untouched. The index is still written; what to do about a subsystem nothing reads is a separate decision and is not taken here. Also adds the root `plugin.json`, which `sync-sop.py classify` has been listing as SHIP — the host's `mcode.miniApp` entry pointer — and which was in the working tree and shipping to readers while never having been added to the repository. It was never deleted either: `git log --all` on that path returns zero commits, so "the whole directory installs" was true of a file git did not track. That is also why every projection refused until now. Measured on the largest session on this machine (189.1 MB, 45 generations, 25,427 messages, 36,525 records), same request, artifact directory emptied first: baseline cold 3309.8 ms warm median 3667.3 ms warm max 3793.7 ms this cold 1439.7 ms warm median 1416.6 ms warm max 1432.6 ms Six needles compared the artifact path against the rebuild on real data: identical bodies and identical totals (45 / 14,092 / 9,103 / 12,770 / 3,604 / 6,227). Of the 1,416.6 ms that remains, reading the 44.4 MB of surfaces costs 37.9 ms, hashing them 82.7 ms, and scanning them 20.7 ms; the rest is the lineage walk parsing every generation's rows, which this path does not use and which is the next thing to look at. Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. verify-search-segments 116 -> 122 assertions and mutate-search-segments 19 -> 22, verify-search-endpoint 93 -> 96 and mutate-search-endpoint 17 -> 19, all caught by name. * Put back the one counter the range-scoped KPI had lost Set a time range and 「追加 N」 disappears from the message card and the turn card. It is still on the kind chip below them, and it is still correct for the whole session — the whole-session cards read the server's stats, and these two read the same numbers re-scoped to the range. Both readings were right and nothing said so. The cause is a shape that lives in two processes. `scopedStats()` builds its own literal rather than reading the server's, the server declares `steers: 0`, and this copy did not — while the loop below still did `stats.steers += 1`. `undefined + 1` is NaN, NaN is sticky, and `isCount()` refuses a non-finite number, so both readers of that field dropped the term silently. It stayed invisible because the guard comparing scopedStats against the server numbers field by field carries a hand-written list, and `steers` was not in it. A hand-written list can only catch the fields someone remembered to write down. So the field is added to that list — the direct check for this field — and the new assertion reads both halves off the function itself, which is what stops the next one whatever it is called. It also covers the nested shape: `stats.tokens.input` needs `tokens` declared, and the parent name is what gets compared. Read points, all of them: `stats.steers` is read in exactly two places, both inside `renderKpi()`. `kpiStats()` has three call sites; the other two read only `messages` and `turnsWithPrompt`, so their numbers do not move. Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. mutate-turn-breakdown gained one mutation — delete the field from the literal — caught by name. client-check gains one static assertion, which is where it lives rather than in the range guard: that one needs a booted runtime, and run.mjs promises nothing in the suite needs one. Found by a retirement audit rather than by a failing guard, which is the first time that has been the discovery route here. * 总览条:去掉前置的筛选标志,改成无条件问同一个判定 总览条是下面那张表的画,但它在 spanPassesFilter() 前面挡了一个局部标志, 只列了五个轴里的两个(类型、搜索)。于是设了时间区间、或者只勾了一条异常规则时, 那个标志说「没在筛选」,条带继续画着下面那张表已经丢掉的记录 —— 读者看到的是「筛选了但什么都没变」。 标志是删掉,不是补全:一份列举轴的清单就是一份会漂移的清单。笔选的例外不由 「这个轴没列进去」表达,而是由 spanPassesFilter 传 null 表达。 可见行为因此改变,且是往对的方向:设区间或勾异常时,总览条会跟着变窄。 本地实测(189.1 MB / 25,427 条 / 45 代的那一场,872×900): - 点「今天」,区间内 0 条:指标读到 0 条消息 / 0 轮,#overview-lanes 的 outerHTML 变成 73 字节、零个子节点。改之前这里仍是三条满带。 - 点「工具失败」756 条:#overview-lanes 是 70,177 字符 ≈ 756 个 span, 只有工具道是红的。改之前这里仍是三条满带。 守卫补的是渲染,不是谓词。verify-overview.mjs 原来有 103 条断言、19 处引用 spanPassesFilter,但 renderOverview() 从没被执行过 —— 沙箱里没有 DOM。 这就是这个缺陷活下来的原因:谓词一直是对的,错的是调用方不再问。 所以给沙箱补上 createElement 和一个最小节点桩,把渲染跑起来, 断言写成读者会说的那句话:总览条是下面那张表的画,同样的记录、同样的顺序, 在每一种筛选下都是。103 → 127 条,每种都把行号写死再和 visibleEntries() 对比, 否则两边同时为空也会「通过」。 mutate-overview.py 加第三条变异,把那个局部标志原样写回去(isFiltering() 砍掉三轴的那种写法),按名抓住,报的是 strip under a time range。 套件 82/82。 * 删掉十一个没有消费者的 class,并让这件事变成一条守卫 样式表里有 159 条 class 选择器,十一个在 <style> 之外零命中:btn-primary、btn-text、 bar-success、chipbar、chips、chip 家族(含 chip-mark / is-on)、turn-meta .num、mono, 以及 legend —— 最后这个原始扫描漏了,手扫也把它读成活的,因为它夹在一堆活规则中间。 删完 148 条,少 94 行。 删规则之前先查了变量:btn-primary 是 --mcode-primary-action-bg 和 -text 的唯一消费者, 而这两个变量在三个主题块里各声明一次。只删规则不动变量就是新造六行死声明 —— 下一次扫死 CSS 的人会看见它们,然后不信这次扫描。删死代码做一半比不做更糟, 因为它把一个可核对的结论换成一个不可核对的。 守卫写的是性质不是名单(tests/verify-css.mjs,42 条): 1. <style> 里每条 class 选择器,在 <style> 之外至少有一个消费者。 2. 每个自定义属性都至少被 var() 读一次。 按名字写死的清单只能挡住同一个名字被加回来,而没人检查的死规则活不过两次重构。 第二条不是设想的,它就是第一条删 btn-primary 时撞上的那六行。 这个守卫自己出过一次错,错法值得记。第一版把「拼接前缀」当成无条件豁免:只要脚本里有 'turn-' + 这类写法,前缀下所有 class 永远算有消费者。后果是删掉 .turn-meta 的唯一 消费者之后,规则看着还是活的,守卫还是绿的 —— mutate-css.py 的第二条变异直接存活。 修法不是删变异,是把豁免收紧:前缀只能覆盖它能点出名字的那个类。生成器取值来自固定 字面量表(' pill-' + tone,kindTone() 返回九个之一),所以 pill-user 之所以活是因为 'user' 作为字面量存在,这不需要跟数据流就能查。前缀点不出后缀,就什么都不覆盖。 mutate-css.py 三条变异,三个方向都按名捕获:给没人建的标记加规则;轮次头去掉副行而 规则留下;主题块里声明一个没人读的属性。 套件 84/84。浏览器实测:页面结构与删除前逐项一致,无塌陷、无位移。 * 退役四个只有测试在用的函数,并把「没人用的函数」变成一条守卫 删掉 formatCompact、turnContext、turnContextSuffix、buildBlock。它们在产品路径上都没有 调用者;turnContext 只被 turnContextSuffix 调用,而 turnContextSuffix 只被测试调用。 turnContextParts 和 turnSubNode 是活的,没动 —— 它们是同一条链的上游。 collectSessionEntries 改判,不删:它有八个读者,全是测试,而 tests/ 随包分发, 所以读者和它一起装到读者机器上,它不是悬空的。「产品路径没有读者」和「没有读者」 是两件事。删它要先迁移八个测试,那是测试基础设施重构,不是退役。 两样东西必须跟着搬,不能跟着删: 一是注释。turnContext 头上那段讲的是产品理由 —— 两个不相交的「5」并排会被读成同一个数 数了两遍,另有 是分界。turnSubNode 还在实现同一个分界,所以注释搬了过去。 二是断言。context group is marked as a separate population 和 numbers before 另有 sum to the headline 原来跑在 turnContextSuffix 上 —— 一个没有任何人渲染的呈现器。这种断言会在 被渲染的那一行坏掉时依然通过,而那正是它存在的理由。改指到 turnSubNode,并且真的跑它: 沙箱只有 { console },所以给 el 和 document.createTextNode 补了最小桩,把渲染出来的文本 从树上读回来。顺带修好一处 —— 旧断言传的是 compactionTurns,而 turnContextParts 读的是 compactions,所以 压缩 那一项从来没进过断言;它过了,但它没在测自己注释里说的那件事。 mutate-record-detail.py 里那条变异的替换文本原本调用 buildBlock。删掉它会把这条控制变成 崩溃(渲染时 ReferenceError)而不是按名失败,而报崩溃的控制等于没报。替换文本改用活代码 重写,缺陷不变:结果页直接渲染 record.result,瘦身之后它永远是空的。17 条变异仍全部按名捕获。 mutate-turn-breakdown.py 的锚点跟着分隔符搬到 turnSubNode,回归本身没变。 守卫写的是性质:内联脚本里声明的每个函数,都在自己的声明之外出现过(tests/verify-dead-code.mjs)。 不是名单 —— 名单只能挡住那三个被加回来。这条规则在这份文件上是准的:批次之前 230 个声明、 4 个无读者,之后 226 个、0 个。 规则定下来之前错了三次,三次全是误报。只认调用会把十三个 addEventListener 的事件处理器 全报成死的,所以是「出现过」不是「被调用过」;没剔注释会把散文里的名字当读者; 为不让页面挂载而切掉的启动块里正好有 init() 的唯一一次调用。三处都写进了守卫的头注释。 另外断言了规则自己的前提:顶层函数缩进恰好 6 空格。一次重新格式化就能让规则悄悄不再 覆盖大部分文件,而那是最像成功的失败。 三条变异按名捕获,其中第三条「把唯一的引用只留在注释里」专治「规则没剔注释」那个错误方向。 范围只到 index.html;server.mjs 有同形状的东西,但它的导出被八个随包分发的测试读着。 套件 86/86。 * 退役记录索引,把投影收窄成只产三个定位字段 磁盘上的索引目录是死的,投影那一步是活的 —— 所以这次退役的不是「一个索引」,而是 一份没人读的产物,加上一次每次发布都要重算的完整记录投影。 server.mjs 从索引模块导入九个名字,六个零读者。剩下三个里有一个例外:索引行的返回值 在段构建时被当场读走,每段行取它的 fileIdx / byteStart / byteEnd。把它连同模块一起删掉, 搜索就再也拿不到记录落在文件里的位置 —— 那正是「按记录取详情」端点的输入。 因此投影保留,但收窄成只产那三个字段,搬进 search-segments.mjs(这三个字段就是段行 JSON 前缀的一部分,搬过去之后段行只有一个来源)。trajectory-index.mjs 整个删除。 「无定位得到 -1/-1/-1,且不得误归文件 0」这条语义原样保留。它此前分散在两层,各守一半: 段守卫只管「完全不带定位字段的输入」的归一化,根本不调用投影;投影层那条只有一处守着, 而它马上要跟着模块一起没了 —— 所以搬到了新守卫里,并且真的跑投影函数,不是抄一遍它的 匹配表达式。 守卫与变异: - tests/verify-locator-rows.mjs(18 条):顺序、形状、fileIdx、字节范围、同名文件不得按 basename 匹配、无定位不得变成文件 0、邻居保住自己的、空输入与空 locator map - tests/mutate-locator-rows.py(4 条):四个方向各一条,全按名捕获 - verify-search-endpoint.mjs 96 → 97 条 重写了一条断言。原来它说的是「这条搜索路径不读索引」,而写索引的调用在同一个函数里, 所以它要靠作用域才成立。现在索引两边都没有了,规则就写成它真正是的东西:整个服务里 没有任何记录索引。断言之前先剔注释 —— 服务端要解释索引去哪了,而一句提到它的解释恰好会 让「没有索引」这条规则失败,那么唯一的写法就是什么都不写。 这条断言原本是三个名字的名单,而名单只能挡住名单上的东西。补了一条按性质的:产物根目录下 只允许存在一个子目录(段所在的那个)。用一个 token 名单抓不到的名字注入验证过,它按名失败 —— 一个用新名字重建的索引会通过名单,被这条抓住。 mutate-search-endpoint.py 补到 20 条,新增的那条用活代码重建被退役的形状:段旁边再写一份 产物,没有任何人读它。这不是把模块加回来,是有人真会写出来的回归形状。 顺带修好一段已经过期的注释:它说索引还在用、并且「是它标出每条记录的 index、turn 和 kind」。 索引没有了,turn 来自 payload 的 turn 表,投影只出文件与字节范围。 存量目录留着不动:停写让新增写入归零,不改变已经落盘的文件(实测每个会话每个视图 8.6 MB), 回收是迁移,不是停止写入的同一步。已确认全仓库没有任何删除或修剪逻辑碰它,所以留着是安全的。 套件 86/86。 * 让新鲜度横幅能被任何一次成功回答收掉,并把失败分成读者能行动的两类 横幅写着「正在重试」,而它能被收掉的唯一条件是「服务端回了一个带 body」—— 304 不算。 而 304 正是页面静止时每一次轮询的答案:cacheKey 只由 dir|sizeBytes|lastActiveAt 决定, 会话没动就是同一个 tag,服务端必然回 304。于是**一次瞬时的连接抖动 + 一个安静的会话 = 横幅永久停留**,并且一直宣称自己在重试。点「刷新」能收掉它,但那只是因为手动刷新丢掉了 validator,强制拿了一次完整 body。 顺带查出同形的第二条:会话列表有完全一样的 304 早退,以及一个**写了三处、读了零处**的标志。 列表读取失败对读者完全不可见。 改动: - 两个散落的布尔(state.stale、state.sessionsError)换成这一层自己的 freshness 模型, 唯一写者 noteFresh()。原来 state.stale 有三个写点,现在每个字段只有一个。 - **304 也算新鲜** —— 轨迹和会话列表两条路都改了。 - 认不出的失败拆成两类:「与本地服务的连接中断」和「服务端返回了无法读取的内容」。 原来四种错误码加上这两类全部塌缩成同一句「更新失败」,页面上不留计数也不打日志, 一次抖动和一次持续故障事后无法区分。 - 连续失败从第二次起显示次数与来源(轨迹 / 会话列表)。第一次不给这句解释,因为一次抖动 会在一个轮询内自己消失。 - fetchSessions 的 HTTP 失败原本把 status 扔掉了,所以任何非 2xx 都会被归成「断线」。 **修复不许变成另一种代价**:render() 重建整个账本,安静会话每 8 秒一个 304。 noteFresh() 返回「状态是否真的动了」,只有动了才渲染。这条有断言,不是靠自觉。 四问在 .research 的退役审计 §11,含逐个读点表。 守卫 tests/verify-freshness.mjs(27 条):把交付的内联脚本原样载入沙箱**真跑**, 断言写成场景而不是符号清单 —— 失败后接 304 / 接 200 / 连抖三次 / 断线 vs 坏响应 / 只有列表失败 / 五个 304 之后渲染次数为 0。 这一层此前没有任何守卫(state.stale、sessionsError、stale-bar 在 tests/ 里的匹配数是 0), 这正是四个缺陷能活下来的原因。 变异 tests/mutate-freshness.py(5 条)全按名捕获:把 304 当成什么都没发生;失败列表只记不看; 恢复不清计数;断线被报成服务端的问题;每个 304 都重渲染。 另加一条反方向的用例 —— 只加一行注释必须仍然全绿,否则守卫读的是散文不是行为。 套件 88/88。 * 总览条按像素列聚合,不再每条记录建一个节点 最坏会话(36,525 条)下这条带子建 36,525 个 `.ov-span`,每个至少 2px 宽,于是 ~36,000 个是 互相压在一起的 —— 872px 的带子上只有约 436 格,每格读者看到的是「最后画上去的那条」。 每次轮询带 body 的重建,浏览器实测 3,937 ms,轮询间隔 2.5 秒。 改动:按**像素列**建节点,一列一条车道取最后一个覆盖它的记录 —— 也就是原来叠放时每个像素上 读者真正看到的那个值。所以**每列的逻辑答案和以前是同一个**。但画面不是逐像素相同的: 旧的盒子最小 2px 且带最多 1px 的间隙,新的是 1px、间隙 0。所以是**更锐**,不是**一样**。 颜色、透明度、车道、`data-kind` 全部仍然来自同一批 CSS 规则:这次只决定**有多少个节点** 和它们**站在哪**,调色板没有第二个来源。这也是没有选 canvas 的原因。 交互一行都没动,因为它们本来就不走 DOM: 笔刷区间 / 滚轮缩放 overviewFractionAt → overviewTimeAt → nearestSpanAt 区间是否含某记录 recordInSelection,读 overviewSpansById 这个 Map `overviewSpansById` 仍然为**每一条**建条目 —— 区间判定要用。只有画被聚合了。 悬停和点击改了:以前 `spanAt()` 用 `document.elementFromPoint` 拿指针下的节点, 在每像素 42 条时它已经是随意的(返回最后画上去的那条);聚合之后它变成**只有列的赢家 有节点**,其余记录根本够不着。现在车道仍然从 DOM 读(车道在 CSS 里,14px 的间距不该在 JS 里抄一份),而记录从**完整模型**里取 —— 指针附近在时间上最近的那条。悬停因此比以前精确, 不是变差。 选中记录与悬停记录各多一个节点,压在最上层:列是一个范围,而「这一条」不是; 选中的那条还可能落在当前视口之外,那更是列表达不了的。 条带没被排版过时退回「每记录一列」,也就是改动前的行为 —— 这不是防御性写法,而是这个函数 不该把画面变得更糟的保证。 实测(同一份探针、同一台机器、同一个桩): | 记录数 | renderOverview 改前 | 改后 | 节点改前 | 节点改后 | | ---: | ---: | ---: | ---: | ---: | | 5,000 | 36.3 ms | 34.9 ms | 5,000 | 2,559 | | 36,525 | 546.5 ms | 192.5 ms | 36,525 | 2,616 | 节点数在 20,000 条就饱和(872px × 3 车道 = 2,616),与记录数无关。outerHTML 从约 2.7 MB 降到约 190 KB。 **收益只算一遍**:去掉的是 DOM,不是那次遍历。改后 192.5 ms 里 overviewModel() 自己占 78.6 ms(41%),其余约 114 ms 是 36,525 次循环本身。「每记录一个节点」的成本归零了, 「每记录走一遍」的成本原样还在。1,000 条那一档变慢 2 ms(聚合的常数项),那一档本来也不痛。 四问在退役审计 §12,逐个读点列了出来。其中一条是**新断言当场抓到了聚合自己造出来的缺陷**: 3 列放 5 条记录时中间那条一列都没占到,从条带上整个消失 —— 改之前它是有节点的,丢不掉。 所以承诺按两种情形分开写:条带宽于记录数时每条独占一列、条带等于列表(断言原样保留); 条带窄于记录数时承诺「画的每条都是列表留下的」且「覆盖记录走到的每一列」。 守卫 verify-overview.mjs 127 → 146 条。stripIds() 改为去重 —— 一条记录可以占住多列, 而「每条记录一个节点」在每像素 42 条时本来就不是读者能验证的话。 mutate-overview.py 3 → 8 条,全按名捕获。原有三条变异锚在 spanPassesFilter 那行,聚合发生在 它之后,锚点原样继续解析。 其中一条是被验收评审推翻之后才写的:第一版守卫直接调 markOverviewHover('g3'),注入 id, 所以它绕过了必须「发现」这个 id 的那条路 —— 而那条路当时是坏的。它是绿的,缺陷也是绿的。 现在这一段驱动真实的 pointermove,让 DOM 按浏览器的方式答一个**列赢家**,再要求模型答出 **那条输掉所有列的记录**。为此桩补了 classList:没有它,命中测试在测试里根本无法成立, 这也正是这个缺陷能活下来的原因。 套件 88/88。 * 悬停和点击各自要的东西不一样:命中测试返回段,而不是行键 聚合把「每条记录一个节点」变成「每像素列一个节点」之后,只有**占到过某一列**的记录在 DOM 里有节点。 而命中测试当时是 `document.elementFromPoint` 往上走到 `.ov-span`,读它的 `data-record-id`,再查 Map —— 于是**输掉所有列的记录既点不到也悬停不到**,而上一批为它们写的悬停标记因此永远不会亮。那条标记是死代码, 它写的场景它自己触发不了。 - **车道从 DOM 读**:14px 的车道间距住在样式表里,指针的 y 是唯一诚实的办法。在 JS 里抄第二份, 意味着以…
1 parent 2330faa commit da780da

65 files changed

Lines changed: 13919 additions & 800 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎plugins/avatasia/mmc-trajectory/.minimax-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"schemaVersion": 1,
33
"name": "mmc-trajectory",
44
"displayName": "会话轨迹",
5-
"version": "1.1.0",
5+
"version": "1.2.0",
66
"description": "按轮次浏览当前会话的模型轨迹:消息流、思考过程、工具调用与结果、token 与耗时统计。",
77
"author": "avatasia",
88
"icon": "icon.png",

‎plugins/avatasia/mmc-trajectory/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ English | [简体中文](README.zh-CN.md)
44

55
Browse the model trajectory of a MiniMax Code conversation: messages, reasoning, tool calls and their results, Token usage, and per-turn timing.
66

7-
Author: [avatasia](https://github.com/avatasia) · Version: `1.1.0`
7+
Author: [avatasia](https://github.com/avatasia) · Version: `1.2.0`
88

99
### Before you change this page
1010

@@ -131,6 +131,7 @@ The runtime database is opened in read-only mode. If it is missing, unreadable,
131131
- A single `messages.jsonl` is read up to 64 MB. Larger sessions are truncated and the page says so.
132132
- The ledger does not use virtual scrolling; every rendered row is a real element. The page therefore opens on the newest 200 records, and the window bar button switches to the whole session. The button names what pressing it will do, not the current state: 「显示全部」 at the default and 「显示最新 200 条」 once everything is showing, while the count beside it reports what is on screen. That full view is opt-in for a reason: on a 9,900-record session it is roughly 57,000 elements rebuilt every 2.5s poll, which was measured to freeze the page rather than be read. 「向上加载更早的 N 条」 widens the window in steps and a poll does not undo it. A sticky window bar under the overview strip offers that choice behind four explicit controls: switch the strip between equal width and a real time axis, load 200 earlier records, jump back to the latest 200, and show everything. "Show everything" builds every record in the session into the DOM at once and gets noticeably slower on large sessions; incoming records do not knock it back down to 200.
133133
- `messageCount` and `turnCount` in the session picker are estimates derived from a bounded prefix of the file; exact values come from the selected session.
134+
- A session the runtime spawned from another one is marked 「子会话」 in the picker. The parent is deliberately not named: a branch is always newer than the session that spawned it, so its parent has usually aged out of the most-recent-N window this list is built from, and a lookup that fails quietly is how a picker ends up asserting something it cannot see.
134135
- Light theme token coverage is verified — every `--mcode-*` token the page consumes is defined for both themes — but its rendered appearance was not visually checked; the page was exercised under a dark system preference.
135136

136137
## Tested environment

‎plugins/avatasia/mmc-trajectory/README.zh-CN.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
按轮次浏览一段 MiniMax Code 对话的模型轨迹:消息流、思考过程、工具调用与结果、token 消耗与每轮耗时。
66

7-
作者:[avatasia](https://github.com/avatasia) · 版本:`1.1.0`
7+
作者:[avatasia](https://github.com/avatasia) · 版本:`1.2.0`
88

99
### 动手改这个页面前
1010

@@ -117,6 +117,7 @@ MiniMax Code 不会告诉 Mini App 是哪段对话打开了它的页面:传给
117117
- 单个 `messages.jsonl` 最多读取 64 MB。更大的会话会被截断,页面会明确说明。
118118
- 台里没有用虚拟滚动,每一行都是真实的 DOM 元素。页面默认只展示最新 200 条,窗口条上的按钮可以切到全量;全量是可选的,因为它很贵 —— 在这个会话上约 9,900 条、约 5.7 万个元素,每 2.5 秒轮询重建一次,实测会直接卡死页面。总览条正下方的吸顶窗口栏提供三个显式控件:「总览轴」「向上加载更早的 200 条」,以及在最新 200 条与全量之间切换的那一个按钮(它写的是按下去会变成什么,默认为「显示全部」,全量时为「显示最新 200 条」;当前窗口由右边的计数条报告,两者回答的是不同问题)。「总览轴」只改总览条的横轴——等宽(每条记录一样宽,完全忽略时间)或真实时间轴(宽度等于该记录的真实耗时);它不筛选、不排序,台账里的行和页面上的任何计数都不变。「向上加载更早的 200 条」每点一次就多加一页,轮询不会把已经加宽的窗口重置回去;点切换按钮也不会自动滚动页面,读者正在看的那一行会留在原地。
119119
- 会话选择器里的 `messageCount` / `turnCount` 是从文件的有界前缀估算的;精确值以选中会话后展示的为准。
120+
- 会话选择器里,被运行时从另一个会话派生出来的会话标为「子会话」,但不写父会话的名字:分支一定比派生它的会话新,而列表按最近活动取前 30 个,父会话通常已经掉出这个窗口。静默失败的查找,最终会让列表声称一个它看不见的东西。
120121
- 浅色主题的 token 覆盖已验证(页面用到的每个 `--mcode-*` 变量在两套主题下都有定义),但渲染外观未做视觉检查;功能验收是在深色系统偏好下进行的。
121122

122123
## 测试环境

0 commit comments

Comments
 (0)