Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes to GBrain will be documented in this file.

## [0.10.2] - 2026-04-16

### Added

- **`gbrain action run` — cron-ready auto-ingest pipeline.** One command reads new WhatsApp messages from the wacli store, extracts commitments with the LLM extractor, and stores them in Action Brain. Checkpoint-aware: skips already-processed messages. Staleness gate: bails out if wacli data is older than `--stale-after-hours` (default 24h). Returns structured JSON with counts and errors — CI/cron-friendly.
- **Morning brief is now checkpoint-aware.** `gbrain action brief` auto-reads the wacli checkpoint to compute message freshness — no more manually passing `--last-sync-at`. Pass `--checkpoint-path` to override the default location.
- **Action item creation now returns idempotency signal.** `createItemWithResult()` tells callers whether an item was freshly inserted or already existed — so the ingest pipeline can report accurate created/skipped counts without extra DB queries.

## [0.10.1] - 2026-04-16

### Added

- **Wacli collector with checkpoint store.** `collector.ts` reads your WhatsApp export files from the wacli local store and maintains a checkpoint so the ingest pipeline only processes new messages since the last run. Deterministic parsing, dedup-safe, and cron-friendly — no duplicate ingestion on re-runs.

## [0.10.0] - 2026-04-16

### Added
Expand Down
24 changes: 16 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,12 @@ markdown files (tool-agnostic, work with both CLI and plugin contexts).
- `openclaw.plugin.json` — ClawHub bundle plugin manifest
- `src/action-brain/types.ts` — Action Brain shared types (ActionItem, CommitmentBatch, ExtractionResult)
- `src/action-brain/action-schema.ts` — PGLite DDL + idempotent schema init for action_items / action_history tables
- `src/action-brain/action-engine.ts` — Storage layer: CRUD, priority scoring (urgency × confidence × recency), PGLite lifecycle
- `src/action-brain/extractor.ts` — LLM commitment extraction (two-tier Haiku→Sonnet), XML delimiter defense, stable source IDs
- `src/action-brain/brief.ts` — Morning priority brief generator: ranked action items, overdue detection, deduplication
- `src/action-brain/operations.ts` — 5 Action Brain operations (action_list, action_brief, action_resolve, action_mark_fp, action_ingest)
- `src/action-brain/action-engine.ts` — Storage layer: CRUD, priority scoring (urgency × confidence × recency), PGLite lifecycle; `createItemWithResult()` returns idempotency signal (created vs skipped)
- `src/action-brain/extractor.ts` — LLM commitment extraction (two-tier Haiku→Sonnet), XML delimiter defense, stable source IDs, owner context injection
- `src/action-brain/brief.ts` — Morning priority brief generator: ranked action items, overdue detection, deduplication; freshness reads from wacli checkpoint (not action item creation time)
- `src/action-brain/collector.ts` — Wacli message collector: reads WhatsApp export files, deduplicates by message ID, checkpoint-aware (skips already-processed messages)
- `src/action-brain/ingest-runner.ts` — Auto-ingest orchestrator: preflight checks, staleness gate, collect → extract → store pipeline; cron-ready, returns structured JSON
- `src/action-brain/operations.ts` — 6 Action Brain operations (action_list, action_brief, action_resolve, action_mark_fp, action_ingest, action_ingest_auto)

## Commands

Expand All @@ -78,7 +80,7 @@ Key commands added in v0.7:

## Testing

`bun test` runs all tests (33 unit test files + 5 E2E test files). Unit tests run
`bun test` runs all tests (43 unit test files + 6 E2E test files). Unit tests run
without a database. E2E tests skip gracefully when `DATABASE_URL` is not set.

Unit tests: `test/markdown.test.ts` (frontmatter parsing), `test/chunkers/recursive.test.ts`
Expand All @@ -104,9 +106,15 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/eval.test.ts` (retrieval metrics: precisionAtK, recallAtK, mrr, ndcgAtK, parseQrels),
`test/action-brain/action-schema.test.ts` (Action Brain DDL + idempotent init),
`test/action-brain/action-engine.test.ts` (CRUD, scoring, PGLite lifecycle),
`test/action-brain/extractor.test.ts` (extraction, source ID stability, injection defense, timestamp bounds),
`test/action-brain/brief.test.ts` (brief generation, scoring, dedup, overdue detection),
`test/action-brain/operations.test.ts` (all 5 ops, ingest trust boundary, batch fallbacks).
`test/action-brain/extractor.test.ts` (extraction, source ID stability, injection defense, timestamp bounds, owner context),
`test/action-brain/brief.test.ts` (brief generation, scoring, dedup, overdue detection, checkpoint-aware freshness),
`test/action-brain/collector.test.ts` (wacli file reading, checkpoint store, dedup, freshness filtering, fail-closed on invalid checkpoint, FIFO cap on same-second IDs, collapsed health alert for global checkpoint failure),
`test/action-brain/ingest-runner.test.ts` (preflight checks, staleness gate, collect/extract/store pipeline, structured JSON output),
`test/action-brain/operations.test.ts` (all 6 ops, ingest trust boundary, batch fallbacks, action_ingest_auto pipeline),
`test/embed.test.ts` (embedding interface contract),
`test/import-walker.test.ts` (directory walker, symlink handling, file filtering),
`test/pglite-lock.test.ts` (PGLite concurrent access and lock behavior),
`test/search-limit.test.ts` (MAX_SEARCH_LIMIT constant, clampSearchLimit bounds).

E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`.
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys)
Expand Down
8 changes: 5 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ src/
action-brain/ Action Brain extension (commitment/obligation tracking)
types.ts Shared types (ActionItem, CommitmentBatch, ExtractionResult)
action-schema.ts PGLite DDL + schema init for action_items/action_history tables
action-engine.ts Storage layer: CRUD, priority scoring, PGLite lifecycle
action-engine.ts Storage layer: CRUD, priority scoring, PGLite lifecycle; createItemWithResult() for idempotency signal
extractor.ts LLM commitment extraction with prompt injection defense
brief.ts Morning priority brief generator (ranked + deduped)
operations.ts 5 registered ops: action_list/brief/resolve/mark-fp/ingest
brief.ts Morning priority brief generator (ranked + deduped, checkpoint-aware)
collector.ts Wacli message collector: checkpoint-aware cursor, dedup by message ID, FIFO cap, fail-closed on bad checkpoint
ingest-runner.ts Auto-ingest orchestrator: preflight checks, staleness gate, collect→extract→store pipeline, structured JSON
operations.ts 6 registered ops: action_list, action_brief, action_resolve, action_mark_fp, action_ingest, action_ingest_auto
schema.sql Postgres DDL
skills/ Fat markdown skills for AI agents
test/ Unit tests (bun test, no DB required)
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.10.0
0.10.2
66 changes: 54 additions & 12 deletions src/action-brain/action-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface QueryResult<T> {

interface ActionDb {
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<QueryResult<T>>;
transaction?<T>(fn: (db: ActionDb) => Promise<T>): Promise<T>;
}

interface ActionItemRow {
Expand Down Expand Up @@ -54,6 +55,15 @@ export interface ActionMutationOptions {
metadata?: Record<string, unknown>;
}

export interface CreateActionItemResult {
item: ActionItem;
created: boolean;
}

interface ActionExecutionOptions {
useTransaction?: boolean;
}

export interface ListActionItemsFilters {
status?: ActionStatus;
owner?: string;
Expand All @@ -79,9 +89,25 @@ export class ActionTransitionError extends Error {
export class ActionEngine {
constructor(private readonly db: ActionDb) {}

async transaction<T>(fn: (engine: ActionEngine) => Promise<T>): Promise<T> {
return this.withTransaction(async (txDb) => {
const txEngine = txDb === this.db ? this : new ActionEngine(txDb);
return fn(txEngine);
});
}

async createItem(input: CreateActionItemInput, options: ActionMutationOptions = {}): Promise<ActionItem> {
return this.withTransaction(async () => {
const result = await this.db.query<ActionInsertRow>(
const result = await this.createItemWithResult(input, options);
return result.item;
}

async createItemWithResult(
input: CreateActionItemInput,
options: ActionMutationOptions = {},
execution: ActionExecutionOptions = {}
): Promise<CreateActionItemResult> {
const execute = async (db: ActionDb) => {
const result = await db.query<ActionInsertRow>(
`WITH inserted AS (
INSERT INTO action_items (
title,
Expand Down Expand Up @@ -132,6 +158,7 @@ export class ActionEngine {

if (toBoolean(row.was_inserted)) {
await this.insertHistory(
db,
row.id,
'created',
options.actor ?? 'system',
Expand All @@ -142,8 +169,17 @@ export class ActionEngine {
);
}

return mapActionItem(row);
});
return {
item: mapActionItem(row),
created: toBoolean(row.was_inserted),
};
};

if (execution.useTransaction === false) {
return execute(this.db);
}

return this.withTransaction(execute);
}

async getItem(id: number): Promise<ActionItem | null> {
Expand Down Expand Up @@ -212,11 +248,11 @@ export class ActionEngine {
nextStatus: ActionStatus,
options: ActionMutationOptions = {}
): Promise<ActionItem> {
return this.withTransaction(async () => {
const currentRow = await this.lockItemById(id);
return this.withTransaction(async (db) => {
const currentRow = await this.lockItemById(db, id);
validateTransition(id, currentRow.status, nextStatus);

const updateResult = await this.db.query<ActionItemRow>(
const updateResult = await db.query<ActionItemRow>(
`UPDATE action_items
SET status = $2,
resolved_at = CASE WHEN $2 = 'resolved' THEN now() ELSE NULL END,
Expand All @@ -235,6 +271,7 @@ export class ActionEngine {
nextStatus === 'resolved' ? 'resolved' : nextStatus === 'dropped' ? 'dropped' : 'status_change';

await this.insertHistory(
db,
id,
eventType,
options.actor ?? 'system',
Expand All @@ -253,8 +290,8 @@ export class ActionEngine {
return this.updateItemStatus(id, 'resolved', options);
}

private async lockItemById(id: number): Promise<ActionItemRow> {
const rowResult = await this.db.query<ActionItemRow>(
private async lockItemById(db: ActionDb, id: number): Promise<ActionItemRow> {
const rowResult = await db.query<ActionItemRow>(
`SELECT *
FROM action_items
WHERE id = $1
Expand All @@ -271,22 +308,27 @@ export class ActionEngine {
}

private async insertHistory(
db: ActionDb,
itemId: number,
eventType: ActionHistoryEventType,
actor: string,
metadata: Record<string, unknown>
): Promise<void> {
await this.db.query(
await db.query(
`INSERT INTO action_history (item_id, event_type, actor, metadata)
VALUES ($1, $2, $3, $4::jsonb)`,
[itemId, eventType, actor, JSON.stringify(metadata)]
);
}

private async withTransaction<T>(fn: () => Promise<T>): Promise<T> {
private async withTransaction<T>(fn: (db: ActionDb) => Promise<T>): Promise<T> {
if (typeof this.db.transaction === 'function') {
return this.db.transaction(async (txDb) => fn(txDb ?? this.db));
}

await this.db.query('BEGIN');
try {
const result = await fn();
const result = await fn(this.db);
await this.db.query('COMMIT');
return result;
} catch (error) {
Expand Down
Loading