@c4t4/heyamigo 0.3.0 → 0.6.1
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/config/config.example.json +2 -1
- package/config/memory-instructions.md +149 -67
- package/dist/config.js +1 -0
- package/dist/gateway/commands.js +4 -195
- package/dist/gateway/outgoing.js +59 -2
- package/dist/memory/compressed.js +334 -0
- package/dist/memory/digest-flag.js +15 -24
- package/dist/memory/digest.js +4 -0
- package/dist/memory/journals.js +0 -8
- package/dist/memory/preamble.js +30 -0
- package/dist/memory/scheduler.js +11 -0
- package/dist/queue/async-tasks.js +215 -0
- package/dist/queue/worker.js +49 -33
- package/package.json +1 -1
|
@@ -1,121 +1,203 @@
|
|
|
1
|
-
# Memory instructions
|
|
1
|
+
# Memory and runtime instructions
|
|
2
2
|
|
|
3
|
-
You have
|
|
3
|
+
You have long-term memory, a journaling system, and a background work lane. This file tells you how to use them. Every rule here is load-bearing — read it carefully.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Storage layout
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Everything lives under `storage/memory/`. You have Read + Write access to this directory.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
storage/memory/
|
|
11
|
+
index.md # map of the whole memory tree
|
|
12
|
+
buckets/<slug>/index.md # topical knowledge (projects, topics)
|
|
13
|
+
buckets/<slug>/*.md # bucket contents
|
|
14
|
+
persons/<phone-number>/index.md # auto-maintained per-person profile
|
|
15
|
+
persons/<phone-number>/profile.md # facts, preferences, patterns
|
|
16
|
+
chats/<jid>/index.md # auto-maintained per-chat brief
|
|
17
|
+
chats/<jid>/brief.md # purpose, tone, recent topics
|
|
18
|
+
journals/<slug>/index.md # journal spec (frontmatter + body)
|
|
19
|
+
journals/<slug>/entries.jsonl # append-only dated entries
|
|
20
|
+
journals/<slug>/observer-state.json # last-scanned timestamp per JID
|
|
21
|
+
journals/<slug>/nudge-state.json # last nudge timestamps + snooze
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Relevant blocks from these files are surfaced to you in the `[Memory: ...]` sections at the top of each turn. You don't need to re-read a file that's already in your preamble.
|
|
25
|
+
|
|
26
|
+
## Rolling state index — [State: current]
|
|
27
|
+
|
|
28
|
+
At the top of every turn, you get `[State: current]`: a rolling index across all people, chats, buckets, and active journals. One to three lines per entity. This is your cheat sheet.
|
|
29
|
+
|
|
30
|
+
It is an **index**, not a summary. Each entry carries load-bearing facts + a path to the full file. Everything else lives in the full profile / brief / entries.
|
|
31
|
+
|
|
32
|
+
### Dig-deeper heuristic
|
|
33
|
+
|
|
34
|
+
- **Passing reference** ("Dani said X in passing"): the compressed line is enough. Answer.
|
|
35
|
+
- **Deep conversation about someone** ("let's dig into Cata's gut protocol"): Read the full file.
|
|
36
|
+
- **Identity, medical, or rule cue** (pronouns, symptoms, relationship, hard rules): verify against the full file before responding. Laziness here is expensive.
|
|
37
|
+
- **Already Read this session**: the content is still in your context. Do NOT re-Read.
|
|
38
|
+
- **Unfamiliar topic or entity**: Read.
|
|
39
|
+
|
|
40
|
+
You decide. The compressed view tells you what exists and gives you enough for skimming. It does not try to replace the full files.
|
|
41
|
+
|
|
42
|
+
Do NOT edit `storage/memory/compressed.md` yourself. It is auto-regenerated after digests and on boot.
|
|
43
|
+
|
|
44
|
+
## Reply footer (system-generated)
|
|
45
|
+
|
|
46
|
+
Your replies are auto-suffixed with a tiny stats line on send — duration, tokens, context %, flags fired. You do NOT write this line. Do NOT mimic it. Do NOT include token counts, timings, or `_stats_`-style italic footers in your reply text. The system adds them; you focus on the message.
|
|
47
|
+
|
|
48
|
+
## DIGEST flag
|
|
49
|
+
|
|
50
|
+
When something in the conversation is worth remembering long-term, append this marker to the END of your reply:
|
|
8
51
|
|
|
9
52
|
```
|
|
10
53
|
[DIGEST: <one-line reason>]
|
|
11
54
|
```
|
|
12
55
|
|
|
13
|
-
|
|
14
|
-
- New durable preference ("prefers audio notes over text")
|
|
15
|
-
- Key fact about their life or work ("moving to Berlin May 1")
|
|
16
|
-
- Relationship or context shift ("no longer working")
|
|
17
|
-
- A decision they made that future replies should respect
|
|
56
|
+
The marker is stripped before the user sees it. It schedules a background consolidation pass that updates the relevant person profile and/or chat brief.
|
|
18
57
|
|
|
19
|
-
|
|
20
|
-
- Small talk, jokes, logistics
|
|
21
|
-
- Facts the profile already knows
|
|
22
|
-
- Every single message (flag sparingly, a few times per week at most)
|
|
58
|
+
Use for: a new durable preference, a key life/work fact, a relationship or context shift, a decision that future replies should respect.
|
|
23
59
|
|
|
24
|
-
|
|
60
|
+
Do NOT use for: small talk, jokes, logistics, facts already in the profile, things that happen constantly. A few times per week at most.
|
|
25
61
|
|
|
26
62
|
## Journals
|
|
27
63
|
|
|
28
|
-
A
|
|
64
|
+
A journal is a long-running tracking project the owner sets up: a health journal for Dani, a dog-training log, a competitor-outreach spy journal, etc. Each journal has a purpose, captures entries over time, and can nudge the owner proactively.
|
|
65
|
+
|
|
66
|
+
Active journals appear in `[Journals: active]` in your preamble with slug + purpose. Use those exact slugs — never invent one.
|
|
67
|
+
|
|
68
|
+
Journals are OWNER-SCOPED and GLOBAL. The same list applies across every chat the owner is in. A journal is not tied to a specific chat or person.
|
|
69
|
+
|
|
70
|
+
### Creating a new journal
|
|
29
71
|
|
|
30
|
-
|
|
72
|
+
When the owner asks you to track something recurring that no existing journal covers:
|
|
31
73
|
|
|
32
|
-
|
|
74
|
+
1. Propose one concrete purpose in one message:
|
|
75
|
+
> "Competitor-outreach spy journal: track HT creators' shock-loss timelines, Elithair comment-section complaints, and open follow-up threads. Sound right?"
|
|
76
|
+
2. Wait for confirmation.
|
|
77
|
+
3. Once confirmed, append this marker at the END of your reply:
|
|
78
|
+
```
|
|
79
|
+
[JOURNAL-NEW:<slug> — <one-line purpose>]
|
|
80
|
+
```
|
|
33
81
|
|
|
34
|
-
|
|
82
|
+
Slug rules: lowercase letters, digits, hyphens. Max 48 chars. Start with a letter or digit. Be descriptive but short (`rivoara-spy`, `health`, `dog-training`).
|
|
35
83
|
|
|
84
|
+
The marker creates `storage/memory/journals/<slug>/index.md` with sensible defaults (status=active, nudge_if_silent=3d). You don't need to write the file yourself — the marker handles it.
|
|
85
|
+
|
|
86
|
+
You can flag the first entry in the same reply:
|
|
36
87
|
```
|
|
37
|
-
[JOURNAL
|
|
88
|
+
[JOURNAL-NEW:rivoara-spy — Track HT creator shock-loss timelines, Elithair complaints, open follow-ups]
|
|
89
|
+
[JOURNAL:rivoara-spy — @ari269906 hits day 60 around mid-May, shock-loss window]
|
|
38
90
|
```
|
|
39
91
|
|
|
40
|
-
|
|
92
|
+
### Appending entries
|
|
41
93
|
|
|
42
|
-
|
|
94
|
+
When a message contains info that belongs in an active journal, append at the END of your reply:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
[JOURNAL:<slug> — <one-line note>]
|
|
98
|
+
```
|
|
43
99
|
|
|
44
|
-
|
|
100
|
+
Multiple tags in one reply are fine. Separator between slug and note: em-dash, en-dash, hyphen, or colon.
|
|
45
101
|
|
|
46
|
-
|
|
47
|
-
Reply ends with: `[JOURNAL:health — 5hrs sleep, mild headache]`
|
|
102
|
+
Realistic examples (assume active slugs `health`, `rivoara-spy`):
|
|
48
103
|
|
|
49
|
-
-
|
|
50
|
-
|
|
104
|
+
- Dani: "slept 5hrs, toilet again, head pounding"
|
|
105
|
+
→ `[JOURNAL:health — 5hrs sleep, GI symptoms recurring, headache]`
|
|
106
|
+
- Cata: "@chigosfoodblog just posted Tag 5, pouring water down his fresh grafts with tap"
|
|
107
|
+
→ `[JOURNAL:rivoara-spy — @chigosfoodblog day 5, visible tap-water rinse, strong filter pitch angle]`
|
|
108
|
+
- Cata: "dinner was great"
|
|
109
|
+
→ no journal tag. Irrelevant to any journal.
|
|
51
110
|
|
|
52
|
-
|
|
53
|
-
|
|
111
|
+
Hard rules:
|
|
112
|
+
- Use only slugs in `[Journals: active]`. Don't invent.
|
|
113
|
+
- One journal, one subject. Don't cross-log (Dani's health entries don't go in Cata's health topic bucket or vice versa).
|
|
114
|
+
- Don't log every message. Flag when there's real content for the journal.
|
|
115
|
+
- If the owner's statement is ambiguous, ask before flagging.
|
|
54
116
|
|
|
55
|
-
###
|
|
117
|
+
### Editing a journal (pause, archive, cadence, schema)
|
|
56
118
|
|
|
57
|
-
|
|
58
|
-
- **Don't flag unrelated content.** A message about dinner isn't a health-journal entry unless the owner explicitly connects it to health.
|
|
59
|
-
- **Don't flag every message.** Flag when there's real content for the journal. Chit-chat is not an entry.
|
|
60
|
-
- **Don't invent entries.** If the owner said something ambiguous, ask them to clarify before flagging.
|
|
119
|
+
There are no markers for pause/resume/archive. When the owner asks to pause, archive, snooze, or reshape a journal, edit `storage/memory/journals/<slug>/index.md` directly with Edit or Write.
|
|
61
120
|
|
|
62
|
-
|
|
121
|
+
Frontmatter fields you may change:
|
|
122
|
+
- `status: active | paused | archived` — paused and archived journals stop nudging and stop appearing in observer sweeps.
|
|
123
|
+
- `purpose: <text>` — refine as the journal evolves.
|
|
124
|
+
- `fields: [<field>, <field>, ...]` — what the journal typically captures.
|
|
125
|
+
- `checkin: "daily HH:MM" | "Xh" | "Xd"` — proactive check-in cadence.
|
|
126
|
+
- `nudge_if_silent: "Xd"` — nudge after this much silence on the topic.
|
|
127
|
+
- `quiet_hours: "HH:MM-HH:MM"` — per-journal quiet window (overrides default 22:00-08:00).
|
|
63
128
|
|
|
64
|
-
|
|
129
|
+
Do NOT edit `entries.jsonl` directly — that's append-only and maintained by the pipeline. Do NOT edit `observer-state.json` or `nudge-state.json` unless fixing a specific bug the owner asked you to investigate.
|
|
65
130
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- Offer to check in: "want me to ask about sleep tomorrow night?"
|
|
131
|
+
Confirm the change in your reply so the owner sees what you did:
|
|
132
|
+
> "Archived. Won't nudge you about it anymore. Entries stay in entries.jsonl as the historical record."
|
|
69
133
|
|
|
70
|
-
|
|
134
|
+
## ASYNC background work
|
|
71
135
|
|
|
72
|
-
|
|
136
|
+
The chat queue is serialized per chat. If you do real work inline (browsing, scraping, multi-step research), every subsequent message in that chat waits for you. To stay responsive, delegate long work to a background worker.
|
|
73
137
|
|
|
74
|
-
|
|
138
|
+
Two parts in the same reply:
|
|
75
139
|
|
|
76
|
-
1.
|
|
77
|
-
|
|
78
|
-
2. Wait for confirmation or edits.
|
|
79
|
-
3. Once confirmed, create it by appending this marker to the END of your reply:
|
|
140
|
+
1. A one or two-sentence ack in the reply text: "On it, will report back." / "Scraping now, give me a few minutes." / "Looking into it."
|
|
141
|
+
2. Append at the END:
|
|
80
142
|
```
|
|
81
|
-
[
|
|
143
|
+
[ASYNC: <self-sufficient task description>]
|
|
82
144
|
```
|
|
83
|
-
The marker will be stripped. The journal is created immediately and becomes active. You can flag the first entry in the same reply by also adding a `[JOURNAL:<slug> — <note>]` right after.
|
|
84
145
|
|
|
85
|
-
Example
|
|
146
|
+
Example:
|
|
147
|
+
|
|
86
148
|
```
|
|
87
|
-
|
|
88
|
-
|
|
149
|
+
On it. Will send the list when it's ready.
|
|
150
|
+
|
|
151
|
+
[ASYNC: Find 10 additional German TikTok creators documenting hair transplant journeys, 500-3000 followers, not in this list: @simply__stefan, @daenieal, @myhairjourney2025, @chigosfoodblog. Use the Rivoara TikTok account (already logged in) to browse. Output handle, follower count, one-line angle per creator.]
|
|
89
152
|
```
|
|
90
153
|
|
|
91
|
-
|
|
154
|
+
### When to use ASYNC
|
|
92
155
|
|
|
93
|
-
|
|
156
|
+
Use it for:
|
|
157
|
+
- Browser work (scraping, multi-page research, form filling, anything touching >1 URL)
|
|
158
|
+
- Multi-step investigations with several tool calls
|
|
159
|
+
- Anything you expect to take more than ~30 seconds
|
|
94
160
|
|
|
95
|
-
|
|
161
|
+
Do NOT use it for:
|
|
162
|
+
- A single quick URL fetch
|
|
163
|
+
- Short calculations or reasoning
|
|
164
|
+
- Anything you can answer from context alone
|
|
165
|
+
- Things the owner needs answered in this reply, right now
|
|
96
166
|
|
|
97
|
-
|
|
167
|
+
### Writing the task description
|
|
98
168
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
169
|
+
The async worker has NO chat history, NO session, no memory of your conversation. Its only input is the description you write. Self-sufficient means:
|
|
170
|
+
- Spell out exactly what to do.
|
|
171
|
+
- Include every constraint, exclusion, and required context.
|
|
172
|
+
- Reference specific tools or accounts (e.g. "use the Rivoara TikTok session").
|
|
173
|
+
- Specify the expected output shape (list format, fields, order).
|
|
104
174
|
|
|
105
|
-
|
|
175
|
+
Over-specify. A vague description produces a vague result.
|
|
106
176
|
|
|
107
|
-
|
|
177
|
+
### Avoiding duplicates
|
|
108
178
|
|
|
109
|
-
|
|
179
|
+
If you see `[Async tasks in progress]` in your preamble, a worker is already running for this chat. Do NOT emit another `[ASYNC:...]` for the same work. Reply naturally: "Still working on it, 4 minutes in."
|
|
110
180
|
|
|
111
|
-
|
|
181
|
+
## Sending files
|
|
112
182
|
|
|
113
|
-
To send a file
|
|
183
|
+
To send a file (screenshot, image, video, PDF, audio) to the chat, save it to `storage/temp/` and include this tag in your reply:
|
|
114
184
|
|
|
115
185
|
```
|
|
116
|
-
[FILE:
|
|
186
|
+
[FILE: /absolute/path/to/file.png]
|
|
117
187
|
```
|
|
118
188
|
|
|
119
|
-
|
|
189
|
+
Aliases (all behave the same): `[IMAGE: path]`, `[VIDEO: path]`, `[AUDIO: path]`, `[DOCUMENT: path]`.
|
|
190
|
+
|
|
191
|
+
Rules:
|
|
192
|
+
- Always use absolute paths.
|
|
193
|
+
- Always save under `storage/temp/`. Never save to the project root or anywhere else. Files are auto-deleted after sending.
|
|
194
|
+
- Media type is detected from the file extension.
|
|
195
|
+
- If you send a single file with a short text reply (under 1000 chars, non-audio), the text becomes the caption.
|
|
196
|
+
|
|
197
|
+
## Browser tools
|
|
198
|
+
|
|
199
|
+
You have a Chrome browser via Playwright MCP: `browser_navigate`, `browser_take_screenshot`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_evaluate`, etc.
|
|
200
|
+
|
|
201
|
+
For anything beyond a single page load, use `[ASYNC: ...]` instead of running inline. Browser work blocks the main queue.
|
|
120
202
|
|
|
121
|
-
|
|
203
|
+
To send a screenshot back: take it with the browser tool (save to `storage/temp/`), then include `[IMAGE: /absolute/path.png]` in your reply.
|
package/dist/config.js
CHANGED
package/dist/gateway/commands.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { clearSession, getSessionInfo } from '../ai/sessions.js';
|
|
2
2
|
import { reloadSystemPrompt } from '../ai/claude.js';
|
|
3
3
|
import { config } from '../config.js';
|
|
4
|
-
import { createJournal, getJournal, isValidSlug, listJournals, readEntries, snoozeJournal, updateJournalStatus, } from '../memory/journals.js';
|
|
5
4
|
import { runDigestNow } from '../memory/scheduler.js';
|
|
6
5
|
import { sendText } from '../wa/sender.js';
|
|
6
|
+
// Feature-level commands (/journal, /snooze, /tasks, etc.) are intentionally
|
|
7
|
+
// absent. Claude is the interface — the owner asks for things in natural
|
|
8
|
+
// language and Claude acts via markers or by editing files directly.
|
|
9
|
+
// Only operational commands live here: reset, status, reload, digest.
|
|
7
10
|
export async function tryCommand(ctx) {
|
|
8
11
|
const prefix = config.commands.prefix;
|
|
9
12
|
const trimmed = ctx.text.trim();
|
|
@@ -55,199 +58,5 @@ export async function tryCommand(ctx) {
|
|
|
55
58
|
}).catch(() => undefined);
|
|
56
59
|
return true;
|
|
57
60
|
}
|
|
58
|
-
if (cmd === 'journal' || cmd === 'journals') {
|
|
59
|
-
if (!isOwner(ctx.senderNumber)) {
|
|
60
|
-
await sendText(ctx.sock, ctx.jid, 'Journals are owner-only.', ctx.quoted);
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
const rest = trimmed.slice(prefix.length + cmd.length).trim();
|
|
64
|
-
await handleJournalCmd(ctx, rest);
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
if (cmd === 'snooze') {
|
|
68
|
-
if (!isOwner(ctx.senderNumber)) {
|
|
69
|
-
await sendText(ctx.sock, ctx.jid, 'Snooze is owner-only.', ctx.quoted);
|
|
70
|
-
return true;
|
|
71
|
-
}
|
|
72
|
-
const rest = trimmed.slice(prefix.length + cmd.length).trim();
|
|
73
|
-
await handleSnoozeCmd(ctx, rest);
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
61
|
return false;
|
|
77
62
|
}
|
|
78
|
-
async function handleSnoozeCmd(ctx, rest) {
|
|
79
|
-
const [slugRaw, durationRaw] = rest.split(/\s+/);
|
|
80
|
-
const slug = (slugRaw ?? '').toLowerCase();
|
|
81
|
-
const duration = (durationRaw ?? '24h').toLowerCase();
|
|
82
|
-
if (!slug) {
|
|
83
|
-
await sendText(ctx.sock, ctx.jid, 'Usage: /snooze <slug> [duration]\nDuration: e.g. 6h, 2d (default 24h)', ctx.quoted);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
if (!getJournal(slug)) {
|
|
87
|
-
await sendText(ctx.sock, ctx.jid, `No journal "${slug}".`, ctx.quoted);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
const secs = parseDuration(duration);
|
|
91
|
-
if (!secs) {
|
|
92
|
-
await sendText(ctx.sock, ctx.jid, `Bad duration "${duration}". Use formats like 6h, 2d, 30m.`, ctx.quoted);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
const until = Math.floor(Date.now() / 1000) + secs;
|
|
96
|
-
snoozeJournal(slug, until);
|
|
97
|
-
await sendText(ctx.sock, ctx.jid, `Snoozed "${slug}" for ${duration}. No nudges until ${new Date(until * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC.`, ctx.quoted);
|
|
98
|
-
}
|
|
99
|
-
function parseDuration(raw) {
|
|
100
|
-
const m = raw.match(/^(\d+)\s*([mhd])$/);
|
|
101
|
-
if (!m)
|
|
102
|
-
return null;
|
|
103
|
-
const n = Number(m[1]);
|
|
104
|
-
const u = m[2];
|
|
105
|
-
if (!Number.isFinite(n) || n <= 0)
|
|
106
|
-
return null;
|
|
107
|
-
if (u === 'm')
|
|
108
|
-
return n * 60;
|
|
109
|
-
if (u === 'h')
|
|
110
|
-
return n * 3600;
|
|
111
|
-
if (u === 'd')
|
|
112
|
-
return n * 86400;
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
function isOwner(senderNumber) {
|
|
116
|
-
return !!config.owner.number && senderNumber === config.owner.number;
|
|
117
|
-
}
|
|
118
|
-
async function handleJournalCmd(ctx, rest) {
|
|
119
|
-
const [subRaw, ...argParts] = rest.split(/\s+/);
|
|
120
|
-
const sub = (subRaw ?? 'list').toLowerCase();
|
|
121
|
-
const args = argParts.join(' ').trim();
|
|
122
|
-
if (sub === 'list' || sub === '') {
|
|
123
|
-
const journals = listJournals();
|
|
124
|
-
if (journals.length === 0) {
|
|
125
|
-
await sendText(ctx.sock, ctx.jid, 'No journals yet. Create one with:\n/journal create <slug> <purpose>', ctx.quoted);
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
const lines = ['Journals:'];
|
|
129
|
-
for (const j of journals) {
|
|
130
|
-
lines.push(`- ${j.slug} [${j.status}]: ${j.purpose || j.name}`);
|
|
131
|
-
}
|
|
132
|
-
await sendText(ctx.sock, ctx.jid, lines.join('\n'), ctx.quoted);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (sub === 'create' || sub === 'new') {
|
|
136
|
-
const [slugRaw, ...purposeParts] = args.split(/\s+/);
|
|
137
|
-
const slug = (slugRaw ?? '').toLowerCase();
|
|
138
|
-
const purpose = purposeParts.join(' ').trim();
|
|
139
|
-
if (!slug || !purpose) {
|
|
140
|
-
await sendText(ctx.sock, ctx.jid, 'Usage: /journal create <slug> <purpose>\nExample: /journal create health Track sleep, symptoms, meds, mood', ctx.quoted);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
if (!isValidSlug(slug)) {
|
|
144
|
-
await sendText(ctx.sock, ctx.jid, `Invalid slug "${slug}". Use lowercase letters, digits, hyphens. Max 48 chars.`, ctx.quoted);
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
if (getJournal(slug)) {
|
|
148
|
-
await sendText(ctx.sock, ctx.jid, `Journal "${slug}" already exists.`, ctx.quoted);
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
try {
|
|
152
|
-
const j = createJournal({
|
|
153
|
-
slug,
|
|
154
|
-
name: titleCase(slug),
|
|
155
|
-
purpose,
|
|
156
|
-
});
|
|
157
|
-
await sendText(ctx.sock, ctx.jid, `Journal "${j.slug}" created and active. I'll start tagging relevant entries. Use /journal show ${j.slug} to inspect.`, ctx.quoted);
|
|
158
|
-
}
|
|
159
|
-
catch (err) {
|
|
160
|
-
await sendText(ctx.sock, ctx.jid, `Create failed: ${err.message}`, ctx.quoted);
|
|
161
|
-
}
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
if (sub === 'show' || sub === 'info') {
|
|
165
|
-
const slug = args.split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
166
|
-
const j = getJournal(slug);
|
|
167
|
-
if (!j) {
|
|
168
|
-
await sendText(ctx.sock, ctx.jid, `No journal "${slug}".`, ctx.quoted);
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
const lines = [
|
|
172
|
-
`${j.name} (${j.slug}) [${j.status}]`,
|
|
173
|
-
j.purpose,
|
|
174
|
-
];
|
|
175
|
-
if (j.fields.length)
|
|
176
|
-
lines.push(`Fields: ${j.fields.join(', ')}`);
|
|
177
|
-
if (j.cadence.checkin)
|
|
178
|
-
lines.push(`Check-in: ${j.cadence.checkin}`);
|
|
179
|
-
if (j.cadence.followup_after)
|
|
180
|
-
lines.push(`Follow-up after: ${j.cadence.followup_after}`);
|
|
181
|
-
if (j.cadence.nudge_if_silent)
|
|
182
|
-
lines.push(`Nudge if silent: ${j.cadence.nudge_if_silent}`);
|
|
183
|
-
const entries = readEntries(j.slug, 5);
|
|
184
|
-
if (entries.length) {
|
|
185
|
-
lines.push('', 'Recent entries:');
|
|
186
|
-
for (const e of entries) {
|
|
187
|
-
const d = new Date(e.ts * 1000)
|
|
188
|
-
.toISOString()
|
|
189
|
-
.slice(0, 16)
|
|
190
|
-
.replace('T', ' ');
|
|
191
|
-
lines.push(`- [${d}] ${e.note}`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
lines.push('', '(no entries yet)');
|
|
196
|
-
}
|
|
197
|
-
await sendText(ctx.sock, ctx.jid, lines.join('\n'), ctx.quoted);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
if (sub === 'entries') {
|
|
201
|
-
const [slugRaw, nRaw] = args.split(/\s+/);
|
|
202
|
-
const slug = (slugRaw ?? '').toLowerCase();
|
|
203
|
-
const n = Math.max(1, Math.min(50, Number(nRaw) || 10));
|
|
204
|
-
if (!getJournal(slug)) {
|
|
205
|
-
await sendText(ctx.sock, ctx.jid, `No journal "${slug}".`, ctx.quoted);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const entries = readEntries(slug, n);
|
|
209
|
-
if (!entries.length) {
|
|
210
|
-
await sendText(ctx.sock, ctx.jid, `No entries in "${slug}" yet.`, ctx.quoted);
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
const lines = [`Last ${entries.length} entries in "${slug}":`];
|
|
214
|
-
for (const e of entries) {
|
|
215
|
-
const d = new Date(e.ts * 1000)
|
|
216
|
-
.toISOString()
|
|
217
|
-
.slice(0, 16)
|
|
218
|
-
.replace('T', ' ');
|
|
219
|
-
lines.push(`- [${d}] (${e.source}) ${e.note}`);
|
|
220
|
-
}
|
|
221
|
-
await sendText(ctx.sock, ctx.jid, lines.join('\n'), ctx.quoted);
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
if (sub === 'pause' || sub === 'resume' || sub === 'archive' || sub === 'activate') {
|
|
225
|
-
const slug = args.split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
226
|
-
if (!getJournal(slug)) {
|
|
227
|
-
await sendText(ctx.sock, ctx.jid, `No journal "${slug}".`, ctx.quoted);
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
const status = sub === 'pause'
|
|
231
|
-
? 'paused'
|
|
232
|
-
: sub === 'archive'
|
|
233
|
-
? 'archived'
|
|
234
|
-
: 'active';
|
|
235
|
-
const updated = updateJournalStatus(slug, status);
|
|
236
|
-
await sendText(ctx.sock, ctx.jid, `Journal "${slug}" is now ${updated?.status}.`, ctx.quoted);
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
await sendText(ctx.sock, ctx.jid, [
|
|
240
|
-
'Journal commands:',
|
|
241
|
-
'/journal list',
|
|
242
|
-
'/journal create <slug> <purpose>',
|
|
243
|
-
'/journal show <slug>',
|
|
244
|
-
'/journal entries <slug> [n]',
|
|
245
|
-
'/journal pause|resume|archive <slug>',
|
|
246
|
-
].join('\n'), ctx.quoted);
|
|
247
|
-
}
|
|
248
|
-
function titleCase(slug) {
|
|
249
|
-
return slug
|
|
250
|
-
.split('-')
|
|
251
|
-
.map((p) => (p ? p[0].toUpperCase() + p.slice(1) : p))
|
|
252
|
-
.join(' ');
|
|
253
|
-
}
|
package/dist/gateway/outgoing.js
CHANGED
|
@@ -33,6 +33,9 @@ export async function handleReply(job, result, originalMsg) {
|
|
|
33
33
|
const { text, files } = extractFiles(raw);
|
|
34
34
|
const isGroup = isJidGroup(job.jid) === true;
|
|
35
35
|
const quoted = isGroup && config.reply.quoteInGroups ? originalMsg : undefined;
|
|
36
|
+
const footer = result.stats && config.reply.showStats
|
|
37
|
+
? formatStatsFooter(result.stats)
|
|
38
|
+
: '';
|
|
36
39
|
try {
|
|
37
40
|
// Send files first (images, videos, PDFs, audio, etc.)
|
|
38
41
|
for (const filePath of files) {
|
|
@@ -43,7 +46,15 @@ export async function handleReply(job, result, originalMsg) {
|
|
|
43
46
|
const caption = isFirst && text && text.length <= 1000 && files.length === 1 && supportsCaption
|
|
44
47
|
? text
|
|
45
48
|
: undefined;
|
|
46
|
-
|
|
49
|
+
// Append footer to caption at send time only (not to storage). Only
|
|
50
|
+
// when this media file is the final user-facing payload (no text
|
|
51
|
+
// coming after, single file with caption case).
|
|
52
|
+
const willHaveTextAfter = !!text &&
|
|
53
|
+
!(files.length === 1 && text.length <= 1000 && supportsCaption);
|
|
54
|
+
const captionForSend = caption && footer && !willHaveTextAfter
|
|
55
|
+
? `${caption}\n\n${footer}`
|
|
56
|
+
: caption;
|
|
57
|
+
await sendFile(sock, job.jid, filePath, captionForSend, isFirst ? quoted : undefined);
|
|
47
58
|
await append({
|
|
48
59
|
id: `reply-file-${Date.now()}`,
|
|
49
60
|
jid: job.jid,
|
|
@@ -73,7 +84,9 @@ export async function handleReply(job, result, originalMsg) {
|
|
|
73
84
|
for (let i = 0; i < chunks.length; i++) {
|
|
74
85
|
const chunk = chunks[i];
|
|
75
86
|
const q = i === 0 && files.length === 0 ? quoted : undefined;
|
|
76
|
-
|
|
87
|
+
const isLast = i === chunks.length - 1;
|
|
88
|
+
const chunkForSend = isLast && footer ? `${chunk}\n\n${footer}` : chunk;
|
|
89
|
+
await sendText(sock, job.jid, chunkForSend, q);
|
|
77
90
|
await append({
|
|
78
91
|
id: `reply-${Date.now()}-${i}`,
|
|
79
92
|
jid: job.jid,
|
|
@@ -107,6 +120,50 @@ export async function handleReply(job, result, originalMsg) {
|
|
|
107
120
|
function sleep(ms) {
|
|
108
121
|
return new Promise((r) => setTimeout(r, ms));
|
|
109
122
|
}
|
|
123
|
+
// Append-only-at-send footer. Never stored, never in Claude's recent-context
|
|
124
|
+
// feedback loop. Adaptive: shows only what's interesting for this reply.
|
|
125
|
+
export function formatStatsFooter(stats) {
|
|
126
|
+
const parts = [];
|
|
127
|
+
// Duration — always
|
|
128
|
+
const secs = stats.durationMs / 1000;
|
|
129
|
+
parts.push(secs < 10 ? `${secs.toFixed(1)}s` : `${Math.round(secs)}s`);
|
|
130
|
+
// Tokens in / out — always. Show cache hit only when it's meaningful.
|
|
131
|
+
const inStr = compactTokens(stats.inputTokens + stats.cacheReadTokens);
|
|
132
|
+
const outStr = compactTokens(stats.outputTokens);
|
|
133
|
+
const cacheStr = stats.cacheReadTokens >= 500
|
|
134
|
+
? ` (${compactTokens(stats.cacheReadTokens)} cached)`
|
|
135
|
+
: '';
|
|
136
|
+
parts.push(`${inStr}↑${cacheStr} ${outStr}↓`);
|
|
137
|
+
// Context % — only when worth calling out
|
|
138
|
+
if (stats.contextWindow > 0) {
|
|
139
|
+
const pct = Math.round((stats.totalContextTokens / stats.contextWindow) * 100);
|
|
140
|
+
if (pct >= 90)
|
|
141
|
+
parts.push(`⚠ ${pct}% ctx`);
|
|
142
|
+
else if (pct >= 70)
|
|
143
|
+
parts.push(`${pct}% ctx`);
|
|
144
|
+
}
|
|
145
|
+
// Fresh session — resume is default, says nothing
|
|
146
|
+
if (stats.fresh)
|
|
147
|
+
parts.push('fresh');
|
|
148
|
+
// Journal flagged — show each slug (usually 0 or 1)
|
|
149
|
+
for (const slug of stats.journalSlugs)
|
|
150
|
+
parts.push(`+journal:${slug}`);
|
|
151
|
+
// Digest fired
|
|
152
|
+
if (stats.hasDigest)
|
|
153
|
+
parts.push('+digest');
|
|
154
|
+
// Async spawned
|
|
155
|
+
if (stats.asyncCount > 0) {
|
|
156
|
+
parts.push(stats.asyncCount === 1 ? '+async' : `+${stats.asyncCount} async`);
|
|
157
|
+
}
|
|
158
|
+
return `_${parts.join(' · ')}_`;
|
|
159
|
+
}
|
|
160
|
+
function compactTokens(n) {
|
|
161
|
+
if (n < 1000)
|
|
162
|
+
return String(n);
|
|
163
|
+
if (n < 10_000)
|
|
164
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
165
|
+
return `${Math.round(n / 1000)}k`;
|
|
166
|
+
}
|
|
110
167
|
// Proactive outbound: send a message to a chat without an incoming trigger.
|
|
111
168
|
// Chunks, persists to the message log, never throws. Callers are responsible
|
|
112
169
|
// for the canSendProactive() gate — this function does not re-check it.
|