Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ shlog read-page <sessionRef> --offset 0 --limit 20

If `find` prints `next:` or JSON includes `nextAction`, refresh the suggested coverage and retry before treating the results as complete. Codex active-session tail drift is softer: `status` may report `freshness: "stale"` with `staleReason: "source_content_changed"` and `recommendedAction: "query"` when an existing JSONL is still growing; query/read first, and sync only when the latest tail or a strict completeness claim matters.

The same distinction applies during `sync`: a Codex JSONL that only appends after its bounded read no longer aborts the whole run. Stable sources and the bounded prefix are committed, the successful sync summary marks `coverage.staleReason: "source_content_changed"`, and the next sync fills the tail. Truncation, prefix rewrite/replacement, and source-set changes still fail strict sync.

If a new, unindexed Codex file already changed before its bounded read opened, Sherlog cannot prove that the old snapshot prefix was not rewritten. It conservatively defers that file and complete coverage, commits other stable sources, and returns `coverage.reason: "active_source_deferred"` with `recommendedAction: "sync"`.

For project-scoped agent work, check and refresh only that coverage:

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Codex adapter 会把原有 `sessionUuid` 映射为 source-aware identity:

[indexer.ts](/Users/envvar/work/repos/cxs/src/indexer.ts) 按显式 selector 扫描选定 source 的 session snapshot。当前公开 source 可以是 Codex、Claude Code 或 Pi;增量判断仍基于文件 `mtime`、`size` 和 `indexVersion`。

strict sync 默认只更新当前 source snapshot 中仍可见的文件,并保留已经进入 SQLite 的旧 session。这样 raw JSONL 的维护、移动或删除不会让 Sherlog 的历史查询丢失。只有显式传 `--prune` 时,sync 才会把 selector 范围收敛成当前 source snapshot,并删除同一 source 中已不存在的旧 index row。一个 source 的 sync/prune 不会删除另一个 source 的数据。当前 source 中仍存在但被过滤或不能解析成 session 的文件仍按当前状态处理。
strict sync 默认只更新当前 source snapshot 中仍可见的文件,并保留已经进入 SQLite 的旧 session。Codex adapter 的读取固定在本轮捕获的 byte 边界;若相同 file set 中某个活跃 JSONL 在读后只追加,indexer 校验已读前缀摘要与既有投影后允许提交起始边界,并把 coverage 标为 `source_content_changed` soft stale。尚未索引的新文件若在 parser 打开前已变化,无法证明 snapshot 前缀安全,indexer 会延后该文件和 complete coverage(`active_source_deferred`),但在同一事务提交其他稳定 operation。截断、可证明的前缀改写/替换、file set 变化和其他 source 的中途变化仍阻断事务。这样既不会因当前对话增长阻塞稳定 source,也不会把未读或未证明的尾部发布成 fresh coverage。只有显式传 `--prune` 时,sync 才会把 selector 范围收敛成当前 source snapshot,并删除同一 source 中已不存在的旧 index row。一个 source 的 sync/prune 不会删除另一个 source 的数据。当前 source 中仍存在但被过滤或不能解析成 session 的文件仍按当前状态处理。

[parser.ts](/Users/envvar/work/repos/cxs/src/parser.ts) 只抽取 `event_msg` 里的:

Expand Down
2 changes: 2 additions & 0 deletions docs/INDEX_COVERAGE_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ Coverage 可以蕴含更窄 selector。
- 不使用 source inventory 作为召回来源
- coverage missing 或 source file 集合变化时即使已有结果也必须返回 `nextAction`,避免 agent 把旧索引结果当完整历史结论
- coverage 仅因既有 source file 内容变化而 stale 时归类为 `source_content_changed`;该判断必须基于 source file set fingerprint / path set,而不是 file count。Codex 当前会话 JSONL 尾部追加可把它降级为 `recommendedAction: "query"` 的软 stale,非空 `find` 不应强制 agent 每次同步。其他 source 仍可保守推荐 `sync`。
- strict sync 读取 Codex 文件时固定本轮 byte 边界。相同 file set 中经前缀摘要与既有索引投影证明的纯追加可以提交该边界,并在成功摘要中返回 `coverage.staleReason: "source_content_changed"` / `recommendedAction: "query"`;未读尾部不进入本轮 coverage。截断、前缀改写、替换、file set 变化和非 Codex 中途变化仍失败。
- 尚未索引的新 Codex 文件若在 parser 建立有界读取前已变化,旧 snapshot 前缀无法自证;本轮延后该文件且不写 complete coverage,成功摘要返回 `coverage.reason: "active_source_deferred"` / `recommendedAction: "sync"`,其他稳定 operation 仍原子提交。

### list

Expand Down
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ By default, source-scoped commands read Codex sessions from `~/.codex/sessions`.
export SHLOG_DATA_DIR="$HOME/.config/shlog"
```

Sync is strict by default. If any selected file fails to parse or write, `sync` exits non-zero with per-file diagnostics and does not commit partial coverage. On success, strict sync updates the selected index slice for files that are currently visible in the source snapshot and writes complete coverage for that snapshot. Previously indexed sessions whose source JSONL later disappears are retained by default, so raw log maintenance does not make historical `shlog find` or `read-*` results disappear.
Sync is strict by default. If any selected file fails to parse or write, `sync` exits non-zero with per-file diagnostics and does not commit partial coverage. Codex active-session append is the narrow exception to the old “source must stay byte-for-byte static” rule: each Codex file is read only through the byte boundary captured for the sync, and a verified append after that boundary does not fail the command. Stable sources and the bounded active prefix are committed together; JSON reports `coverage.staleReason: "source_content_changed"` and `recommendedAction: "query"`, and a later sync fills the tail. If an unindexed Codex file already changed before its bounded read opened, sync cannot prove that the old prefix was append-only; it defers that file and complete coverage while committing other stable operations, and reports `coverage.reason: "active_source_deferred"` with `recommendedAction: "sync"`. Truncation, verified prefix rewrite/replacement, source-file-set changes, and mid-sync changes from other sources remain strict failures. Previously indexed sessions whose source JSONL later disappears are retained by default, so raw log maintenance does not make historical `shlog find` or `read-*` results disappear.
Pass `--prune` only when you explicitly want to delete indexed sessions that are no longer present in the selected source snapshot.
Pass `--best-effort` only when you explicitly want successful files written despite failures; best-effort sync does not record complete coverage.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@act0r/sherlog",
"version": "0.3.17",
"version": "0.3.18",
"type": "module",
"description": "Progressive search CLI for local Codex, Claude Code, and Pi session logs",
"license": "MIT",
Expand Down
4 changes: 3 additions & 1 deletion skill-packages/sherlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ description: "Use proactively for local Codex history and personal setup archaeo
- `index_unavailable`: 普通首次安装可 `sync`;明确项目范围优先 `sync --cwd <path>`。
- `session_not_found`: 只说明当前 index 没有这个 `sessionRef`;按 `nextAction` 检查 source/id/coverage,必要时同 source scoped sync 后重试。
- `stale_or_missing_coverage`: 先判断是否需要完整结论。Codex `source_content_changed` + `recommendedAction: "query"` 常是活跃尾部软 stale,可先 query/read;coverage 缺失、source set 变化、非 Codex 保守同步、零结果可疑或用户要求完整性时,按提示同范围 sync 后重试。
- `sync` 成功但 `coverage.staleReason: "source_content_changed"`: Codex 活跃 JSONL 在读取后继续追加;已读边界和其他稳定 source 已安全落库,可继续 query/read,稍后再 sync 补尾部。截断、前缀改写和 source set 变化仍是失败,不要把它们当成同一类软 stale。
- `sync` 成功但 `coverage.reason: "active_source_deferred"`: 尚未索引的新 Codex 文件在有界读取前已变化,无法证明是纯追加;该文件和 complete coverage 被保守延后,其他稳定 source 已落库。按 `recommendedAction: "sync"` 重试,不要把本轮结果当完整覆盖。
- fresh coverage 下仍无结果,才说没找到。

## 不适用
Expand All @@ -66,4 +68,4 @@ description: "Use proactively for local Codex history and personal setup archaeo
- `references/advanced-queries.md`: metadata SQLite projection、CJK/query 语义、缩范围策略。
- `references/json-schema.md`: 需要解析完整 JSON 字段或 error shape。

# skill-sync: distributable sherlog skill package, compressed entrypoint, 2026-06-22
# skill-sync: distributable sherlog skill package, compressed entrypoint, 2026-07-12
2 changes: 1 addition & 1 deletion skill-packages/sherlog/references/cli-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Options:
| `--prune` | 显式删除所选 source 中已经消失的旧索引记录 |
| `--json` | 成功时把 `SyncSummary` 打到 stdout |

严格模式成功时,`sync` 会更新当前 source snapshot 中仍可见的文件,并写 complete coverage。默认保留已经索引过、但 raw JSONL 后来从 source 中消失的旧 session;查询时不需要切换 root 去追 raw 文件位置。
严格模式成功时,`sync` 会更新当前 source snapshot 中仍可见的文件,并写 coverage。Codex JSONL 若在读取后仅继续追加,命令仍成功:coverage 对应已读 byte 边界,并返回 `staleReason: "source_content_changed"`、`recommendedAction: "query"`;其他稳定 source 同时落库,后续 sync 再补活跃尾部。尚未索引的新 Codex 文件若在有界读取前已变化、无法证明前缀安全,本轮会保守延后该文件和 coverage,成功摘要返回 `reason: "active_source_deferred"`、`recommendedAction: "sync"`,其他稳定 source 仍落库。截断、可证明的前缀改写、source file set 变化以及非 Codex source 的同步中变化仍保持严格失败。默认保留已经索引过、但 raw JSONL 后来从 source 中消失的旧 session;查询时不需要切换 root 去追 raw 文件位置。

只有显式传 `--prune` 时,`sync` 才把 selector 范围内的 index 与当前 source snapshot 对齐;源文件已删除的旧 row 会被移除,并计入 `removed`。源文件仍存在但被过滤或不再能解析成 session 时,仍按当前文件状态删除或报错。

Expand Down
2 changes: 2 additions & 0 deletions skill-packages/sherlog/references/failure-cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ sqlite3 -readonly "$DB_PATH" \
| 旧安装版 `sync` 缺 selector | stdout | `{ "error": { "code": "selector_required", "message": "..." } }` |
| `sync` invalid selector | stdout | `{ "error": { "code": "invalid_selector", "message": "..." } }` |
| `sync` per-file 错 | stderr | `SyncSummary`,看 `errors / errorDetails[]` |
| `sync` Codex 活跃尾部软 stale | stdout | 成功的 `SyncSummary`;`coverage.written=true`、`staleReason=source_content_changed`、`recommendedAction=query`,稍后重试补尾部 |
| `sync` Codex 新活跃文件读取前已变化 | stdout | 成功的 `SyncSummary`;`coverage.written=false`、`reason=active_source_deferred`、`recommendedAction=sync`,稳定 source 已落库,重试补该文件 |
| `sync` 锁超时 | stderr | `{ "error": <message string> }` |
| `status` invalid selector | stdout | `{ "error": { "code": "invalid_selector", "message": "..." } }` |
| `find / read-range / read-page / list / stats` 索引不存在 | stdout | `{ "error": { "code": "index_unavailable", "message": "...", "dbPath": "...", "hint": "...", "nextAction": { "kind": "bootstrap_index", "commands": [...] } } }` |
Expand Down
6 changes: 6 additions & 0 deletions skill-packages/sherlog/references/json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,16 @@ errors in `--json` mode for expected index setup and read failures:
sourceFileCount: number;
indexedSessionCount: number;
reason?: string;
staleReason?: "source_content_changed";
recommendedAction?: "query" | "sync";
};
}
```

当 Codex JSONL 在 strict sync 读取其起始 byte 边界后仅继续追加时,sync 仍以成功结束,稳定 source 和已读前缀一起提交;`coverage.staleReason` 标记当前活跃尾部尚未包含,`recommendedAction: "query"` 表示现有 index 可查询,稍后再 sync 可补齐尾部。截断、前缀摘要不一致、同大小替换或 source file set 变化不会得到这两个字段,而是继续走非零失败与 `errorDetails`。

若尚未索引的新 Codex 文件在 parser 建立有界读取前已经变化,sync 无法证明旧 snapshot 前缀未被改写,因此不写该文件,也不写 complete coverage;其他稳定 operation 仍在同一事务提交。此时 `coverage.written=false`、`reason="active_source_deferred"`、`recommendedAction="sync"`,下一次 sync 再补该文件。

## Shared Records

`SessionRecord`:
Expand Down
9 changes: 8 additions & 1 deletion src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ export {
withReadDb,
withSourceAwareReadDb,
} from "./db/connection";
export { getIndexedSessionMeta, getIndexedSessionMetas, deleteSessionByFilePath, replaceSession, getSessionRecord } from "./db/session-store";
export {
getIndexedSessionMeta,
getIndexedSessionMetas,
getIndexedSessionProjection,
deleteSessionByFilePath,
replaceSession,
getSessionRecord,
} from "./db/session-store";
export { getMessagesForPage, getMessagesForRange } from "./db/message-store";
export { listSessions } from "./db/list-store";
export { getStatsCounts, getTopCwds } from "./db/stats-store";
Expand Down
38 changes: 37 additions & 1 deletion src/db/session-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { tokenizedText } from "../tokenize";
import { DEFAULT_SESSION_SOURCE_ID, isSessionSourceId, type ParsedSession, type SessionRecord, type SessionSourceId } from "../types";
import { DEFAULT_SESSION_SOURCE_ID, isSessionSourceId, type ParsedMessage, type ParsedSession, type SessionRecord, type SessionSourceId } from "../types";
import type { Db } from "./shared";
import { sessionRootFromFile } from "./sql";

Expand Down Expand Up @@ -54,6 +54,42 @@ export function getIndexedSessionMetas(
return map;
}

export interface IndexedSessionProjection {
sessionUuid: string;
title: string;
summaryText: string;
compactText: string;
reasoningSummaryText: string;
cwd: string;
startedAt: string;
endedAt: string;
messages: ParsedMessage[];
}

export function getIndexedSessionProjection(
db: Db,
filePath: string,
sourceId: SessionSourceId = DEFAULT_SESSION_SOURCE_ID,
): IndexedSessionProjection | null {
const row = db.prepare<[SessionSourceId, string], Omit<IndexedSessionProjection, "messages"> & { id: number }>(`
SELECT id, session_uuid AS sessionUuid, title, summary_text AS summaryText,
compact_text AS compactText, reasoning_summary_text AS reasoningSummaryText,
cwd, started_at AS startedAt, ended_at AS endedAt
FROM sessions
WHERE source_id = ? AND file_path = ?
LIMIT 1
`).get(sourceId, filePath);
if (!row) return null;
const messages = db.prepare<[number], ParsedMessage>(`
SELECT role, content_text AS contentText, timestamp, seq, source_kind AS sourceKind
FROM messages
WHERE session_id = ?
ORDER BY seq ASC
`).all(row.id);
const { id: _id, ...session } = row;
return { ...session, messages };
}

export function deleteSessionByFilePath(db: Db, filePath: string, sourceId: SessionSourceId = DEFAULT_SESSION_SOURCE_ID): void {
const row = db
.prepare<[SessionSourceId, string], { id: number }>("SELECT id FROM sessions WHERE source_id = ? AND file_path = ? LIMIT 1")
Expand Down
66 changes: 64 additions & 2 deletions src/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { printFindResults, printReadPage, printReadRangeResult, printStats } from "./format";
import type { FindResult, MessageRecord, SessionRecord } from "./types";
import { printFindResults, printReadPage, printReadRangeResult, printStats, printSyncSummary } from "./format";
import type { FindResult, MessageRecord, SessionRecord, SyncSummary } from "./types";
import chalk from "chalk";

const stripAnsi = (value: string): string => value.replace(/\[[0-9;]*m/g, "");
Expand Down Expand Up @@ -63,6 +63,68 @@ function makeMessage(seq: number): MessageRecord {
};
}

describe("printSyncSummary", () => {
afterEach(() => vi.restoreAllMocks());

test("distinguishes an active Codex soft-stale tail from failure", () => {
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const selector = { source: "codex" as const, kind: "all" as const, root: "/tmp/sessions" };
const summary: SyncSummary = {
scanned: 2,
added: 2,
updated: 0,
skipped: 0,
filtered: 0,
removed: 0,
errors: 0,
errorDetails: [],
selector,
coverage: {
written: true,
selector,
sourceFingerprint: "before",
sourceFileSetFingerprint: "same-files",
sourceFileCount: 2,
indexedSessionCount: 2,
staleReason: "source_content_changed",
recommendedAction: "query",
},
};

printSyncSummary(summary);

expect(captured(consoleLogSpy)).toContain("coverage: written (soft stale: active Codex tail changed; query is available, retry sync later)");
});

test("explains when an unproven active Codex source was deferred", () => {
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const selector = { source: "codex" as const, kind: "all" as const, root: "/tmp/sessions" };
printSyncSummary({
scanned: 2,
added: 1,
updated: 0,
skipped: 0,
filtered: 0,
removed: 0,
errors: 0,
errorDetails: [],
selector,
coverage: {
written: false,
selector,
sourceFingerprint: "before",
sourceFileSetFingerprint: "same-files",
sourceFileCount: 2,
indexedSessionCount: 0,
reason: "active_source_deferred",
recommendedAction: "sync",
},
});

expect(captured(consoleLogSpy)).toContain("coverage: not written (active Codex source changed before read; stable sources committed, retry sync)");
});
});

describe("printStats", () => {
let consoleLogSpy: any;

Expand Down
8 changes: 7 additions & 1 deletion src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ export function printSyncSummary(summary: SyncSummary): void {
console.log(`filtered: ${summary.filtered}`);
console.log(`removed: ${summary.removed}`);
console.log(`errors: ${summary.errors}`);
console.log(`coverage: ${summary.coverage.written ? "written" : `not written (${summary.coverage.reason ?? "unknown"})`}`);
const writtenCoverage = summary.coverage.staleReason === "source_content_changed"
? "written (soft stale: active Codex tail changed; query is available, retry sync later)"
: "written";
const unwrittenCoverage = summary.coverage.reason === "active_source_deferred"
? "not written (active Codex source changed before read; stable sources committed, retry sync)"
: `not written (${summary.coverage.reason ?? "unknown"})`;
console.log(`coverage: ${summary.coverage.written ? writtenCoverage : unwrittenCoverage}`);
if (summary.errorDetails.length > 0) {
console.log();
console.log(chalk.bold.red("sync errors"));
Expand Down
Loading