mirror-mind 0.33.4 → 0.34.0
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/.agents/plugins/marketplace.json +16 -0
- package/AGENTS.md +4 -4
- package/docs/releases/index.md +1 -0
- package/docs/releases/v0.34.0.md +142 -0
- package/package.json +2 -3
- package/plugins/mirror-mind/.claude-plugin/plugin.json +1 -1
- package/ts/src/conversation/backfill.ts +13 -129
- package/ts/src/conversation/commandTurn.ts +42 -0
- package/ts/src/conversation/deferredCloseTail.ts +154 -0
- package/ts/src/conversation/logger.ts +104 -20
- package/ts/src/conversation/loggerCli.ts +6 -18
- package/ts/src/conversation/loggerRuntime.ts +6 -1
- package/ts/src/conversation/runtimePolicy.ts +50 -0
- package/ts/src/conversation/sessionComposites.ts +54 -8
- package/ts/src/frontDoor/routing.ts +3 -2
- package/ts/src/frontDoor/runtimeRoute.ts +13 -1
- package/ts/src/guards/claudePlugin.ts +39 -2
- package/ts/src/guards/packContents.ts +4 -4
- package/ts/src/guards/retiredSurfaces.ts +36 -0
- package/ts/src/hooks/claude.ts +16 -4
- package/ts/src/hooks/gemini.ts +3 -1
- package/ts/src/hooks/main.ts +6 -16
- package/ts/src/providers/config.ts +11 -0
- package/ts/src/runtime/diagnose.ts +115 -1
- package/ts/src/runtime/pluginWiring.ts +135 -0
- package/ts/src/runtime/update.ts +39 -6
- package/ts/src/runtime/wiring.ts +24 -10
- package/scripts/codex-hooks/session-end.sh +0 -85
- package/scripts/codex-hooks/session-start.sh +0 -85
- package/scripts/codex-mirror.sh +0 -48
- package/ts/src/hooks/codex.ts +0 -45
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mirror-mind",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Mirror Mind"
|
|
5
|
+
},
|
|
6
|
+
"plugins": [
|
|
7
|
+
{
|
|
8
|
+
"name": "mirror-mind",
|
|
9
|
+
"source": {
|
|
10
|
+
"source": "local",
|
|
11
|
+
"path": "./plugins/mirror-mind"
|
|
12
|
+
},
|
|
13
|
+
"description": "Mirror Mind — local-first memory and identity for agentic runtimes."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
package/AGENTS.md
CHANGED
|
@@ -18,10 +18,10 @@ The mirror operates in four modes, chosen automatically based on context:
|
|
|
18
18
|
Mirror, Builder, Explorer, and Soul.
|
|
19
19
|
|
|
20
20
|
Each skill below is named three ways: `/mm-<skill>` in Pi and Gemini CLI,
|
|
21
|
-
`$mm-<skill>` in Codex, `/mm:<skill>` in Claude Code. A
|
|
22
|
-
|
|
23
|
-
`mirror-mind:mm-<skill>` as the skill's own
|
|
24
|
-
the same skill.
|
|
21
|
+
`$mm-<skill>` in Codex, `/mm:<skill>` in Claude Code. A **plugin** install
|
|
22
|
+
prefixes the plugin's name — `/mirror-mind:mm:<skill>` in Claude Code's menu,
|
|
23
|
+
`$mirror-mind:mm:<skill>` in Codex, `mirror-mind:mm-<skill>` as the skill's own
|
|
24
|
+
name — and the forms below refer to the same skill.
|
|
25
25
|
|
|
26
26
|
**Mirror Mode** — activate for: life decisions, feelings, business strategy,
|
|
27
27
|
writing, mentoring, health, existential questions, sensemaking, psychological
|
package/docs/releases/index.md
CHANGED
|
@@ -12,6 +12,7 @@ Future release notes should use the structure defined in [Release Notes](../proc
|
|
|
12
12
|
|
|
13
13
|
## Releases
|
|
14
14
|
|
|
15
|
+
- [v0.34.0 — Codex Through the Canonical Plugin](v0.34.0.md) — minor release closing CV21.DS4: Codex runs Mirror through the canonical plugin, installed in two commands from a marketplace the package ships, measured live at L4 on Codex 0.163.0. The v0.32.1 wrapper wiring no longer ships; `runtime diagnose` and `runtime update` say when Codex's cached plugin copy is behind and name the re-add that brings it level. No migration.
|
|
15
16
|
- [v0.33.4 — A Name Is a Key](v0.33.4.md) — patch release of three first-run corrections: two from a contributor's PR #43 and CR141, which its review opened. `init --help` created a home named `--help`, `init ../escaped` created one outside `.mirror-minds`, and `init "My User"` put a space into `MIRROR_USER`; a user name is now a kebab-case key like a journey slug or a persona id, refused otherwise with a name that would work and nothing created. `identity edit` on Windows opens Notepad when no editor is named. Existing homes untouched. No migration.
|
|
16
17
|
- [v0.33.3 — Mirror Finds Itself Under Any Path](v0.33.3.md) — patch release of one Change Request, CR140, carried by a contributor's PR #42: Mirror read its own install path from `URL.pathname`, so a checkout or package under a path with a space or a non-ASCII character read as `Install: unknown`, the welcome card showed no update, and `runtime update` refused. The path is now a filesystem path, a guard keeps the idiom out, and an `unknown` install names the path it looked up from. On Windows, npm's global layout is recognized and npm runs with no shell, verified on Windows in CI for the two files they touch; there is still no Windows product. Affected installs update once by hand. No migration.
|
|
17
18
|
- [v0.33.2 — Git Runs Where Mirror Lives](v0.33.2.md) — patch release of one Change Request, CR138: on a clone install, `runtime status`, `version`, and `diagnose` describe the install from any directory, and `diagnose` grades Mirror's own tree. Git now runs only in the install's own checkout, so a checkout someone else made can no longer run a `core.fsmonitor` command through these commands; `version --start PATH` is the one explicit exception. Package installs were never exposed. No migration.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
digest: >
|
|
3
|
+
v0.34.0 is a minor release closing CV21.DS4: Codex runs Mirror through the
|
|
4
|
+
canonical plugin, the same one Claude Code loads. The package ships a Codex
|
|
5
|
+
marketplace; two commands install the plugin, Codex asks once to trust its
|
|
6
|
+
hooks, and from then on every session carries the skills, the Operating
|
|
7
|
+
Instructions, per-turn Mirror Mode injection, the MCP server, and logging as
|
|
8
|
+
`codex` with every reply beside its prompt — measured live at L4 on Codex
|
|
9
|
+
0.163.0. The v0.32.1 wrapper wiring is removed: `scripts/codex-mirror.sh`,
|
|
10
|
+
its hooks, and the JSONL backfill no longer ship. Because Codex copies the
|
|
11
|
+
plugin into a versioned cache, `runtime diagnose` and `runtime update` now
|
|
12
|
+
say when that copy is behind and name the one command that brings it level.
|
|
13
|
+
No migration.
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# v0.34.0 — Codex Through the Canonical Plugin
|
|
17
|
+
|
|
18
|
+
**Date:** 2026-10-17
|
|
19
|
+
|
|
20
|
+
## Highlights
|
|
21
|
+
|
|
22
|
+
- **Codex installs Mirror as a plugin, from a marketplace the package ships**
|
|
23
|
+
(CV21.DS4.US1). Two commands — `codex plugin marketplace add "<pkg>"`, then
|
|
24
|
+
`codex plugin add mirror-mind@mirror-mind` — and Codex asks once, at the
|
|
25
|
+
next session, to trust the plugin's hooks. `mirror init` prints exactly
|
|
26
|
+
these steps when it finds `codex` on the PATH.
|
|
27
|
+
- **Codex is at L4, measured live** (CV21.DS4.US4, on Codex 0.163.0): all 25
|
|
28
|
+
skills, the Operating Instructions as a developer message, Mirror Mode on
|
|
29
|
+
an ordinary turn and on `$mirror-mind:mm:mirror`, the 7 MCP tools behind
|
|
30
|
+
Codex's `tool_search`, and every prompt and reply logged as `codex`. Codex
|
|
31
|
+
detaches the TUI on `/quit` and fires SessionEnd only when its daemon reaps
|
|
32
|
+
the idle thread, so Mirror gained a safety net for sessions that outlive
|
|
33
|
+
their TUI: session maintenance closes them as orphans
|
|
34
|
+
(`MIRROR_STALE_ORPHAN_MINUTES`, default 30).
|
|
35
|
+
- **A bounded SessionEnd no longer cuts extraction** (CV21.DS4.TS2). Codex
|
|
36
|
+
bounds the hook at 1 s; the close now runs no LLM work, and the next
|
|
37
|
+
session start finishes the deferred tail. Grok's 1.5 s bound rides the same
|
|
38
|
+
change.
|
|
39
|
+
- **`$`-skill turns are command turns** (CV21.DS4.TS3), as `/`-commands are
|
|
40
|
+
on every other runtime, with Mirror's explicit form the one exception.
|
|
41
|
+
- **The v0.32.1 Codex wiring is removed** (CV21.DS4.TS5). What no longer
|
|
42
|
+
exists: `scripts/codex-mirror.sh`, `scripts/codex-hooks/`, the
|
|
43
|
+
`codex:session-*` hook entries, and
|
|
44
|
+
`conversation-logger backfill-codex-session` — the plugin logs Codex turns
|
|
45
|
+
as they happen, so nothing re-imports the transcript at exit. A
|
|
46
|
+
retired-surfaces guard keeps the wiring from drifting back.
|
|
47
|
+
- **An updated Mirror now reaches Codex's plugin cache — or says so**
|
|
48
|
+
(CV21.DS4.TS6). Codex copies the plugin into its cache at a version, so an
|
|
49
|
+
npm update changes the install but not the copy Codex runs. `runtime
|
|
50
|
+
diagnose` reports `codex_plugin_cache_behind` naming both versions and the
|
|
51
|
+
measured command — `codex plugin add mirror-mind@mirror-mind`; re-adding
|
|
52
|
+
refreshes the cache in place, no remove needed — and a successful `runtime
|
|
53
|
+
update` prints the same line. A dangling Claude Code skills link is
|
|
54
|
+
reported too (`claude_plugin_link_dangling`). Absence is not drift: an
|
|
55
|
+
unwired machine stays green.
|
|
56
|
+
|
|
57
|
+
## Where We Started
|
|
58
|
+
|
|
59
|
+
v0.33.0 proved one plugin works as it is on Claude Code, Codex, and Grok.
|
|
60
|
+
But a Codex user still wired Mirror by hand: a skills link, an `AGENTS.md`
|
|
61
|
+
link, and a wrapper script around every session that imported the transcript
|
|
62
|
+
from JSONL at exit — v0.32.1's wiring, built when Codex had no hook system.
|
|
63
|
+
Codex 0.157 added plugins; DS4 asked whether the canonical plugin could be
|
|
64
|
+
the whole answer, and set a gate: the old wiring retires only once parity is
|
|
65
|
+
shown live.
|
|
66
|
+
|
|
67
|
+
## What Changed
|
|
68
|
+
|
|
69
|
+
**The install is two commands.** The npm package root carries
|
|
70
|
+
`.agents/plugins/marketplace.json`, one local plugin — the same
|
|
71
|
+
`plugins/mirror-mind` Claude Code loads. Codex copies it into its cache at
|
|
72
|
+
the plugin's version and runs its Claude-family hooks as they are; runtime
|
|
73
|
+
detection reads Codex's `turn_id` and labels the session `codex`.
|
|
74
|
+
|
|
75
|
+
**The gate was met, so the old path is gone.** US4 measured L4 twice, live,
|
|
76
|
+
through the shipped plugin alone. TS5 then removed the wrapper, its generated
|
|
77
|
+
hooks, the `codex:*` hook family, the JSONL backfill (its only caller was the
|
|
78
|
+
wrapper's exit hook; the plugin's SessionEnd closes without reading it),
|
|
79
|
+
their pack-contents guards, and their CI step. The docs name the marketplace
|
|
80
|
+
install and nothing else.
|
|
81
|
+
|
|
82
|
+
**The one stale copy is watched.** Claude Code reads the plugin through a
|
|
83
|
+
link, in place; Codex runs a versioned copy. TS6 taught `runtime diagnose`
|
|
84
|
+
and `runtime update`'s post-update hints to compare that copy against the
|
|
85
|
+
installed version — the landed version, read from the just-updated tree —
|
|
86
|
+
and to print the one command that works on Codex 0.163.0, which has no
|
|
87
|
+
plugin-update verb: the re-add.
|
|
88
|
+
|
|
89
|
+
## Upgrading
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm install -g mirror-mind@latest # or: mirror runtime update
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**If Codex runs the plugin already:** after this update (and any future one),
|
|
96
|
+
bring the cache level once:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
codex plugin add mirror-mind@mirror-mind
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`mirror runtime diagnose` will tell you whenever this is needed.
|
|
103
|
+
|
|
104
|
+
**If you wired Codex before the plugin** (a skills link, an `AGENTS.md` link,
|
|
105
|
+
the wrapper script): install the plugin with the two commands above, then
|
|
106
|
+
undo the old wiring — the exact steps are in Getting Started's Codex section.
|
|
107
|
+
If you do nothing, everything still works, at the cost of doubled skills and
|
|
108
|
+
double logging where the old wrapper still runs; an older installed package
|
|
109
|
+
keeps its own wrapper copy, but this version ships none.
|
|
110
|
+
|
|
111
|
+
No migration. Existing homes, sessions, and memories are untouched.
|
|
112
|
+
|
|
113
|
+
## Conscious Exclusions
|
|
114
|
+
|
|
115
|
+
- **Codex logging is no longer end-to-end tested**, by decision: the retired
|
|
116
|
+
wrapper smoke was the only artifact-level Codex check. Confidence now rests
|
|
117
|
+
on the shared Claude-family hook path (smoked), runtime-detection units,
|
|
118
|
+
and US4's live measurement.
|
|
119
|
+
- **Mirror never runs `codex` itself.** The cache readers are read-only
|
|
120
|
+
filesystem checks; the person runs the printed command.
|
|
121
|
+
- **Two named silences in the wiring checks:** a cache *ahead* of the install
|
|
122
|
+
(a deliberate downgrade gets no advice from the older tree), and a Claude
|
|
123
|
+
link that resolves to another install tree (healthy means the target
|
|
124
|
+
reads). Both are recorded as decisions with tests.
|
|
125
|
+
- **CR142 is captured, not fixed:** the Claude plugin smoke's negative probe
|
|
126
|
+
fails on a machine with a globally installed `mirror-hook` — a smoke
|
|
127
|
+
isolation bug found during TS5's validation, pre-existing, filed under
|
|
128
|
+
RS010.
|
|
129
|
+
|
|
130
|
+
## What We Learned
|
|
131
|
+
|
|
132
|
+
The gate ordering did the work: retiring the old wiring only after L4 was
|
|
133
|
+
measured live meant the removal was a deletion, not a bet. And the one real
|
|
134
|
+
surprise of the arc — Codex sessions outlive their TUI — was caught by the
|
|
135
|
+
live measurement, not by any test, which is why the measurement is part of
|
|
136
|
+
the method.
|
|
137
|
+
|
|
138
|
+
## Next Horizon
|
|
139
|
+
|
|
140
|
+
DS4 closes with this release. CV21's remaining runtime stories — Antigravity
|
|
141
|
+
through a native layer (DS7), Grok (DS10) — are pulled deliberately from the
|
|
142
|
+
CV21 index.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mirror-mind",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Mirror Mind: a local-first memory and identity framework for agentic AI runtimes (Pi, Claude Code, Gemini CLI, Codex)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,8 +38,7 @@
|
|
|
38
38
|
".claude/hooks",
|
|
39
39
|
".gemini/hooks",
|
|
40
40
|
"plugins/mirror-mind",
|
|
41
|
-
"
|
|
42
|
-
"scripts/codex-mirror.sh",
|
|
41
|
+
".agents/plugins",
|
|
43
42
|
"AGENTS.md",
|
|
44
43
|
"README.md",
|
|
45
44
|
"LICENSE"
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
// Session backfills from runtime-written JSONL (CV22.DS7.US10 slice E).
|
|
2
2
|
//
|
|
3
|
-
// Ports `backfill_pi_sessions`
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Ports `backfill_pi_sessions` from `src/memory/cli/conversation_logger.py`:
|
|
4
|
+
// it imports closed sessions the live hooks never saw, through the atomic
|
|
5
|
+
// seam in `sessionImport.ts`, and is graded against
|
|
6
|
+
// `ts/test/goldens/backfill.golden.json`. (The Codex importer,
|
|
7
|
+
// `backfill_codex_session`, was ported beside it and retired with the
|
|
8
|
+
// v0.32.1 Codex wrapper by CV21.DS4.TS5: its only caller was the wrapper's
|
|
9
|
+
// session-end hook, and the plugin logs Codex turns live.)
|
|
7
10
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// through `_generate_title` and a bare column write with no provenance.
|
|
13
|
-
// * Pi tolerates a corrupt or structurally invalid file by skipping THAT
|
|
14
|
-
// file and continuing the walk; Codex gives up on its single file.
|
|
15
|
-
// * Pi parses every timestamp form (millisecond epoch, string, absent);
|
|
16
|
-
// Codex stores the raw `timestamp` value and lets the model reject a
|
|
17
|
-
// missing one.
|
|
11
|
+
// Pi titles through `set_provisional_title` (whitespace collapse plus
|
|
12
|
+
// `title_source`/`title_status` provenance in metadata), tolerates a corrupt
|
|
13
|
+
// or structurally invalid file by skipping THAT file and continuing the
|
|
14
|
+
// walk, and parses every timestamp form (millisecond epoch, string, absent).
|
|
18
15
|
//
|
|
19
16
|
// Nothing here touches a provider or the clock directly: `now` is injected
|
|
20
17
|
// through `LoggerDeps`, which is what makes "no timestamp -> now" pinnable.
|
|
@@ -24,12 +21,11 @@
|
|
|
24
21
|
// home meant the person's whole Pi history, copied and spent on before the
|
|
25
22
|
// first prompt. The floor is explicit (`notBefore`), never defaulted, and
|
|
26
23
|
// the rule fails closed: there is no value that means "everything" except
|
|
27
|
-
// the epoch, which says so.
|
|
28
|
-
// one session the wrapper names, not a history.
|
|
24
|
+
// the epoch, which says so.
|
|
29
25
|
|
|
30
26
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
31
27
|
import { join, relative, sep } from "node:path";
|
|
32
|
-
import {
|
|
28
|
+
import { type LoggerDeps, setProvisionalTitle } from "#conversation/logger.ts";
|
|
33
29
|
import { type ImportedMessage, importClosedConversation } from "#conversation/sessionImport.ts";
|
|
34
30
|
import type { WritableDatabase } from "#db/database.ts";
|
|
35
31
|
import { expandHome } from "#util/paths.ts";
|
|
@@ -76,13 +72,6 @@ function getFrom(container: unknown, key: string): unknown {
|
|
|
76
72
|
return getOr(container, key, undefined);
|
|
77
73
|
}
|
|
78
74
|
|
|
79
|
-
/** Python truthiness for JSON values: empty containers are falsy too. */
|
|
80
|
-
function pythonTruthy(value: unknown): boolean {
|
|
81
|
-
if (Array.isArray(value)) return value.length > 0;
|
|
82
|
-
if (isRecord(value)) return Object.keys(value).length > 0;
|
|
83
|
-
return Boolean(value);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
75
|
/** Python `_parse_pi_content`: a string as-is, text blocks joined by `\n`, else `""`. */
|
|
87
76
|
export function parsePiContent(content: unknown): string {
|
|
88
77
|
if (typeof content === "string") return content;
|
|
@@ -280,108 +269,3 @@ export function backfillPiSessions(
|
|
|
280
269
|
}
|
|
281
270
|
return count;
|
|
282
271
|
}
|
|
283
|
-
|
|
284
|
-
interface CodexParse {
|
|
285
|
-
sessionId: string | null;
|
|
286
|
-
messages: { role: string; content: unknown; createdAt: unknown }[];
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Parse a Codex session file. Throws on the first line that is not valid
|
|
291
|
-
* JSON or not the expected shape; Python's single `try` turns that into
|
|
292
|
-
* "return 0". Values are collected raw: Python validates them only when the
|
|
293
|
-
* import constructs its models.
|
|
294
|
-
*/
|
|
295
|
-
function parseCodexSessionFile(path: string): CodexParse {
|
|
296
|
-
const parsed: CodexParse = { sessionId: null, messages: [] };
|
|
297
|
-
for (const line of readFileSync(path, "utf8").split(/\r?\n|\r/)) {
|
|
298
|
-
if (!line.trim()) continue;
|
|
299
|
-
const entry: unknown = JSON.parse(line);
|
|
300
|
-
const entryType = getFrom(entry, "type");
|
|
301
|
-
const payload = getOr(entry, "payload", {});
|
|
302
|
-
if (entryType === "session_meta") {
|
|
303
|
-
// Codex writes a UUID string; a non-string id is treated as absent
|
|
304
|
-
// (the same simplification the hook payloads make).
|
|
305
|
-
const id = getFrom(payload, "id");
|
|
306
|
-
parsed.sessionId = typeof id === "string" ? id : null;
|
|
307
|
-
} else if (entryType === "event_msg") {
|
|
308
|
-
const payloadType = getFrom(payload, "type");
|
|
309
|
-
if (payloadType === "user_message" || payloadType === "agent_message") {
|
|
310
|
-
const content = getOr(payload, "message", "");
|
|
311
|
-
// Python: `if content:` -- any truthy value is kept and validated later.
|
|
312
|
-
if (pythonTruthy(content)) {
|
|
313
|
-
parsed.messages.push({
|
|
314
|
-
role: payloadType === "user_message" ? "user" : "assistant",
|
|
315
|
-
content,
|
|
316
|
-
createdAt: getFrom(entry, "timestamp"),
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
return parsed;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
export interface CodexBackfillOptions {
|
|
326
|
-
jsonlPath: string;
|
|
327
|
-
interface?: string;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Python `backfill_codex_session`: import one Codex session file as a closed
|
|
332
|
-
* conversation titled through `_generate_title`. Returns 1 on import, else 0.
|
|
333
|
-
*
|
|
334
|
-
* Throws when a kept event has a non-string `message` or `timestamp`: Python's
|
|
335
|
-
* `Message(...)` model rejects those inside the import transaction, so the
|
|
336
|
-
* database is left untouched and the caller sees the failure.
|
|
337
|
-
*/
|
|
338
|
-
export function backfillCodexSession(
|
|
339
|
-
db: WritableDatabase,
|
|
340
|
-
options: CodexBackfillOptions,
|
|
341
|
-
deps: LoggerDeps,
|
|
342
|
-
): number {
|
|
343
|
-
const jsonlPath = expandHome(options.jsonlPath);
|
|
344
|
-
if (!existsSync(jsonlPath) || !statSync(jsonlPath).isFile()) return 0;
|
|
345
|
-
|
|
346
|
-
let parsed: CodexParse;
|
|
347
|
-
try {
|
|
348
|
-
parsed = parseCodexSessionFile(jsonlPath);
|
|
349
|
-
} catch {
|
|
350
|
-
return 0;
|
|
351
|
-
}
|
|
352
|
-
if (!parsed.sessionId || parsed.messages.length === 0) return 0;
|
|
353
|
-
if (db.prepare("SELECT 1 FROM runtime_sessions WHERE session_id = ?").get(parsed.sessionId)) {
|
|
354
|
-
return 0;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const messages: ImportedMessage[] = parsed.messages.map((message) => {
|
|
358
|
-
if (typeof message.content !== "string") {
|
|
359
|
-
throw new TypeError("Codex event message must be a string");
|
|
360
|
-
}
|
|
361
|
-
if (typeof message.createdAt !== "string") {
|
|
362
|
-
throw new TypeError("Codex event timestamp must be a string");
|
|
363
|
-
}
|
|
364
|
-
return { role: message.role, content: message.content, createdAt: message.createdAt };
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
const conversationId = importClosedConversation(
|
|
368
|
-
db,
|
|
369
|
-
parsed.sessionId,
|
|
370
|
-
{
|
|
371
|
-
interface: options.interface ?? "codex",
|
|
372
|
-
messages,
|
|
373
|
-
endedAt: (messages.at(-1) as ImportedMessage).createdAt,
|
|
374
|
-
},
|
|
375
|
-
deps,
|
|
376
|
-
);
|
|
377
|
-
if (conversationId === null) return 0;
|
|
378
|
-
|
|
379
|
-
const firstUser = messages.find((message) => message.role === "user");
|
|
380
|
-
if (firstUser) {
|
|
381
|
-
db.prepare("UPDATE conversations SET title = ? WHERE id = ?").run(
|
|
382
|
-
generateTitle(firstUser.content),
|
|
383
|
-
conversationId,
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
return 1;
|
|
387
|
-
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// A command turn: a prompt that opens with a skill invocation, in any runtime's
|
|
2
|
+
// sigil (CV21.DS4.TS3).
|
|
3
|
+
//
|
|
4
|
+
// The prompt hook skips a command turn and records it, so the Stop hook skips
|
|
5
|
+
// its reply too (`command_turn`, CV21.DS2.US8): a skill's rendering -- a
|
|
6
|
+
// journey table, help text -- is not conversation, and extraction reading it
|
|
7
|
+
// as if the person said it is noise. What you say to the mirror is remembered;
|
|
8
|
+
// a skill you invoke is not. The one exception is the Mirror skill's explicit
|
|
9
|
+
// form, whose question IS the conversation turn (`mirrorForm.ts`); the prompt
|
|
10
|
+
// hook checks that form first, and a test holds every Mirror form to be a
|
|
11
|
+
// command turn, so the exception always sits inside this rule.
|
|
12
|
+
//
|
|
13
|
+
// The two sides are deliberately asymmetric:
|
|
14
|
+
//
|
|
15
|
+
// `/` skips ANYTHING. Every runtime uses `/` for its own built-ins as well as
|
|
16
|
+
// for skills (`/hooks`, `/mcp`, `/compact`), so a prompt opening with it
|
|
17
|
+
// is a command whatever follows. This is the rule US8 inherited.
|
|
18
|
+
// `$` skips only a SKILL NAME. `$` is Codex's skill sigil and nothing else
|
|
19
|
+
// (`$mirror-mind:mm:journeys` through the plugin, DS12.TS2 L10;
|
|
20
|
+
// `$mm-journeys` through a skills link), and `$5 a month` is English.
|
|
21
|
+
// The name is lowercase segments -- letters, digits, hyphens, and
|
|
22
|
+
// underscores after the first letter, since Codex discovers
|
|
23
|
+
// `~/.codex/skills/<dir>` by directory name -- with an optional
|
|
24
|
+
// plugin-name prefix joined by `:`, ending at whitespace or the end of
|
|
25
|
+
// the prompt, the boundary `mirrorForm` draws. A comma is not a boundary
|
|
26
|
+
// and a leading space is not a prompt start, as ` /help` is not a
|
|
27
|
+
// command today.
|
|
28
|
+
//
|
|
29
|
+
// One rule for every runtime: a Claude Code user who types `$mm-journeys`,
|
|
30
|
+
// which Claude Code does not understand, is still invoking a skill, and that
|
|
31
|
+
// turn is not conversation either. The rule has one home for every hook that
|
|
32
|
+
// runs in our process (the Claude-family logger, the Gemini hook); the Pi
|
|
33
|
+
// extension, a separate process in Pi's file, keeps its own `/` check.
|
|
34
|
+
|
|
35
|
+
const SKILL_SEGMENT = "[a-z][a-z0-9_-]*";
|
|
36
|
+
const DOLLAR_SKILL_FORM = new RegExp(`^\\$${SKILL_SEGMENT}(?::${SKILL_SEGMENT})*(?=\\s|$)`);
|
|
37
|
+
|
|
38
|
+
/** Whether the prompt opens with a skill invocation in any runtime's sigil. */
|
|
39
|
+
export function isCommandTurn(prompt: string): boolean {
|
|
40
|
+
if (prompt.startsWith("/")) return true;
|
|
41
|
+
return DOLLAR_SKILL_FORM.test(prompt);
|
|
42
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The deferred close tail (CV21.DS4.TS2).
|
|
2
|
+
//
|
|
3
|
+
// A session's close tail is two things: the state writes (`ended_at`, the
|
|
4
|
+
// runtime session inactive, the transcript backfill) and the LLM work
|
|
5
|
+
// (extraction, then title/summary/tags). Some runtimes give a SessionEnd hook
|
|
6
|
+
// less time than one model call: Codex runs it synchronously under a 1 s
|
|
7
|
+
// default (DS12.TS2 L2; DS2's live `/hooks` table), Grok under 1.5 s
|
|
8
|
+
// (DS12.TS4). Pi and Gemini CLI already close without extraction at exit and
|
|
9
|
+
// let the next session start extract (`session-end-pi`). On a runtime that
|
|
10
|
+
// bounds the hook, the shared Claude-family `session-end` does the same, and a
|
|
11
|
+
// little more: it also skips the metadata finalization, and flags the
|
|
12
|
+
// conversation so the next start's maintenance finishes it.
|
|
13
|
+
//
|
|
14
|
+
// The flag lives in `conversations.metadata` beside `extracted`,
|
|
15
|
+
// `extraction_status`, and `extraction_quarantined`: same table, same
|
|
16
|
+
// `json_extract` predicate, no migration. It is written and cleared through
|
|
17
|
+
// the two helpers here and nowhere else, read-modify-write on the blob, and
|
|
18
|
+
// cleared by deleting the key: the sibling predicates read `IS 1`/`IS NOT 1`,
|
|
19
|
+
// and a `false` left behind is noise every later reader has to think about.
|
|
20
|
+
//
|
|
21
|
+
// The maintenance step runs AFTER `extractPending`, so a conversation's stored
|
|
22
|
+
// summary is kept by `finalizeMetadataOnClose` ("summary already exists")
|
|
23
|
+
// rather than generated twice, and takes only conversations already extracted
|
|
24
|
+
// or ineligible for extraction: one still waiting on the extraction budget is
|
|
25
|
+
// left, flag intact, so finalization never precedes its extraction. It is
|
|
26
|
+
// bounded like its siblings (`RETITLE_LIMIT`, the extraction budget), FIFO by
|
|
27
|
+
// `ended_at`, and carries the rest over. Two runtimes started at once both run
|
|
28
|
+
// maintenance with no lock and can both pick the same conversation; that race
|
|
29
|
+
// is `extractPending`'s and `retitlePending`'s today, and is not solved here.
|
|
30
|
+
|
|
31
|
+
import { metadataDict } from "#conversation/closeTail.ts";
|
|
32
|
+
import type { WritableDatabase } from "#db/database.ts";
|
|
33
|
+
import { pythonJsonDumps } from "#util/pyGenerators.ts";
|
|
34
|
+
|
|
35
|
+
// The policy that decides which runtimes defer the tail lives in
|
|
36
|
+
// `runtimePolicy.ts` (`sessionEndRunsLlmTail`), beside its sibling for sessions
|
|
37
|
+
// that outlive the TUI (US4).
|
|
38
|
+
|
|
39
|
+
export const CLOSE_TAIL_PENDING_KEY = "close_tail_pending";
|
|
40
|
+
|
|
41
|
+
/** The sibling budgets: extraction 10 per run, retitle 5. Finalization is up to three calls. */
|
|
42
|
+
export const DEFERRED_CLOSE_TAIL_LIMIT = 5;
|
|
43
|
+
|
|
44
|
+
function readMetadata(
|
|
45
|
+
db: WritableDatabase,
|
|
46
|
+
conversationId: string,
|
|
47
|
+
): Record<string, unknown> | null {
|
|
48
|
+
const row = db.prepare("SELECT metadata FROM conversations WHERE id = ?").get(conversationId);
|
|
49
|
+
if (!row) return null;
|
|
50
|
+
return metadataDict(typeof row.metadata === "string" ? row.metadata : null);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeMetadata(
|
|
54
|
+
db: WritableDatabase,
|
|
55
|
+
conversationId: string,
|
|
56
|
+
metadata: Record<string, unknown>,
|
|
57
|
+
): void {
|
|
58
|
+
db.prepare("UPDATE conversations SET metadata = ? WHERE id = ?").run(
|
|
59
|
+
pythonJsonDumps(metadata),
|
|
60
|
+
conversationId,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Flag the conversation as owing its finalization. A missing conversation is left alone. */
|
|
65
|
+
export function markCloseTailPending(db: WritableDatabase, conversationId: string): void {
|
|
66
|
+
const metadata = readMetadata(db, conversationId);
|
|
67
|
+
if (metadata === null) return;
|
|
68
|
+
metadata[CLOSE_TAIL_PENDING_KEY] = true;
|
|
69
|
+
writeMetadata(db, conversationId, metadata);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Clear the flag by deleting the key, never by writing `false`. */
|
|
73
|
+
export function clearCloseTailPending(db: WritableDatabase, conversationId: string): void {
|
|
74
|
+
const metadata = readMetadata(db, conversationId);
|
|
75
|
+
if (metadata === null || !(CLOSE_TAIL_PENDING_KEY in metadata)) return;
|
|
76
|
+
delete metadata[CLOSE_TAIL_PENDING_KEY];
|
|
77
|
+
writeMetadata(db, conversationId, metadata);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isCloseTailPending(db: WritableDatabase, conversationId: string): boolean {
|
|
81
|
+
return readMetadata(db, conversationId)?.[CLOSE_TAIL_PENDING_KEY] === true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const PENDING_WHERE = `
|
|
85
|
+
WHERE c.ended_at IS NOT NULL
|
|
86
|
+
AND json_extract(c.metadata, '$.${CLOSE_TAIL_PENDING_KEY}') IS 1
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Already extracted, or not eligible for extraction (the orchestration's own
|
|
91
|
+
* gate: a journey and four messages), so finalization follows extraction and
|
|
92
|
+
* never precedes it.
|
|
93
|
+
*/
|
|
94
|
+
const READY_WHERE = `
|
|
95
|
+
AND (json_extract(c.metadata, '$.extracted') IS 1
|
|
96
|
+
OR c.journey IS NULL
|
|
97
|
+
OR (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) < 4)
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
export interface DeferredCloseTail {
|
|
101
|
+
id: string;
|
|
102
|
+
endedAt: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Flagged conversations ready to finalize, oldest-ended first, bounded. */
|
|
106
|
+
export function selectDeferredCloseTails(
|
|
107
|
+
db: WritableDatabase,
|
|
108
|
+
limit: number | null,
|
|
109
|
+
): DeferredCloseTail[] {
|
|
110
|
+
const base = `SELECT c.id, c.ended_at FROM conversations c${PENDING_WHERE}${READY_WHERE}ORDER BY c.ended_at ASC`;
|
|
111
|
+
const rows = limit === null ? db.prepare(base).all() : db.prepare(`${base} LIMIT ?`).all(limit);
|
|
112
|
+
return rows.map((row) => ({ id: String(row.id), endedAt: String(row.ended_at) }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Every flagged conversation, ready or not, and the oldest: what `runtime diagnose` reads. */
|
|
116
|
+
export function countDeferredCloseTails(db: WritableDatabase): {
|
|
117
|
+
count: number;
|
|
118
|
+
oldestEndedAt: string | null;
|
|
119
|
+
} {
|
|
120
|
+
const row = db
|
|
121
|
+
.prepare(`SELECT COUNT(*) AS n, MIN(c.ended_at) AS oldest FROM conversations c${PENDING_WHERE}`)
|
|
122
|
+
.get();
|
|
123
|
+
const count = Number(row?.n ?? 0);
|
|
124
|
+
return { count, oldestEndedAt: count > 0 && row?.oldest ? String(row.oldest) : null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface FinishDeferredCloseTailsDeps {
|
|
128
|
+
/** The close-time finalization (title, summary, tags). Throwing is isolated. */
|
|
129
|
+
finalizeCloseTail: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
|
|
130
|
+
limit?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The maintenance step: finalize each ready, flagged conversation and clear
|
|
135
|
+
* its flag. A finalization that throws leaves its flag for the next run and
|
|
136
|
+
* does not stop the loop. Returns how many were finished.
|
|
137
|
+
*/
|
|
138
|
+
export async function finishDeferredCloseTails(
|
|
139
|
+
db: WritableDatabase,
|
|
140
|
+
deps: FinishDeferredCloseTailsDeps,
|
|
141
|
+
): Promise<number> {
|
|
142
|
+
const limit = deps.limit ?? DEFERRED_CLOSE_TAIL_LIMIT;
|
|
143
|
+
let count = 0;
|
|
144
|
+
for (const tail of selectDeferredCloseTails(db, limit)) {
|
|
145
|
+
try {
|
|
146
|
+
await deps.finalizeCloseTail(db, tail.id);
|
|
147
|
+
} catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
clearCloseTailPending(db, tail.id);
|
|
151
|
+
count += 1;
|
|
152
|
+
}
|
|
153
|
+
return count;
|
|
154
|
+
}
|