@jeffober/thatch 0.1.41 → 0.1.43
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/README.md +28 -11
- package/artifacts/skills/thatch-clear-writing.md +77 -0
- package/artifacts/skills/thatch-coding-workflow.md +1 -0
- package/bin/release +4 -1
- package/bin/thatch +268 -55
- package/docs/dev/README.md +7 -4
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/cli.md +31 -11
- package/docs/dev/features/cross-session-chat.md +141 -67
- package/docs/dev/features/multi-host.md +4 -3
- package/docs/dev/features/session-lifecycle.md +24 -1
- package/docs/dev/features/watchers.md +113 -23
- package/docs/dev/gotchas.md +9 -0
- package/docs/dev/mcp-parity.md +2 -1
- package/docs/dev/skills.md +1 -0
- package/docs/user/README.md +19 -3
- package/docs/user/cli.md +68 -14
- package/docs/user/cross-session-chat.md +80 -27
- package/docs/user/skills.md +1 -0
- package/docs/user/watchers.md +61 -16
- package/package.json +2 -1
- package/src/chat-names.ts +6 -7
- package/src/chat.ts +553 -152
- package/src/commands.ts +97 -0
- package/src/config.ts +34 -1
- package/src/db.ts +67 -7
- package/src/git.ts +22 -0
- package/src/index.ts +198 -25
- package/src/prompts.ts +142 -50
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +301 -98
- package/src/tools.ts +2 -0
- package/src/watchers.ts +358 -23
package/README.md
CHANGED
|
@@ -89,16 +89,30 @@ Thatch gives your agent:
|
|
|
89
89
|
`session_get`, opencode only) and as CLI subcommands (`thatch session
|
|
90
90
|
list/get/transcript/search`, JSONL output designed for piping to `jq`).
|
|
91
91
|
- **Watchers** -- event-driven notifications from external sources (opencode
|
|
92
|
-
only). The agent registers a watch on a GitHub PR
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
only). The agent registers a watch on a GitHub PR, a branch (main), or a
|
|
93
|
+
local shell command; thatch polls it in the
|
|
94
|
+
background and prompts the session when comments, commits, CI results, status changes, or a
|
|
95
|
+
watched command's exit-0 condition arrive. Notifications carry pointer
|
|
96
|
+
data plus machine status (check conclusions, exit codes), never external
|
|
97
|
+
content.
|
|
95
98
|
- **Cross-session chat** -- your agent sessions can message each other
|
|
96
|
-
(opencode, Claude Code, and Cursor; same machine).
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
(opencode, Claude Code, and Cursor; same machine). opencode sessions
|
|
100
|
+
join the directory automatically under an assigned, never-reused name
|
|
101
|
+
(a slug of the session title plus a counter); others see the roster, and
|
|
102
|
+
messages land in inboxes -- opencode sessions get woken with a prompt
|
|
99
103
|
when idle, other hosts see pending mail at their next prompt. Loop-safe:
|
|
100
104
|
messages are informational to the receiving agent, and wake prompts are
|
|
101
|
-
rate-capped.
|
|
105
|
+
rate-capped. Delivered messages are framed as untrusted content so a
|
|
106
|
+
hostile message cannot impersonate your instructions. Disable the whole
|
|
107
|
+
feature with `chat.enabled: false` in the thatch config (or just the
|
|
108
|
+
auto-joining with `chat.autoRegister: false`).
|
|
109
|
+
- **Wrap-up commands** (opencode only) -- `/thatch/compact` and `/thatch/exit`
|
|
110
|
+
run a pre-flight checklist before a compaction or an exit: the agent flushes
|
|
111
|
+
pending fact extraction, finishes promised memory writes, and surfaces
|
|
112
|
+
todos or follow-ups it never addressed. It ends its response with a
|
|
113
|
+
greenlight token only when the checklist is clean; thatch then triggers the
|
|
114
|
+
compaction or quits opencode. With items outstanding, the agent lists them
|
|
115
|
+
and nothing fires -- you decide when to retry.
|
|
102
116
|
- **Notifications + user config** -- the agent can ping you out-of-band when a
|
|
103
117
|
long-running outcome lands: a desktop banner, a spoken voice
|
|
104
118
|
announcement, or both (macOS and Linux). Preferences live in a
|
|
@@ -120,10 +134,13 @@ host.
|
|
|
120
134
|
Everything is local. The embedding model downloads once from Hugging Face Hub
|
|
121
135
|
and is cached. No data leaves your machine.
|
|
122
136
|
|
|
123
|
-
One exception: watchers (opencode only) call the GitHub API
|
|
124
|
-
CLI to poll
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
One exception: PR and branch watchers (opencode only) call the GitHub API
|
|
138
|
+
through the `gh` CLI to poll targets you explicitly asked to watch, using
|
|
139
|
+
your existing gh authentication; command watchers run local shell commands
|
|
140
|
+
in the project directory, reading only their exit codes. Thatch never sees
|
|
141
|
+
or stores the gh token, and notifications
|
|
142
|
+
carry pointer data plus machine status (check conclusions, exit codes),
|
|
143
|
+
never external content - GitHub comment text never enters your context
|
|
127
144
|
unless the agent fetches it.
|
|
128
145
|
|
|
129
146
|
## Development
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: thatch-clear-writing
|
|
3
|
+
description: Prose rules for every piece of text a human reads that no dedicated skill already covers - PR and ticket comments posted on the user's behalf, documentation, feature plans, reports, write-ups, status updates, and chat replies to the user. Load it before drafting any of those. When a dedicated writing skill is loaded (thatch-pr-description, thatch-ticket-description, thatch-review-response), that skill's rules win.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Clear writing
|
|
7
|
+
|
|
8
|
+
## Core principle
|
|
9
|
+
|
|
10
|
+
Write for humans. Not for LLMs. Not to impress. Not to pad.
|
|
11
|
+
|
|
12
|
+
Every sentence earns its place or gets cut. Plain English over jargon. Concrete over abstract. If a phrase sounds smart but you cannot say it in simpler words, it is a buzzword. Cut or replace it.
|
|
13
|
+
|
|
14
|
+
Target a 7th-grade reading level. Short sentences, common words, one idea each. The concepts stay technical; the language stays simple.
|
|
15
|
+
|
|
16
|
+
## Where these rules apply
|
|
17
|
+
|
|
18
|
+
Every text a human reads:
|
|
19
|
+
|
|
20
|
+
- **Comments you post for the user** - PR comments, ticket comments (Linear, Jira), replies to reviewers, replies to bot findings.
|
|
21
|
+
- **Written artifacts** - documentation, feature plans, reports, write-ups, design notes, status updates.
|
|
22
|
+
- **Replies to the user in chat** - explanations, summaries, answers.
|
|
23
|
+
|
|
24
|
+
Same prose rules everywhere. Only the structure differs:
|
|
25
|
+
|
|
26
|
+
- **Artifacts** may use sections, headings, and bullets as the content needs.
|
|
27
|
+
- **Chat replies** use no scaffold. Lead with the answer, then the context, then the caveats. Short, but never compressed into shorthand to make it short.
|
|
28
|
+
|
|
29
|
+
A dedicated writing skill overrides this one when loaded: `thatch-pr-description` for PR bodies, `thatch-ticket-description` for ticket descriptions, `thatch-review-response` for replies on the user's own PR, the review skills for review reports. Use those where they fit. This skill covers everything they do not.
|
|
30
|
+
|
|
31
|
+
## The reader
|
|
32
|
+
|
|
33
|
+
A busy engineer with zero context on this task. They know the stack and general engineering vocabulary. They have never touched this area. They read once, top to bottom, and every sentence must parse on the first read.
|
|
34
|
+
|
|
35
|
+
## Ordering
|
|
36
|
+
|
|
37
|
+
Lead with the answer. Then the evidence. Then the caveats.
|
|
38
|
+
|
|
39
|
+
The reader decides when to stop reading. Give them the point first so stopping early is safe. Never build to a reveal.
|
|
40
|
+
|
|
41
|
+
- First sentence or two: the conclusion, the answer, or the request.
|
|
42
|
+
- Then: why. The mechanism, the evidence, the reasoning.
|
|
43
|
+
- Last: caveats, open questions, alternatives considered.
|
|
44
|
+
|
|
45
|
+
## Prose rules
|
|
46
|
+
|
|
47
|
+
Hard rules. Not suggestions.
|
|
48
|
+
|
|
49
|
+
1. **One idea per sentence.** Split clause-chains. One topic per paragraph.
|
|
50
|
+
2. **Literal phrasing beats compressed idiom.** Write for an unfamiliar reader. Do not use method names as verbs. Prefer plain English nouns over code-domain nouns: "reading the row from the database" not "the row read"; "a request waiting for a worker" not "a waiter". If a word sounds like it belongs in a source file rather than a sentence, replace it.
|
|
51
|
+
3. **Define subsystem-specific terms on first use.** General engineering vocabulary needs no definition. "the DEK (the symmetric key that encrypts secrets at rest)" - yes. After the first definition, use the term freely.
|
|
52
|
+
4. **Translate project-private labels.** Roadmap names, subsystem nicknames, ticket shorthand, and branch-local labels are not explanations. Say what the code does first: what reads, writes, blocks, retries, or changes. The label can follow if it helps the reader connect prose to code.
|
|
53
|
+
5. **Prefer concrete over ambiguous.** If a word can mean more than one thing here, qualify it or replace it with the specific behavior. Ordinary words can be ambiguous when the code gives them a special role: claim, owner, active, current, publish, resolve, sync, scope. "Persist the value in the database so the UI can display it" beats "publish the value".
|
|
54
|
+
6. **Explain operations before naming them.** Refresh, repair, coalesce, reconcile, normalize, hydrate, fan out - these are not explanations. Name the object, the action, and the effect: "The worker waits up to 5 seconds, then reloads each stale cache key once."
|
|
55
|
+
7. **Name both sides of a contrast.** If timing, ownership, or behavior changed, state the old behavior and the new close together.
|
|
56
|
+
8. **Show causal links.** If a sentence uses "so", "because", or "prevents", make the middle step visible. Cause and effect that do not obviously touch need the missing step spelled out.
|
|
57
|
+
9. **Clarity wins over compression.** Brevity means fewer claims, not denser claims. Shorten by deleting whole claims. Never rewrite surviving sentences into tighter ones. Keep the connective tissue that makes a sentence parse on first read.
|
|
58
|
+
10. **No invented shorthand.** Do not coin abbreviations, portmanteaus, or labels mid-text. Use the real name, or spell out the behavior. A term the reader cannot find anywhere else is a term they cannot look up.
|
|
59
|
+
11. **No buzzwordy abstractions.** "leverage" -> "use". "utilize" -> "use". If you cannot say what a phrase means in plain English, it does not belong.
|
|
60
|
+
12. **Plain ASCII.** No smart quotes, em dashes, or ellipsis glyphs.
|
|
61
|
+
13. **No process narration.** Never describe the order you assembled things in or narrate your own actions inside the text. State the thing itself.
|
|
62
|
+
|
|
63
|
+
## Length
|
|
64
|
+
|
|
65
|
+
Match length to complexity: how much new mental model the reader must build.
|
|
66
|
+
|
|
67
|
+
- A one-line answer gets one line.
|
|
68
|
+
- A real explanation gets as many sentences as the idea needs, and not one more.
|
|
69
|
+
- When over length, cut the lowest-value claim. Do not compress the survivors.
|
|
70
|
+
|
|
71
|
+
For chat replies: terse is fine, illegible is not. If a two-line answer only fits by inventing shorthand, spend five lines.
|
|
72
|
+
|
|
73
|
+
## Clarity pass
|
|
74
|
+
|
|
75
|
+
Before you post, save, or send: reread the draft as a reader with zero context. Fix what they would stumble on. Do not announce the pass; just return clean prose.
|
|
76
|
+
|
|
77
|
+
Check: Does every sentence parse on first read? Does every term carry its plain-English meaning before or with the label? Would a reader who has never seen this codebase, ticket, or conversation understand it? If the draft runs long, cut a claim; never tighten the grammar.
|
|
@@ -48,6 +48,7 @@ After all changes are complete, verify in this order:
|
|
|
48
48
|
- Simple fixes: fix immediately.
|
|
49
49
|
- Complex fixes: plan as a separate milestone.
|
|
50
50
|
4. **Run tests, linters, and formatters.** Address any issues they surface. If the project has a quality gate (make check, npm run check, mise run check), run it.
|
|
51
|
+
5. **Repo finish gate.** If the current repo defines its own definition-of-done skill (in the thatch repo: `thatch-dev-done`), load it and work its checklist before claiming the task complete. The generic verification above does not cover repo-specific duties: QA use cases, doc tiers, count touch points, commit conventions.
|
|
51
52
|
|
|
52
53
|
## Blockers vs intermediate states
|
|
53
54
|
|
package/bin/release
CHANGED
|
@@ -90,7 +90,10 @@ echo
|
|
|
90
90
|
|
|
91
91
|
echo "▸ pushing..."
|
|
92
92
|
git push
|
|
93
|
-
git push --tags
|
|
93
|
+
# Push only this release's tag. `git push --tags` would also push every
|
|
94
|
+
# other local tag, and a history rewrite (filter-repo) recreates old tags
|
|
95
|
+
# on new SHAs - which the remote correctly refuses to move.
|
|
96
|
+
git push origin "v$next"
|
|
94
97
|
echo " ✓ pushed"
|
|
95
98
|
echo
|
|
96
99
|
|
package/bin/thatch
CHANGED
|
@@ -10,7 +10,8 @@ import { hygieneReport } from "../src/hygiene";
|
|
|
10
10
|
import { claudeSessionStartReminder } from "../src/prompts";
|
|
11
11
|
import { setupClaudeCode, setupCursor } from "../src/setup";
|
|
12
12
|
import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToTranscriptEntries, partToFullJson, messageToFullJson, type SessionPart } from "../src/session-db";
|
|
13
|
-
import { chatTailDiff,
|
|
13
|
+
import { chatTailDiff, chatTailBacklog, filterChatTailRows, formatChatTailCard, CHAT_TAIL_SEPARATOR, CHAT_TAIL_DEFAULT_LIMIT, parseChatTimeBound, isStale, CHAT_STALE_MINUTES, humanAge, mcpSessionID, selectChatBodyRenderer, cleanRenderedBody, type ChatSessionRow, type ChatTailFilter } from "../src/chat";
|
|
14
|
+
import { chatEnabled, loadConfig } from "../src/config";
|
|
14
15
|
|
|
15
16
|
const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
16
17
|
const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
|
|
@@ -46,8 +47,17 @@ function usage(): never {
|
|
|
46
47
|
thatch session search <query> [--session <id>] [--regex] [--limit N]
|
|
47
48
|
JSONL of matching parts across sessions (decoded content)
|
|
48
49
|
thatch chat list Registered chat sessions: name, age, project, topic
|
|
49
|
-
thatch chat tail [--once]
|
|
50
|
-
|
|
50
|
+
thatch chat tail [--once] [--limit N|all] [--match RE]
|
|
51
|
+
[--from NAME] [--to NAME] [--since DT] [--until DT]
|
|
52
|
+
Follow cross-session chat: sent and read
|
|
53
|
+
events. The backlog shows the last
|
|
54
|
+
${CHAT_TAIL_DEFAULT_LIMIT} messages
|
|
55
|
+
(--limit N or --limit all to override).
|
|
56
|
+
--match filters message bodies
|
|
57
|
+
(repeatable, ANDed); --from/--to filter
|
|
58
|
+
participant names; --since/--until bound
|
|
59
|
+
the window ("YYYY-MM-DD [HH:MM]" local
|
|
60
|
+
time). Filters apply to follow mode too.
|
|
51
61
|
|
|
52
62
|
Stores default to the repo detected from git remote. Use "global" for the
|
|
53
63
|
global store. Use "all" with search to search project + global together.`);
|
|
@@ -63,20 +73,56 @@ async function defaultStore(): Promise<string> {
|
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
/**
|
|
66
|
-
* Builds the chat-mail hook line for the given project
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
76
|
+
* Builds the chat-mail hook line for the given project. With a hook
|
|
77
|
+
* session ID (Claude Code/Cursor pass one in the hook's stdin JSON), it
|
|
78
|
+
* first ensures the caller is registered - the host's assigned identity -
|
|
79
|
+
* and prints that identity plus the caller's own unread count, so the
|
|
80
|
+
* model learns who it is on every prompt without a tool call. Without a
|
|
81
|
+
* session ID it keeps the old shape: a line only when pending mail exists
|
|
82
|
+
* for registered sessions in the project, naming the addressees.
|
|
72
83
|
*/
|
|
73
|
-
async function chatHookLine(): Promise<string> {
|
|
84
|
+
async function chatHookLine(hookSessionID?: string): Promise<string> {
|
|
85
|
+
// Disabled chat prints nothing: the hook line must not advertise a
|
|
86
|
+
// feature the user turned off (the tools refuse, the poller never
|
|
87
|
+
// starts, the prompt section omits chat).
|
|
88
|
+
if (!chatEnabled(loadConfig(dbPath).config)) return "";
|
|
74
89
|
let repo: string | null = null;
|
|
75
90
|
try {
|
|
76
91
|
repo = await detectRepo();
|
|
77
92
|
} catch {
|
|
78
93
|
return "";
|
|
79
94
|
}
|
|
95
|
+
|
|
96
|
+
if (hookSessionID) {
|
|
97
|
+
// Ensure-register: idempotent, so every prompt re-touches liveness and
|
|
98
|
+
// the first prompt of a conversation mints the assigned name. This is
|
|
99
|
+
// the MCP identity anchor - the session ID is stable per conversation,
|
|
100
|
+
// so re-registering finds the same row and name.
|
|
101
|
+
const sessionID = mcpSessionID(hookSessionID);
|
|
102
|
+
// The leave tombstone blocks this ensure: chat_unregister must outlive
|
|
103
|
+
// the turn that issued it, on MCP hosts as on opencode. The line is
|
|
104
|
+
// the standing signal - it repeats every prompt until the model
|
|
105
|
+
// rejoins, and it says plainly that rejoining mints a fresh identity,
|
|
106
|
+
// because the old one is closed for good (assigned names are never
|
|
107
|
+
// reused, so a tombstoned identity cannot be resurrected by a later
|
|
108
|
+
// register).
|
|
109
|
+
if (db.hasChatLeaveTombstone(sessionID) && !db.chatLeaveSuperseded(sessionID, repo)) {
|
|
110
|
+
return `[thatch] chat: this conversation left the chat directory (chat_unregister) and will not receive mail. If the user asks to rejoin, call thatch_chat_register with no arguments - that mints a fresh identity; the old one stays closed.`;
|
|
111
|
+
}
|
|
112
|
+
db.registerChatSession(sessionID, repo, null, "mcp");
|
|
113
|
+
const me = db.findChatSession(sessionID);
|
|
114
|
+
if (!me) return "";
|
|
115
|
+
const mine = db.unreadChatCount(sessionID);
|
|
116
|
+
const pendingOthers = db
|
|
117
|
+
.chatPendingByProject(repo)
|
|
118
|
+
.filter((p) => p.name !== me.name);
|
|
119
|
+
const others = pendingOthers.length > 0
|
|
120
|
+
? ` Others in this project with unread mail: ${pendingOthers.map((p) => `${p.name} (${p.pending})`).join(", ")}.`
|
|
121
|
+
: "";
|
|
122
|
+
const mailbox = mine > 0 ? `${mine} unread message(s) for you` : "no unread mail for you";
|
|
123
|
+
return `[thatch] chat: you are ${me.name} (${mailbox}).${others}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
80
126
|
const pending = db.chatPendingByProject(repo);
|
|
81
127
|
if (pending.length === 0) return "";
|
|
82
128
|
const parts = pending.map((p) => `${p.name} (${p.pending})`);
|
|
@@ -88,8 +134,8 @@ async function chatHookLine(): Promise<string> {
|
|
|
88
134
|
* warning goes first so the LLM sees it before the nudge text. Used by
|
|
89
135
|
* flush-tools to surface version skew and update-available notices on
|
|
90
136
|
* every UserPromptSubmit, regardless of which tier fired. The chat hook
|
|
91
|
-
* line, when present, is appended last (the mail check is the
|
|
92
|
-
* the model reads before the nudge body).
|
|
137
|
+
* line, when present, is appended last (the identity/mail check is the
|
|
138
|
+
* last thing the model reads before the nudge body).
|
|
93
139
|
*/
|
|
94
140
|
function emit(output: string, versionWarning: string | null, isJson: boolean, chatLine = ""): void {
|
|
95
141
|
if (chatLine) {
|
|
@@ -123,17 +169,42 @@ function parseTimeBound(value: string, flag: string): number {
|
|
|
123
169
|
return parsed;
|
|
124
170
|
}
|
|
125
171
|
|
|
126
|
-
/** Reads a flag's value (-flag value or --flag=value forms).
|
|
172
|
+
/** Reads a flag's value (-flag value or --flag=value forms). A flag given
|
|
173
|
+
* with no value is a typo, not a default - it dies loudly so `--until`
|
|
174
|
+
* at the end of a command line cannot silently disable the bound. */
|
|
127
175
|
function flagValue(flags: string[], ...names: string[]): string | undefined {
|
|
128
176
|
for (let i = 0; i < flags.length; i++) {
|
|
129
177
|
for (const name of names) {
|
|
130
|
-
if (flags[i] === name)
|
|
178
|
+
if (flags[i] === name) {
|
|
179
|
+
if (flags[i + 1] === undefined) {
|
|
180
|
+
console.error(`error: ${name} expects a value`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
return flags[i + 1];
|
|
184
|
+
}
|
|
131
185
|
if (flags[i]?.startsWith(`${name}=`)) return flags[i]!.slice(name.length + 1);
|
|
132
186
|
}
|
|
133
187
|
}
|
|
134
188
|
return undefined;
|
|
135
189
|
}
|
|
136
190
|
|
|
191
|
+
/** Collects every occurrence of a repeatable flag, dying on a dangling one. */
|
|
192
|
+
function flagValues(flags: string[], ...names: string[]): string[] {
|
|
193
|
+
const out: string[] = [];
|
|
194
|
+
for (let i = 0; i < flags.length; i++) {
|
|
195
|
+
for (const name of names) {
|
|
196
|
+
if (flags[i] === name) {
|
|
197
|
+
if (flags[i + 1] === undefined) {
|
|
198
|
+
console.error(`error: ${name} expects a value`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
out.push(flags[i + 1]);
|
|
202
|
+
} else if (flags[i]?.startsWith(`${name}=`)) out.push(flags[i]!.slice(name.length + 1));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
137
208
|
function openSessionDb(): SessionDB {
|
|
138
209
|
const path = resolveOpencodeDbPath();
|
|
139
210
|
if (!path) {
|
|
@@ -253,15 +324,84 @@ async function sessionCommand(rest: string[]): Promise<void> {
|
|
|
253
324
|
// chat subcommand group - read-only window on the cross-session chat
|
|
254
325
|
// ---------------------------------------------------------------------------
|
|
255
326
|
|
|
256
|
-
/**
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
327
|
+
/** Column headers for the `chat list` roster, in display order. */
|
|
328
|
+
const CHAT_ROSTER_HEADER = ["NAME", "AGE", "STATUS", "PROJECT", "TOPIC"];
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Renders the `chat list` roster as aligned columns under a header row.
|
|
332
|
+
* Columns size to the widest cell so names, ages, and projects line up
|
|
333
|
+
* across sessions. Color is applied only when stdout is an interactive
|
|
334
|
+
* terminal (and NO_COLOR is unset), so piped output - grep, redirect,
|
|
335
|
+
* QA runners - stays clean plain text. Palette matches the chat tail
|
|
336
|
+
* cards: green values, dim age, red STALE, muted italic topic.
|
|
337
|
+
*/
|
|
338
|
+
function formatChatRoster(sessions: ChatSessionRow[]): string {
|
|
339
|
+
const colored = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
340
|
+
const paint = (code: string, s: string) => (colored ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
341
|
+
const rows = sessions.map((s) => ({
|
|
342
|
+
name: s.name,
|
|
343
|
+
age: humanAge(s.last_seen),
|
|
344
|
+
stale: isStale(s, CHAT_STALE_MINUTES),
|
|
345
|
+
project: s.project ?? "unknown",
|
|
346
|
+
topic: s.topic ?? "",
|
|
347
|
+
}));
|
|
348
|
+
const cells = (r: (typeof rows)[number]) => [r.name, r.age, r.stale ? "STALE" : "fresh", r.project, r.topic];
|
|
349
|
+
// Widths come from the PLAIN cell text; color is applied after padding so
|
|
350
|
+
// zero-width escape codes never skew the alignment.
|
|
351
|
+
const widths = CHAT_ROSTER_HEADER.map((h, i) => Math.max(h.length, ...rows.map((r) => cells(r)[i].length)));
|
|
352
|
+
const align = (line: string[]) => line.map((c, i) => (i < line.length - 1 ? c.padEnd(widths[i]) : c));
|
|
353
|
+
const header = align(CHAT_ROSTER_HEADER).map((h) => paint("1", h)).join(" ");
|
|
354
|
+
const body = rows.map((r) => {
|
|
355
|
+
const [name, age, status, project, topic] = align(cells(r));
|
|
356
|
+
return [
|
|
357
|
+
paint("32", name),
|
|
358
|
+
paint("2", age),
|
|
359
|
+
paint(r.stale ? "31" : "32", status),
|
|
360
|
+
project,
|
|
361
|
+
topic ? paint("3;90", topic) : "",
|
|
362
|
+
].join(" ").trimEnd();
|
|
363
|
+
});
|
|
364
|
+
return [header, ...body].join("\n");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Markdown-to-ANSI renderer for chat tail bodies: prefers glow, then
|
|
369
|
+
* `gum format`, and degrades to the plain body when neither is installed.
|
|
370
|
+
* Detection runs once and only for interactive terminals - pipes (grep,
|
|
371
|
+
* QA runners) always get the raw body, and NO_COLOR opts out entirely.
|
|
372
|
+
* The renderer never fails open: a crashed or missing binary returns the
|
|
373
|
+
* body unchanged.
|
|
374
|
+
*/
|
|
375
|
+
let renderChatBody: (body: string) => string | null | undefined;
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Markdown-to-ANSI renderer for chat tail bodies: renderer selection
|
|
379
|
+
* (glow, then gum format) and body cleanup live in src/chat.ts
|
|
380
|
+
* (selectChatBodyRenderer, cleanRenderedBody, unit-tested there); this
|
|
381
|
+
* wrapper owns the TTY gate, the once-per-process cache, and the spawn.
|
|
382
|
+
* Detection runs only for interactive terminals - pipes (grep, QA
|
|
383
|
+
* runners) always get the raw body, and NO_COLOR opts out entirely.
|
|
384
|
+
* The renderer never fails open: a crashed or missing binary returns the
|
|
385
|
+
* body unchanged.
|
|
386
|
+
*/
|
|
387
|
+
function markdownBodyRenderer(): (body: string) => string {
|
|
388
|
+
if (renderChatBody !== undefined) return renderChatBody;
|
|
389
|
+
if (process.stdout.isTTY !== true || process.env.NO_COLOR) {
|
|
390
|
+
renderChatBody = (body) => body;
|
|
391
|
+
return renderChatBody;
|
|
392
|
+
}
|
|
393
|
+
const argv = selectChatBodyRenderer(Bun.which("glow"), Bun.which("gum"));
|
|
394
|
+
if (!argv) {
|
|
395
|
+
renderChatBody = (body) => body;
|
|
396
|
+
return renderChatBody;
|
|
397
|
+
}
|
|
398
|
+
// spawnSync stdin needs a Response (the `input` option is a no-op in
|
|
399
|
+
// Bun 1.3.14).
|
|
400
|
+
renderChatBody = (body) => {
|
|
401
|
+
const run = Bun.spawnSync(argv, { stdin: new Response(body), stdout: "pipe", stderr: "pipe" });
|
|
402
|
+
return run.exitCode === 0 ? cleanRenderedBody(run.stdout.toString()) : body;
|
|
403
|
+
};
|
|
404
|
+
return renderChatBody;
|
|
265
405
|
}
|
|
266
406
|
|
|
267
407
|
async function chatCommand(rest: string[]): Promise<void> {
|
|
@@ -271,48 +411,114 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
271
411
|
case "list": {
|
|
272
412
|
const sessions = db.listChatSessions();
|
|
273
413
|
if (sessions.length === 0) {
|
|
274
|
-
console.log("No sessions registered. Sessions join
|
|
414
|
+
console.log("No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register.");
|
|
275
415
|
break;
|
|
276
416
|
}
|
|
277
|
-
|
|
278
|
-
// Same staleness convention as the chat_list tool: an age past the
|
|
279
|
-
// threshold means the session's host process is probably gone.
|
|
280
|
-
const liveness = isStale(s, CHAT_STALE_MINUTES) ? "STALE" : "fresh";
|
|
281
|
-
const parts = [
|
|
282
|
-
s.name,
|
|
283
|
-
`(${humanAge(s.last_seen)})`,
|
|
284
|
-
liveness,
|
|
285
|
-
`project:${s.project ?? "unknown"}`,
|
|
286
|
-
];
|
|
287
|
-
if (s.topic) parts.push(s.topic);
|
|
288
|
-
console.log(parts.join(" "));
|
|
289
|
-
}
|
|
417
|
+
console.log(formatChatRoster(sessions));
|
|
290
418
|
break;
|
|
291
419
|
}
|
|
292
420
|
|
|
293
421
|
case "tail": {
|
|
294
422
|
const onceOnly = restArgs.includes("--once");
|
|
423
|
+
// Parse and validate everything up front: a bad regex or date must
|
|
424
|
+
// fail before any cards print, not mid-follow.
|
|
425
|
+
const matches = flagValues(restArgs, "--match").map((pattern) => {
|
|
426
|
+
try {
|
|
427
|
+
return new RegExp(pattern);
|
|
428
|
+
} catch {
|
|
429
|
+
console.error(`error: --match is not a valid regular expression: "${pattern}"`);
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
const limitArg = flagValue(restArgs, "--limit");
|
|
434
|
+
let limit: number | null = CHAT_TAIL_DEFAULT_LIMIT;
|
|
435
|
+
if (limitArg !== undefined) {
|
|
436
|
+
if (limitArg === "all") limit = null;
|
|
437
|
+
else if (!/^\d+$/.test(limitArg)) {
|
|
438
|
+
console.error(`error: --limit expects a positive integer or "all", got "${limitArg}"`);
|
|
439
|
+
process.exit(1);
|
|
440
|
+
} else limit = Number(limitArg);
|
|
441
|
+
if (limit !== null && limit < 1) {
|
|
442
|
+
console.error(`error: --limit expects a positive integer or "all", got "${limitArg}"`);
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
let sinceMs: number | null = null;
|
|
447
|
+
let untilMs: number | null = null;
|
|
448
|
+
try {
|
|
449
|
+
const since = flagValue(restArgs, "--since");
|
|
450
|
+
const until = flagValue(restArgs, "--until");
|
|
451
|
+
if (since !== undefined) sinceMs = parseChatTimeBound(since, "--since");
|
|
452
|
+
if (until !== undefined) untilMs = parseChatTimeBound(until, "--until");
|
|
453
|
+
} catch (err) {
|
|
454
|
+
console.error(`error: ${(err as Error).message}`);
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
if (sinceMs !== null && untilMs !== null && sinceMs >= untilMs) {
|
|
458
|
+
console.error("error: --since is not before --until; the window is empty");
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
const fromName = flagValue(restArgs, "--from");
|
|
462
|
+
const toName = flagValue(restArgs, "--to");
|
|
463
|
+
const filter: ChatTailFilter = {
|
|
464
|
+
matches,
|
|
465
|
+
fromSubstr: fromName !== undefined ? [fromName] : [],
|
|
466
|
+
toSubstr: toName !== undefined ? [toName] : [],
|
|
467
|
+
sinceMs,
|
|
468
|
+
untilMs,
|
|
469
|
+
};
|
|
470
|
+
|
|
295
471
|
// Diff state: one entry per seen message id holding its read_at, so
|
|
296
472
|
// each poll emits only new messages and newly-read rows. The diff
|
|
297
|
-
// logic lives in src/chat.ts (chatTailDiff
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
473
|
+
// and backlog logic lives in src/chat.ts (chatTailDiff,
|
|
474
|
+
// chatTailBacklog), unit-tested there. The backlog seeds the state
|
|
475
|
+
// from every feed row, so the limit hides cards, not history.
|
|
476
|
+
let state: Map<number, string | null>;
|
|
477
|
+
let firstFollowEvent = true;
|
|
478
|
+
|
|
479
|
+
// Follow mode holds the handle open until Ctrl-C. The finally below
|
|
480
|
+
// closes it on every exit path except the SIGINT handler's, which
|
|
481
|
+
// is why the handler closes it itself: process.exit does not unwind
|
|
482
|
+
// finally blocks (closing twice is harmless).
|
|
302
483
|
try {
|
|
303
|
-
// First pass:
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
484
|
+
// First pass: render the backlog as cards, then follow unless
|
|
485
|
+
// --once or the --until window has already closed (a closed
|
|
486
|
+
// window has no future events to follow). The backlog fetch gets
|
|
487
|
+
// the same friendly failure treatment as follow polls: a locked
|
|
488
|
+
// or missing database should say so, not die with a stack trace.
|
|
489
|
+
let backlog;
|
|
490
|
+
try {
|
|
491
|
+
backlog = chatTailBacklog(db.chatMessageFeed(), filter, limit);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
console.error(`[thatch chat tail] feed query failed: ${err}`);
|
|
494
|
+
console.error("The database may be locked or missing; exiting.");
|
|
495
|
+
return;
|
|
307
496
|
}
|
|
308
|
-
|
|
497
|
+
state = backlog.state;
|
|
498
|
+
if (backlog.elided > 0) {
|
|
499
|
+
// The elision note goes to stderr so stdout stays a clean card
|
|
500
|
+
// stream for pipes.
|
|
501
|
+
console.error(
|
|
502
|
+
`[thatch chat tail] showing last ${backlog.events.length} of ${backlog.elided + backlog.events.length} messages (--limit N, --limit all)`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
console.log(backlog.events.map((e) => formatChatTailCard(e, markdownBodyRenderer())).join(`\n\n${CHAT_TAIL_SEPARATOR}\n\n`));
|
|
506
|
+
if (onceOnly || (filter.untilMs !== null && filter.untilMs <= Date.now())) break;
|
|
309
507
|
|
|
310
|
-
process.on("SIGINT", () =>
|
|
508
|
+
process.on("SIGINT", () => {
|
|
509
|
+
db.close();
|
|
510
|
+
process.exit(0);
|
|
511
|
+
});
|
|
311
512
|
for (;;) {
|
|
312
513
|
await Bun.sleep(2000);
|
|
313
514
|
try {
|
|
314
|
-
|
|
315
|
-
|
|
515
|
+
// Follow polls re-filter the live feed: matching messages
|
|
516
|
+
// surface as they arrive, non-matching ones stay invisible.
|
|
517
|
+
for (const event of chatTailDiff(state, filterChatTailRows(db.chatMessageFeed(), filter)).events) {
|
|
518
|
+
// Separate follow-mode cards from whatever came before.
|
|
519
|
+
if (firstFollowEvent && backlog.events.length > 0) console.log(`\n${CHAT_TAIL_SEPARATOR}\n`);
|
|
520
|
+
firstFollowEvent = false;
|
|
521
|
+
console.log(formatChatTailCard(event, markdownBodyRenderer()));
|
|
316
522
|
}
|
|
317
523
|
} catch (err) {
|
|
318
524
|
// The surface's whole job is watching a shared, mutable file;
|
|
@@ -321,6 +527,11 @@ async function chatCommand(rest: string[]): Promise<void> {
|
|
|
321
527
|
console.error("The database may be locked or missing; exiting.");
|
|
322
528
|
return;
|
|
323
529
|
}
|
|
530
|
+
// The window check runs after the poll, so a message created
|
|
531
|
+
// inside the window but landed during the final sleep is still
|
|
532
|
+
// displayed; anything inserted after this poll has a created_at
|
|
533
|
+
// past the bound and is filtered by the window anyway.
|
|
534
|
+
if (filter.untilMs !== null && Date.now() >= filter.untilMs) break;
|
|
324
535
|
}
|
|
325
536
|
} finally {
|
|
326
537
|
db.close();
|
|
@@ -443,8 +654,9 @@ switch (command) {
|
|
|
443
654
|
} catch (err) {
|
|
444
655
|
console.error(`[thatch] hygiene report failed: ${err}`);
|
|
445
656
|
}
|
|
446
|
-
// Chat mail check
|
|
447
|
-
//
|
|
657
|
+
// Chat mail check. Without a hook session ID (SessionStart does not
|
|
658
|
+
// read stdin) the line keeps the old shape: a line only when pending
|
|
659
|
+
// mail exists for registered sessions in this project.
|
|
448
660
|
const chatLine = await chatHookLine();
|
|
449
661
|
const reminder = claudeSessionStartReminder(repo, hygiene) + (chatLine ? `\n\n${chatLine}` : "");
|
|
450
662
|
if (isJson) {
|
|
@@ -686,9 +898,10 @@ switch (command) {
|
|
|
686
898
|
// flush-tools fires on every prompt, including conversational turns
|
|
687
899
|
// with no tool calls.
|
|
688
900
|
const versionWarning = detectSkew(dbPath) ?? readNpmCacheForUpdate(dbPath);
|
|
689
|
-
// Chat
|
|
690
|
-
//
|
|
691
|
-
|
|
901
|
+
// Chat check: with a hook session ID this always prints the caller's
|
|
902
|
+
// assigned identity (and its unread count), so the model knows who it
|
|
903
|
+
// is before any chat tool call; with no mail the line says so.
|
|
904
|
+
const chatLine = await chatHookLine(sessionID);
|
|
692
905
|
|
|
693
906
|
// Tier 1: extraction nudge. Peek (don't drain) - the queue persists
|
|
694
907
|
// until the agent calls memory_remember, which triggers consumeQueue.
|
package/docs/dev/README.md
CHANGED
|
@@ -79,9 +79,9 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
79
79
|
| `config.ts` | User config file (`~/.config/thatch/config.json`, beside the DB): Zod section schemas (strict — unknown keys fail validation), load with missing/invalid fallback to defaults, field-level merge, atomic save (temp + rename). Backs the `config_get`/`config_set` tools. |
|
|
80
80
|
| `notify.ts` | Out-of-band notification dispatch: darwin (`osascript` banner + `/usr/bin/say` voice), linux (`notify-send` + `spd-say`/`espeak`), other platforms report unsupported. Injectable spawner (`CoreContext.spawner`) so tests never fire real commands. Backs the `notify_user` tool. |
|
|
81
81
|
| `session-db.ts` | Read-only access to the opencode session database (opencode.db): session timeline (`session list`), full part/message retrieval (`session get`), OpenAI chat-completions transcript builder (`session transcript`), and decoded-content search (`session search`). Backs the `thatch session` CLI subcommands and the opencode-only `session_search`/`session_get` tools. Path resolution honors `OPENCODE_DB`, then the XDG default; the db opens read-only so a thatch bug cannot corrupt host history. |
|
|
82
|
-
| `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only).
|
|
82
|
+
| `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Three sources: GitHub PRs and branches via `gh api` (injected `GhRunner`), and local shell commands via `bash -c` (injected `CommandRunner`) run as condition variables - only the exit code is read, never the command's output. Diffs against last-seen state, queues events (pointer data plus machine status like check conclusions and exit codes, never external content) per session, and delivers them through a plugin-supplied callback when the session is idle. The `pollSeconds` getter surfaces the poll cadence to tool output and notifications. Process-lifetime state by design - no SQLite. |
|
|
83
83
|
| `chat.ts` | Cross-session chat between opencode sessions on one machine (opencode-only). `ChatStore` holds the shared directory and inbox in SQLite so any process can read/write them; `ChatPoller` heartbeats hosted sessions and delivers wake prompts through an injected callback when the recipient is idle - only the recipient's host process delivers, so there is no cross-process double-delivery. Heartbeat staleness flags crashed sessions; a per-recipient nudge rate cap is the anti-loop hard brake. See [features/cross-session-chat.md](features/cross-session-chat.md). |
|
|
84
|
-
| `chat-names.ts` | The static display-name pool for the chat directory: whimsical geek-culture names in fnord's Nomenclater style, baked in so name assignment never costs a model call.
|
|
84
|
+
| `chat-names.ts` | The static display-name pool for the chat directory: whimsical geek-culture names in fnord's Nomenclater style, baked in so name assignment never costs a model call. The pool is the fallback base for registrations with no usable session title. |
|
|
85
85
|
| `sideband.ts` | Unix domain socket server + client. The MCP server (long-lived, warm model) runs `SidebandServer` so one-shot hook processes can ask it to embed a prompt and search for matches without loading the model themselves. Handles three methods: `match` (recall nudge), `predictions` (prediction auto-fire), and `behaviors` (behavior auto-fire). Socket path is a hash of the DB path — both processes compute it independently. |
|
|
86
86
|
| `prompts.ts` | Text constants: opencode system prompt, compaction context, session-start reminder, prompt-aware recall nudge (`recallNudge` / `claudeRecallNudge`), prediction nudge (`predictionNudge`), behavior nudge (`behaviorNudge`), prediction verb selection (`predictionVerb`), watcher notification (`watcherNotificationNudge`), version warning (`versionWarningNudge`), chat wake notification (`chatNotificationNudge`), chat transcript echo text and echo recognizer (`chatEchoText` / `isChatEchoParts`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. |
|
|
87
87
|
| `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (fact-extractor, dedup-classifier, project-primer, the review specialists, review synthesizer, review context, code archaeology, review followup, review response, change walkthrough, code walkthrough, session reflection, coding-workflow, thatch-pr-description, thatch-ticket-description, thatch-split-overlarge-pr, memory-verify, knowledge-export — work on all three hosts) and `OPENCODE_ONLY_SKILLS` (code-review coordinator — requires sub-agent support, not installed for Claude Code or Cursor). `installSkills(dir, skills)` defaults to `SHARED_SKILLS`; the opencode plugin passes `[...SHARED_SKILLS, ...OPENCODE_ONLY_SKILLS]`. |
|
|
@@ -99,6 +99,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
99
99
|
|------|-------------|
|
|
100
100
|
| `experimental.chat.system.transform` | Appends the thatch system prompt (store names, usage rules). |
|
|
101
101
|
| `experimental.session.compacting` | Marks the session as compacting and appends re-familiarization context so a compacted session still knows thatch exists. |
|
|
102
|
+
| `command.execute.before` | Arms the wrap-up greenlight check: `/thatch/compact` and `/thatch/exit` mark their session in `pendingWrapUp`. The next `session.status` idle resolves it by inspecting the final assistant message for the greenlight token and, when present, triggering the TUI action (`session_compact` via `executeCommand`, `app.exit` via `publish`; the exit path unregisters the session from the chat directory first). See [features/session-lifecycle.md](features/session-lifecycle.md). |
|
|
102
103
|
| `experimental.compaction.autocontinue` | Clears the compacting flag so `chat.message` nudges resume. Without this, nudges that instruct tool calls would fire during summary generation where tools are blocked. The `chat.message` hook also clears the flag if it fires for a session still in the compacting set but the incoming message has no compaction-type part — this handles compaction failure, where the session would otherwise be stuck with nudges off forever. |
|
|
103
104
|
| `tool.execute.after` | Buffers every non-`thatch_*`, non-`skill`, non-`task` tool call into the session's extraction buffer. (Skill/task are excluded — buffering them creates a feedback loop where the nudge triggers a skill load, which gets buffered, which triggers another nudge.) Memory writes (`thatch_memory_remember`) and `thatch_extraction_done` drain the buffer and reset the missed-nudge counter. For child sessions (`childToParent.has(sessionID)`), also tracks metrics: `remember` with `overwrite:false` → new++, `overwrite:true` → updated++, `forget` → deleted++. For `thatch_chat_*` tool calls, builds the transcript echo (`chatEchoText`) and posts it as a visible non-synthetic `noReply` part. This is a plugin hook, NOT a bus event — do not move it into the `event` handler; the event bus has no such event and it will silently never fire. |
|
|
104
105
|
| `chat.message` | Two priority tiers: (a) if extraction buffer has interactions and the session is NOT in the `extracting` set (direct extraction in progress), **peeks** the buffer (does NOT drain it) and injects a synthetic text part carrying the extraction nudge with the session ID and fetch tool name (not the full payload) — the sub-agent calls `thatch_get_extraction_payload` to retrieve the interactions as a tool response, keeping the full payload out of the main session's context window. The buffer persists until the agent writes a memory or calls `thatch_extraction_done`, so ignored nudges repeat and escalate (polite → insistent → ALL-CAPS) via the `missedNudges` counter; (b) otherwise, embeds the user's prompt text with the in-process warm model, searches `db.search()` across repo + global, and pushes a recall nudge if matches exceed the threshold (default 0.55). The same embedding also feeds the prediction auto-fire (`db.scorePredictionNudge`, injects `[thatch] User decision model`) and the behavior auto-fire (`db.scoreBehaviorNudge`, injects `[thatch] Situational behaviors`). All three nudges (recall, prediction, behavior) fire independently in separate try/catch blocks with separate synthetic parts. Chat transcript echoes (non-synthetic `noReply` parts whose text is entirely `[chat]`-prefixed bubbles) are skipped before any tier — no model turn reads them, so nudging them wastes an embedding. Skipped entirely while the session is compacting (tool calls are blocked during summary generation). |
|
|
@@ -337,8 +338,10 @@ behavior cycle (agent-driven, self-graded)
|
|
|
337
338
|
`behaviors(id PK, store, statement, rationale, embedding BLOB, model, confidence REAL, confirm_count REAL, disconfirm_count REAL, created_at, updated_at)`,
|
|
338
339
|
`behavior_edges(matcher_id, behavior_id, weight REAL, PK(matcher_id, behavior_id), FK CASCADE)`,
|
|
339
340
|
`behavior_provenance(id PK, behavior_id, signal, detail, created_at, FK CASCADE)`,
|
|
340
|
-
`chat_sessions(session_id PK, name UNIQUE COLLATE NOCASE, project, registered_at, last_seen)`,
|
|
341
|
-
`chat_messages(id PK AUTOINCREMENT, from_session, to_session, body, created_at, delivered_at, read_at)
|
|
341
|
+
`chat_sessions(session_id PK, name UNIQUE COLLATE NOCASE, topic, project, host_kind, registered_at, last_seen, auto)`,
|
|
342
|
+
`chat_messages(id PK AUTOINCREMENT, from_session, to_session, body, created_at, delivered_at, read_at, via_broadcast)`,
|
|
343
|
+
`chat_name_counters(base PK, next)` - per-base counters for assigned chat names; only ever increments.
|
|
344
|
+
`chat_leave_tombstones(session_id PK, left_at)` - explicit leaves; suppresses auto-registration until chat_register rejoins.
|
|
342
345
|
- `recall_count`, `last_recalled_at`, and `archived` are added to pre-existing
|
|
343
346
|
databases by an idempotent column migration at init (`PRAGMA table_info` +
|
|
344
347
|
`ALTER TABLE`). The `archived` column is `INTEGER NOT NULL DEFAULT 0` (0 =
|