typeclaw 0.2.0 → 0.3.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.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: typeclaw-memory
3
- description: Use this skill whenever the user asks what you remember, what you forgot, what you dreamed, why a fact is or isn't in your memory, when memory consolidation happens, or whenever you are about to read or write `MEMORY.md`, anything under `memory/`, or `memory/skills/`. Triggers include "what do you remember", "do you remember X", "forget that", "what did you dream", "when do you dream next", "why did you forget X", "edit MEMORY.md", "add to memory", "your daily streams", "memory-logger", "dreaming", "muscle memory", or any mention of `memory.idleMs` / `memory.dreaming.schedule` in `typeclaw.json`. Read it before you touch any memory file — `MEMORY.md` and `memory/yyyy-MM-dd.md` are runtime-owned, hand-edits are easy to do wrong, and the user almost always means something more specific than "edit memory" when they say it.
3
+ description: Use this skill whenever the user asks what you remember, what you forgot, what you dreamed, why a fact is or isn't in your memory, when memory consolidation happens, or whenever you are about to read or write `MEMORY.md`, anything under `memory/`, or `memory/skills/`. Triggers include "what do you remember", "do you remember X", "forget that", "what did you dream", "when do you dream next", "why did you forget X", "edit MEMORY.md", "add to memory", "your daily streams", "memory-logger", "dreaming", "muscle memory", or any mention of `memory.idleMs` / `memory.dreaming.schedule` in `typeclaw.json`. Read it before you touch any memory file — `MEMORY.md` and `memory/yyyy-MM-dd.jsonl` are runtime-owned, hand-edits are easy to do wrong, and the user almost always means something more specific than "edit memory" when they say it.
4
4
  ---
5
5
 
6
6
  # typeclaw-memory
7
7
 
8
- You have a two-stage memory system, owned by the bundled `memory` plugin (auto-loaded on every TypeClaw agent — there is no `plugins[]` entry to add and no opt-out). Daily observations flow into `memory/yyyy-MM-dd.md` while you are awake; offline reflection consolidates them into `MEMORY.md` and may distill repeated procedures into muscle-memory skills under `memory/skills/`. Both stages are run by subagents the runtime spawns on its own — not tools you call directly.
8
+ You have a two-stage memory system, owned by the bundled `memory` plugin (auto-loaded on every TypeClaw agent — there is no `plugins[]` entry to add and no opt-out). Daily observations flow into `memory/yyyy-MM-dd.jsonl` while you are awake; offline reflection consolidates them into `MEMORY.md` and may distill repeated procedures into muscle-memory skills under `memory/skills/`. Both stages are run by subagents the runtime spawns on its own — not tools you call directly.
9
9
 
10
10
  This skill exists so you can answer the user's questions about your own memory honestly and so you do not corrupt it by hand-editing.
11
11
 
@@ -18,7 +18,7 @@ After every prompt completes, the runtime fires the `session.idle` hook. The mem
18
18
  The memory-logger reads:
19
19
 
20
20
  1. `MEMORY.md` (long-term memory)
21
- 2. The current `memory/yyyy-MM-dd.md` daily stream
21
+ 2. The current `memory/yyyy-MM-dd.jsonl` daily stream
22
22
  3. The transcript of the parent session past a watermark (the `entry=` value of the last fragment or watermark marker for that session)
23
23
 
24
24
  It writes zero or more **fragments** to today's stream, plus a watermark marker so the next run knows where to resume. It writes nothing else, and it cannot run shell commands or edit existing content (its only tools are `read` and a custom `append`-only file tool — append never truncates, and a leading `\n` is auto-inserted if the existing file did not end in one).
@@ -43,9 +43,9 @@ The dreaming subagent runs on cron, configured under `memory.dreaming.schedule`
43
43
  When dreaming fires, it reads:
44
44
 
45
45
  1. `MEMORY.md`
46
- 2. The **undreamed tail** of every `memory/yyyy-MM-dd.md` (the runtime tells it the exact line rangeearlier lines are already consolidated into `MEMORY.md` and must NOT be re-read)
46
+ 2. The **undreamed fragments** of every `memory/yyyy-MM-dd.jsonl` (the runtime tells it which fragment ids are new fragments whose ids are already in `memory/.dreaming-state.json#dreamedThrough[date].dreamedIds` have been consolidated and must NOT be re-cited)
47
47
 
48
- It rewrites `MEMORY.md` with the merged result, advances the per-day watermark in `memory/.dreaming-state.json`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, then commits the snapshot with a message shaped like `dream: <summary> <emoji>` — e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`. The summary is derived from the staged diff (line additions in daily streams, newly-added skills, etc.), and the emoji is a random pick from a small thematic pool. After the commit, the runtime sets the `skip-worktree` index flag on the tracked memory artifacts so the user's `git status` and `git diff` stay clean. The flag is cleared and re-applied around every commit.
48
+ It rewrites `MEMORY.md` with the merged result, advances the per-day dreamed-id set in `memory/.dreaming-state.json`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, **compacts the touched daily streams** (drops superseded watermarks per source and fragments that are in `dreamedIds` but not cited from `MEMORY.md`), then commits the snapshot with a message shaped like `dream: <summary> <emoji>` — e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`. The summary is derived from the staged diff (line additions in daily streams, newly-added skills, etc.), and the emoji is a random pick from a small thematic pool. After the commit, the runtime sets the `skip-worktree` index flag on the tracked memory artifacts so the user's `git status` and `git diff` stay clean. The flag is cleared and re-applied around every commit.
49
49
 
50
50
  The dreaming subagent has only three tools: `read`, `write`, `ls`. No `bash`. No `edit`. It cannot run shell commands.
51
51
 
@@ -58,17 +58,17 @@ The dreaming subagent has only three tools: `read`, `write`, `ls`. No `bash`. No
58
58
  <conclusion paragraph in dreaming's own words>
59
59
 
60
60
  fragments:
61
- - memory/yyyy-MM-dd:<line>-<line>
62
- - memory/yyyy-MM-dd:<line>-<line>
61
+ - memory/yyyy-MM-dd#<fragment-id>
62
+ - memory/yyyy-MM-dd#<fragment-id>
63
63
 
64
64
  ## <topic>
65
65
  <conclusion paragraph>
66
66
 
67
67
  fragments:
68
- - memory/yyyy-MM-dd:<line>-<line>
68
+ - memory/yyyy-MM-dd#<fragment-id>
69
69
  ```
70
70
 
71
- The first line is always `# Memory`. Topics are level-2 headings. Every topic cites the source fragments by `memory/yyyy-MM-dd:<line>-<line>` so any claim is traceable back to the daily stream entry that justified it.
71
+ The first line is always `# Memory`. Topics are level-2 headings. Every topic cites the source fragments by `memory/yyyy-MM-dd#<uuidv7>` (the full id from the fragment event's `id` field) so any claim is traceable back to the daily stream entry that justified it. Citations are id-based, not line-based, so daily streams can be compacted between dreaming runs without invalidating prior references.
72
72
 
73
73
  If the undreamed tails contain only watermarks, or every new fragment is already represented in `MEMORY.md`, dreaming **does nothing** and exits without writing. The watermark advances either way. "No-op dreaming" is a normal outcome, not a failure.
74
74
 
@@ -77,7 +77,7 @@ If the undreamed tails contain only watermarks, or every new fragment is already
77
77
  Core's `createResourceLoader` appends a `# Memory` section as the LAST block of your system prompt (after `gitNudge`) by calling `loadMemory`. It is pinned to the cache-suffix end so growth in the daily stream invalidates only the memory section itself, not the skills/tools/history above. The section contains:
78
78
 
79
79
  - `MEMORY.md` (truncated to 12 KB; if larger, the rest is dropped with a `[truncated]` marker)
80
- - The **undreamed tails** of each `memory/yyyy-MM-dd.md`, with bare watermark lines stripped (they are bookkeeping for the memory-logger, no signal for you)
80
+ - The **undreamed tails** of each `memory/yyyy-MM-dd.jsonl`, with bare watermark lines stripped (they are bookkeeping for the memory-logger, no signal for you)
81
81
 
82
82
  Already-consolidated content is not injected twice — once a day's stream is fully dreamed, the loader drops it from the prompt entirely.
83
83
 
@@ -86,9 +86,9 @@ If `MEMORY.md` is missing, the section shows `[MISSING] Expected at: <path>`. If
86
86
  ## What you must not do
87
87
 
88
88
  - **Do not edit `MEMORY.md` directly.** It is dreaming-owned. The default system prompt says this verbatim. If you write to `MEMORY.md` from a normal session, your edit will survive only until the next dreaming run, which rewrites the file from scratch using the consolidation logic above. The user's intent is almost never "diff-edit `MEMORY.md`" — see "When the user asks ..." below for the right routings.
89
- - **Do not write to `memory/yyyy-MM-dd.md`.** Daily streams are memory-logger's territory. The runtime reads watermarks out of these files; a hand-edit in the wrong place silently corrupts the cursor. (`memory/` is gitignored at the agent level but force-committed by the dreaming snapshot — your hand-edit there will not look untracked, but it will still be a bug.)
89
+ - **Do not write to `memory/yyyy-MM-dd.jsonl`.** Daily streams are memory-logger's territory. The runtime reads watermarks out of these files; a hand-edit in the wrong place silently corrupts the cursor. (`memory/` is gitignored at the agent level but force-committed by the dreaming snapshot — your hand-edit there will not look untracked, but it will still be a bug.)
90
90
  - **Do not write to `memory/skills/<name>/SKILL.md`.** That is the _muscle memory_ layer, owned exclusively by the dreaming subagent. The `typeclaw-skills` skill says the same thing from the skills-system angle; this skill says it from the memory angle. If you want a hand-authored skill, put it in `.agents/skills/` instead.
91
- - **Do not write to `memory/.dreaming-state.json`.** It is internal bookkeeping (per-day line counts already consolidated). On malformed input the plugin fails open with empty state, so a wrong edit causes one redundant re-consolidation, but it is still a sign you misunderstood the contract.
91
+ - **Do not write to `memory/.dreaming-state.json`.** It is internal bookkeeping (per-day dreamed-id sets). On malformed input the plugin fails open with empty state, so a wrong edit causes one redundant re-consolidation, but it is still a sign you misunderstood the contract.
92
92
  - **Do not promise the user that an `idleMs` or `dreaming.schedule` change took effect just because you edited `typeclaw.json`.** Both fields are **restart-required** — the plugin reads them once at boot, and `reload` does not re-run plugin factories. Tell the user to run `typeclaw restart` (host stage).
93
93
  - **Do not invent fragments.** If you find yourself wanting to "seed" a memory by hand, that is a symptom of the previous rules — surface the fact in your reply (so the memory-logger captures it) instead of writing to memory yourself.
94
94
  - **Do not echo `[truncated]` or `[MISSING]` markers back at the user as if they were part of remembered content.** They are runtime annotations.
@@ -96,13 +96,13 @@ If `MEMORY.md` is missing, the section shows `[MISSING] Expected at: <path>`. If
96
96
  ## When the user asks "what do you remember?"
97
97
 
98
98
  1. Read `MEMORY.md`. Summarize at the topic level — do not dump the whole file unless asked. Cite specific topics by their level-2 headings.
99
- 2. If relevant to the current task, also read the undreamed-tail of recent `memory/yyyy-MM-dd.md` files for fresh observations not yet consolidated. (Note: these are already in your prompt under `# Memory`, so usually you can just refer to them rather than re-reading.)
99
+ 2. If relevant to the current task, also read the undreamed-tail of recent `memory/yyyy-MM-dd.jsonl` files for fresh observations not yet consolidated. (Note: these are already in your prompt under `# Memory`, so usually you can just refer to them rather than re-reading.)
100
100
  3. If `MEMORY.md` is `[MISSING]` or `[EMPTY]`, say so plainly. The first dreaming run creates the file; if dreaming has never fired (e.g. no `memory.dreaming.schedule` configured, or fewer than ~24 hours since hatching), there is genuinely nothing yet.
101
101
 
102
102
  ## When the user asks "do you remember X?"
103
103
 
104
104
  1. Search `MEMORY.md` and recent daily streams for a fragment matching X.
105
- 2. If you find one: say what you found and cite the source (the topic heading from `MEMORY.md`, or the fragment line range from the daily stream).
105
+ 2. If you find one: say what you found and cite the source (the topic heading from `MEMORY.md`, or the `memory/yyyy-MM-dd#<id>` citation from the daily stream).
106
106
  3. If you do not find one: say so plainly. **Do not invent a memory** to be helpful. The honest answer is "no, that is not in my memory" — the user can then decide whether to repeat the context now (which the memory-logger will pick up) or skip it.
107
107
 
108
108
  ## When the user asks "forget X" / "remove X from your memory"
@@ -125,7 +125,7 @@ Stay concrete. Use this map:
125
125
  | File / dir | What it is | Who writes it | Tracked in git |
126
126
  | ------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
127
127
  | `MEMORY.md` | Long-term memory, consolidated topics with fragment citations. | Dreaming subagent (rewrites in full on each run). | Yes (force-committed under `dream:` commits, skip-worktree). |
128
- | `memory/yyyy-MM-dd.md` | Daily fragment streams. Append-only during the day. | Memory-logger subagent (one fragment ≈ one prompt completion). | Gitignored, but force-committed in the dreaming snapshot. |
128
+ | `memory/yyyy-MM-dd.jsonl` | Daily fragment streams. Append-only during the day. | Memory-logger subagent (one fragment ≈ one prompt completion). | Gitignored, but force-committed in the dreaming snapshot. |
129
129
  | `memory/skills/<name>/SKILL.md` | Muscle-memory skills distilled from recurring procedures. | Dreaming subagent only. | Gitignored, force-committed in the dreaming snapshot. |
130
130
  | `memory/.dreaming-state.json` | Per-day watermarks (line counts already consolidated). Plain JSON, fail-open. | Dreaming subagent. | Gitignored, force-committed in the dreaming snapshot. |
131
131
 
@@ -1,4 +1,5 @@
1
- import type { AssistantRow } from './scan'
1
+ import type { AssistantRow, OriginKind } from './scan'
2
+ import { ORIGIN_KINDS } from './scan'
2
3
 
3
4
  export type UsageTotals = {
4
5
  messageCount: number
@@ -18,13 +19,16 @@ export type SessionUsage = UsageTotals & {
18
19
  firstAt: number
19
20
  lastAt: number
20
21
  models: string[]
22
+ originKind: OriginKind
21
23
  }
24
+ export type OriginUsage = UsageTotals & { originKind: OriginKind; sessionCount: number }
22
25
 
23
26
  export type Aggregation = {
24
27
  total: UsageTotals
25
28
  byDay: DailyUsage[]
26
29
  byModel: ModelUsage[]
27
30
  bySession: SessionUsage[]
31
+ byOrigin: OriginUsage[]
28
32
  }
29
33
 
30
34
  export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggregation> {
@@ -32,6 +36,7 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
32
36
  const byDay = new Map<string, DailyUsage & { _sessionIds: Set<string> }>()
33
37
  const byModel = new Map<string, ModelUsage>()
34
38
  const bySession = new Map<string, SessionUsage & { _modelSet: Set<string> }>()
39
+ const byOrigin = new Map<OriginKind, OriginUsage & { _sessionIds: Set<string> }>()
35
40
 
36
41
  for await (const row of rows) {
37
42
  addInto(total, row)
@@ -66,6 +71,7 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
66
71
  lastAt: row.timestamp,
67
72
  models: [],
68
73
  _modelSet: new Set<string>(),
74
+ originKind: row.originKind,
69
75
  }
70
76
  addInto(sessionBucket, row)
71
77
  sessionBucket.firstAt = Math.min(sessionBucket.firstAt, row.timestamp)
@@ -73,6 +79,17 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
73
79
  sessionBucket._modelSet.add(modelKey)
74
80
  sessionBucket.models = [...sessionBucket._modelSet]
75
81
  bySession.set(sessionKey, sessionBucket)
82
+
83
+ const originBucket = byOrigin.get(row.originKind) ?? {
84
+ ...emptyTotals(),
85
+ originKind: row.originKind,
86
+ sessionCount: 0,
87
+ _sessionIds: new Set<string>(),
88
+ }
89
+ addInto(originBucket, row)
90
+ originBucket._sessionIds.add(sessionKey)
91
+ originBucket.sessionCount = originBucket._sessionIds.size
92
+ byOrigin.set(row.originKind, originBucket)
76
93
  }
77
94
 
78
95
  return {
@@ -80,9 +97,21 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
80
97
  byDay: [...byDay.values()].map(({ _sessionIds: _, ...rest }) => rest).sort((a, b) => a.date.localeCompare(b.date)),
81
98
  byModel: [...byModel.values()].sort((a, b) => b.cost - a.cost),
82
99
  bySession: [...bySession.values()].map(({ _modelSet: _, ...rest }) => rest).sort((a, b) => b.cost - a.cost),
100
+ byOrigin: [...byOrigin.values()]
101
+ .map(({ _sessionIds: _, ...rest }) => rest)
102
+ .sort((a, b) => originSortIndex(a.originKind) - originSortIndex(b.originKind)),
83
103
  }
84
104
  }
85
105
 
106
+ // Stable presentation order for the byOrigin table. Matches ORIGIN_KINDS so
107
+ // the renderer doesn't need to know about ordering. 'unknown' is pinned last
108
+ // because it represents legacy/malformed data the user probably cares about
109
+ // least.
110
+ function originSortIndex(kind: OriginKind): number {
111
+ const idx = (ORIGIN_KINDS as readonly OriginKind[]).indexOf(kind)
112
+ return idx === -1 ? Number.MAX_SAFE_INTEGER : idx
113
+ }
114
+
86
115
  function emptyTotals(): UsageTotals {
87
116
  return { messageCount: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0 }
88
117
  }
@@ -4,8 +4,9 @@ import type { Aggregation } from './aggregate'
4
4
  import { aggregate } from './aggregate'
5
5
  import { scanAssistantRows } from './scan'
6
6
 
7
- export type { Aggregation, DailyUsage, ModelUsage, SessionUsage, UsageTotals } from './aggregate'
8
- export type { AssistantRow } from './scan'
7
+ export type { Aggregation, DailyUsage, ModelUsage, OriginUsage, SessionUsage, UsageTotals } from './aggregate'
8
+ export type { AssistantRow, OriginKind } from './scan'
9
+ export { ORIGIN_KINDS } from './scan'
9
10
 
10
11
  export type UsageReport = {
11
12
  generatedAt: number
@@ -1,12 +1,13 @@
1
1
  import { styleText } from 'node:util'
2
2
 
3
- import type { ModelUsage, UsageTotals } from './aggregate'
3
+ import type { ModelUsage, OriginUsage, UsageTotals } from './aggregate'
4
4
  import { formatCacheHitRate, formatCost, formatTokens, isoDay } from './format'
5
5
  import type { UsageReport } from './index'
6
+ import type { OriginKind } from './scan'
6
7
 
7
8
  export type FormatOptions = {
8
9
  useColor?: boolean
9
- view?: 'summary' | 'daily' | 'session' | 'models'
10
+ view?: 'summary' | 'daily' | 'session' | 'models' | 'origin'
10
11
  limit?: number
11
12
  // Terminal width hint used to size the elastic Item column. Omit to render
12
13
  // without truncation (tests, piped output where columns is undefined).
@@ -26,6 +27,8 @@ export function formatReport(report: UsageReport, opts: FormatOptions = {}): str
26
27
  return renderSessions(report, ctx, opts.limit ?? 20)
27
28
  case 'models':
28
29
  return renderModels(report, ctx, opts.limit)
30
+ case 'origin':
31
+ return renderOrigin(report, ctx)
29
32
  }
30
33
  }
31
34
 
@@ -58,12 +61,20 @@ function renderSummary(report: UsageReport, ctx: RenderCtx): string {
58
61
 
59
62
  if (aggregation.byDay.length > 0) {
60
63
  sections.push('')
64
+ const trend = renderDailyTrend(aggregation.byDay, ctx)
65
+ if (trend !== null) sections.push(trend, '')
61
66
  sections.push(header('By day (most recent first)', ctx))
62
67
  const recent = aggregation.byDay.slice(-7).reverse()
63
68
  const dayRows = recent.map((d) => ({ label: d.date, totals: d as UsageTotals }))
64
69
  sections.push(renderTotalsTable(dayRows, ctx, { total: totalOfRows(dayRows) }))
65
70
  }
66
71
 
72
+ if (aggregation.byOrigin.length > 0) {
73
+ sections.push('')
74
+ sections.push(header('By origin', ctx))
75
+ sections.push(renderOriginTable(aggregation.byOrigin, ctx))
76
+ }
77
+
67
78
  if (aggregation.byModel.length > 0) {
68
79
  sections.push('')
69
80
  sections.push(header('By model', ctx))
@@ -84,6 +95,79 @@ function renderSummary(report: UsageReport, ctx: RenderCtx): string {
84
95
  return sections.join('\n')
85
96
  }
86
97
 
98
+ function renderOrigin(report: UsageReport, ctx: RenderCtx): string {
99
+ const sections: string[] = [sectionTitle('USAGE BY ORIGIN', report.agentDir, ctx)]
100
+ if (report.aggregation.byOrigin.length === 0) {
101
+ sections.push(dim('No assistant turns recorded yet.', ctx))
102
+ return sections.join('\n')
103
+ }
104
+ sections.push(renderOriginTable(report.aggregation.byOrigin, ctx))
105
+ return sections.join('\n')
106
+ }
107
+
108
+ // Single source of truth for the origin breakdown table used by both the
109
+ // summary view and the dedicated `usage origin` subcommand. Matches the
110
+ // column shape of renderTotalsTable's other callers (byDay, byModel) plus
111
+ // one extra column for session count — deliberately plain numbers rather
112
+ // than a proportional bar, since the Cost column already lets the eye sort
113
+ // rows by spend and an extra "share of max" bar adds no information that
114
+ // isn't already in the report.
115
+ function renderOriginTable(byOrigin: readonly OriginUsage[], ctx: RenderCtx): string {
116
+ const rows = byOrigin.map((o) => ({
117
+ label: renderOriginLabel(o.originKind, ctx),
118
+ totals: o as UsageTotals,
119
+ extra: String(o.sessionCount),
120
+ extraTruncatable: false,
121
+ }))
122
+ return renderTotalsTable(rows, ctx, {
123
+ extraHeader: 'Sessions',
124
+ total: totalOfRows(rows),
125
+ })
126
+ }
127
+
128
+ // Glyph + colored label for each origin kind. The glyphs are chosen to be
129
+ // instantly readable in a dense table: ▶ for TUI (interactive), ⏱ for cron
130
+ // (time-triggered), # for channel (chat-room sigil), ↳ for subagent (child),
131
+ // ? for unknown. ASCII fallbacks are not provided — the codebase already
132
+ // requires unicode for `…` `●` `✓` etc. on other commands.
133
+ function renderOriginLabel(kind: OriginKind, ctx: RenderCtx): string {
134
+ switch (kind) {
135
+ case 'tui':
136
+ return `${color('cyan', '▶', ctx)} ${'tui'}`
137
+ case 'cron':
138
+ return `${color('magenta', '⏱', ctx)} ${'cron'}`
139
+ case 'channel':
140
+ return `${color('green', '#', ctx)} ${'channel'}`
141
+ case 'subagent':
142
+ return `${color('yellow', '↳', ctx)} ${'subagent'}`
143
+ case 'unknown':
144
+ return `${dim('?', ctx)} ${dim('unknown', ctx)}`
145
+ }
146
+ }
147
+
148
+ // Sparkline trend across the full byDay range, scaled to the row's max cost.
149
+ // Returns null when there are fewer than 2 days (a single point conveys no
150
+ // trend information). The 8-level Unicode block scale `▁▂▃▄▅▆▇█` lets us
151
+ // pack ~80 days into a one-line glance — wider than any table-based view
152
+ // could fit at terminal widths under ~160 columns.
153
+ const SPARK_GLYPHS = '▁▂▃▄▅▆▇█'
154
+
155
+ function renderDailyTrend(byDay: readonly { date: string; cost: number }[], ctx: RenderCtx): string | null {
156
+ if (byDay.length < 2) return null
157
+ const costs = byDay.map((d) => d.cost)
158
+ const max = costs.reduce((m, c) => Math.max(m, c), 0)
159
+ if (max <= 0) return null
160
+ const spark = costs
161
+ .map((c) => {
162
+ const idx = Math.min(SPARK_GLYPHS.length - 1, Math.max(0, Math.round((c / max) * (SPARK_GLYPHS.length - 1))))
163
+ return SPARK_GLYPHS[idx]!
164
+ })
165
+ .join('')
166
+ const first = byDay[0]!.date
167
+ const last = byDay[byDay.length - 1]!.date
168
+ return `${dim('Trend (cost):', ctx)} ${color('cyan', spark, ctx)} ${dim(`${first} → ${last}`, ctx)}`
169
+ }
170
+
87
171
  function renderDaily(report: UsageReport, ctx: RenderCtx, limit: number | undefined): string {
88
172
  const days = limit !== undefined ? report.aggregation.byDay.slice(-limit) : report.aggregation.byDay
89
173
  if (days.length === 0) return dim('No usage in range.', ctx)
@@ -100,8 +184,9 @@ function renderSessions(report: UsageReport, ctx: RenderCtx, limit: number): str
100
184
  const rows = sessions.map((s) => {
101
185
  const firstModel = s.models[0]
102
186
  const extra = s.models.length > 1 ? `${s.models.length} models` : modelIdFromKey(firstModel)
187
+ const originGlyph = originGlyphOnly(s.originKind, ctx)
103
188
  return {
104
- label: `${color('magenta', s.sessionId.slice(0, 12), ctx)} ${dim(isoDay(s.firstAt), ctx)}`,
189
+ label: `${originGlyph} ${color('magenta', s.sessionId.slice(0, 12), ctx)} ${dim(isoDay(s.firstAt), ctx)}`,
105
190
  totals: s as UsageTotals,
106
191
  extra,
107
192
  extraTruncatable: s.models.length === 1,
@@ -113,6 +198,21 @@ function renderSessions(report: UsageReport, ctx: RenderCtx, limit: number): str
113
198
  ].join('\n')
114
199
  }
115
200
 
201
+ function originGlyphOnly(kind: OriginKind, ctx: RenderCtx): string {
202
+ switch (kind) {
203
+ case 'tui':
204
+ return color('cyan', '▶', ctx)
205
+ case 'cron':
206
+ return color('magenta', '⏱', ctx)
207
+ case 'channel':
208
+ return color('green', '#', ctx)
209
+ case 'subagent':
210
+ return color('yellow', '↳', ctx)
211
+ case 'unknown':
212
+ return dim('?', ctx)
213
+ }
214
+ }
215
+
116
216
  function renderModels(report: UsageReport, ctx: RenderCtx, limit: number | undefined): string {
117
217
  const models = limit !== undefined ? report.aggregation.byModel.slice(0, limit) : report.aggregation.byModel
118
218
  if (models.length === 0) return dim('No models in range.', ctx)
package/src/usage/scan.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { readdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
 
4
+ // Recognised origin kinds. Keep aligned with SessionOrigin's discriminator in
5
+ // src/agent/session-origin.ts. The 'unknown' bucket catches sessions written
6
+ // before origin stamping landed AND sessions whose session-meta line is
7
+ // malformed or missing — surfacing them under one explicit label is more
8
+ // honest than silently dropping them.
9
+ export const ORIGIN_KINDS = ['tui', 'cron', 'channel', 'subagent', 'unknown'] as const
10
+ export type OriginKind = (typeof ORIGIN_KINDS)[number]
11
+
4
12
  // Narrow projection: session files can grow into tens of MB on long-lived
5
13
  // agents, so we deliberately drop content/tool blocks before aggregation.
6
14
  export type AssistantRow = {
@@ -15,6 +23,7 @@ export type AssistantRow = {
15
23
  cacheWrite: number
16
24
  totalTokens: number
17
25
  cost: number
26
+ originKind: OriginKind
18
27
  }
19
28
 
20
29
  export type ScanOptions = {
@@ -66,6 +75,13 @@ async function* readSessionFile(file: string, opts: ScanOptions): AsyncGenerator
66
75
  }
67
76
  const decoder = new TextDecoder()
68
77
  let buf = ''
78
+ // First-stamp-wins per file. Once a `typeclaw.session-meta` custom entry
79
+ // pins the origin, later entries with the same customType are ignored —
80
+ // session-resume code paths may legitimately re-stamp on reopen, and the
81
+ // earliest one is the authoritative one for the session's first turn.
82
+ // Stays 'unknown' for legacy files (no stamp at all) so usage attribution
83
+ // surfaces them as a distinct bucket rather than dropping the rows.
84
+ const ctx: ParseCtx = { originKind: 'unknown', originPinned: false }
69
85
  try {
70
86
  for await (const chunk of stream) {
71
87
  buf += decoder.decode(chunk, { stream: true })
@@ -73,7 +89,7 @@ async function* readSessionFile(file: string, opts: ScanOptions): AsyncGenerator
73
89
  while (nl !== -1) {
74
90
  const line = buf.slice(0, nl)
75
91
  buf = buf.slice(nl + 1)
76
- const row = parseLine(line, file, basename, opts)
92
+ const row = parseLine(line, file, basename, opts, ctx)
77
93
  if (row !== null) yield row
78
94
  nl = buf.indexOf('\n')
79
95
  }
@@ -86,17 +102,20 @@ async function* readSessionFile(file: string, opts: ScanOptions): AsyncGenerator
86
102
  // half-written record from a live writer is silently skipped (parseLine
87
103
  // returns null and does NOT warn for the tail).
88
104
  if (buf.length > 0) {
89
- const row = parseLine(buf, file, basename, opts, { isTail: true })
105
+ const row = parseLine(buf, file, basename, opts, ctx, { isTail: true })
90
106
  if (row !== null) yield row
91
107
  }
92
108
  }
93
109
 
110
+ type ParseCtx = { originKind: OriginKind; originPinned: boolean }
111
+
94
112
  function parseLine(
95
113
  line: string,
96
114
  file: string,
97
115
  basename: string,
98
116
  opts: ScanOptions,
99
- ctx: { isTail?: boolean } = {},
117
+ ctx: ParseCtx,
118
+ flags: { isTail?: boolean } = {},
100
119
  ): AssistantRow | null {
101
120
  const trimmed = line.trim()
102
121
  if (trimmed === '') return null
@@ -106,7 +125,18 @@ function parseLine(
106
125
  entry = JSON.parse(trimmed)
107
126
  } catch {
108
127
  // Silently skip the trailing tail: a live writer may be mid-append.
109
- if (ctx.isTail !== true) opts.onWarn?.(`skipping malformed JSONL line in ${basename}`)
128
+ if (flags.isTail !== true) opts.onWarn?.(`skipping malformed JSONL line in ${basename}`)
129
+ return null
130
+ }
131
+
132
+ if (isSessionMetaCustomEntry(entry)) {
133
+ if (!ctx.originPinned) {
134
+ const kind = entry.data.origin.kind
135
+ if ((ORIGIN_KINDS as readonly string[]).includes(kind)) {
136
+ ctx.originKind = kind as OriginKind
137
+ ctx.originPinned = true
138
+ }
139
+ }
110
140
  return null
111
141
  }
112
142
 
@@ -134,9 +164,34 @@ function parseLine(
134
164
  cacheWrite: numberOrZero(u.cacheWrite),
135
165
  totalTokens: numberOrZero(u.totalTokens),
136
166
  cost: numberOrZero(u.cost?.total),
167
+ originKind: ctx.originKind,
137
168
  }
138
169
  }
139
170
 
171
+ // Pi-coding-agent persists `appendCustomEntry(customType, data)` calls as
172
+ // `{type:"custom", customType, data, id, parentId, timestamp}` lines. We
173
+ // stamp our origin block with customType `typeclaw.session-meta` (constant
174
+ // kept in src/agent/session-meta.ts; duplicated as a literal here to keep
175
+ // the usage subsystem free of agent-stack imports — a Grep across the repo
176
+ // is the chosen drift guard).
177
+ type SessionMetaCustomEntry = {
178
+ type: 'custom'
179
+ customType: 'typeclaw.session-meta'
180
+ data: { origin: { kind: string } }
181
+ }
182
+
183
+ function isSessionMetaCustomEntry(value: unknown): value is SessionMetaCustomEntry {
184
+ if (typeof value !== 'object' || value === null) return false
185
+ const v = value as Record<string, unknown>
186
+ if (v.type !== 'custom') return false
187
+ if (v.customType !== 'typeclaw.session-meta') return false
188
+ if (typeof v.data !== 'object' || v.data === null) return false
189
+ const d = v.data as Record<string, unknown>
190
+ if (typeof d.origin !== 'object' || d.origin === null) return false
191
+ const o = d.origin as Record<string, unknown>
192
+ return typeof o.kind === 'string'
193
+ }
194
+
140
195
  type MessageEntry = { type: 'message'; message: { role: string; [k: string]: unknown } }
141
196
  type AssistantMessageShape = {
142
197
  role: 'assistant'