Compare commits

..
Author SHA1 Message Date
d8cba9a76b fix(context): read documented '## P1 — Today' plain tasks in live context (#2186)
resolveTodayTasks only matched a bare '## Today' heading and bold-prefixed
'- [ ] **task**' lines, while the daily-task-manager skill's documented
Output Format writes '## P1 — Today' with plain '- [ ] task' lines — so
documented writes surfaced zero tasks in live context.

Reader now accepts both heading forms and both line forms, two-step: the
legacy bold prefix extracts just the task name (dropping trailing metadata),
falling back to the plain full-line form.

Salvaged from PR #2188 (reader-side half). The skill-doc rewrites in that PR
are dropped: master #2938 kept ops/ synced and made put_page write-through
durable, so the 'gbrain get/put ops/tasks' docs are correct as-is. The PR's
single-regex line matcher is replaced with the two-step match because its
alternation captured '**name** — metadata' verbatim for bold lines.

Takeover of #2188. Fixes #2186.

Co-authored-by: caioribeiroclw-pixel <caioribeiroclw-pixel@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:36:51 -07:00
6 changed files with 42 additions and 62 deletions
+1 -9
View File
@@ -1743,15 +1743,7 @@ async function extractStaleFromDB(
// `page.updated_at.toISOString()` — the JS Date is ms-truncated, so the
// µs-precision DB updated_at stayed strictly greater and the page never
// cleared on Postgres. Stamping the exact value makes them equal.
//
// Version-arm floor: a page last edited BEFORE LINK_EXTRACTOR_VERSION_TS
// would otherwise be stamped below the version watermark and stay
// permanently stale (`links_extracted_at < versionTs` re-fires every run).
// Stamp max(updated_at, versionTs) — versionTs is always a past release
// date, so a concurrent edit's now() still exceeds the stamp and D4 holds.
// Tie at ms precision picks updated_at_iso (its µs ≥ versionTs's .000000).
const stampTs = new Date(page.updated_at_iso) >= new Date(versionTs) ? page.updated_at_iso : versionTs;
processedRefs.push({ slug: page.slug, source_id: page.source_id, extractedAt: stampTs });
processedRefs.push({ slug: page.slug, source_id: page.source_id, extractedAt: page.updated_at_iso });
}
// Flush NON-swallowing (CDX-4): a throw here propagates out of the sweep so
+15 -4
View File
@@ -453,7 +453,14 @@ function resolveActivity(
* every `assemble()` call. 1 MB is generous for a human-edited task list. */
const MAX_TASKS_MD_BYTES = 1_000_000;
/** Extract open tasks from ops/tasks.md "## Today" section. */
/** Extract open tasks from ops/tasks.md Today section.
*
* The daily-task-manager skill's documented Output Format uses priority
* headings (`## P1 — Today`) with plain `- [ ] task` lines; older fixtures
* used a bare `## Today` heading with bold task names. Accept both so the
* live-context reader matches the documented writer contract instead of
* silently surfacing no tasks (#2186).
*/
function resolveTodayTasks(workspaceDir: string): string[] {
try {
const path = join(workspaceDir, 'ops', 'tasks.md');
@@ -461,14 +468,18 @@ function resolveTodayTasks(workspaceDir: string): string[] {
// statSync throws if the file doesn't exist; that lands in the outer catch.
if (statSync(path).size > MAX_TASKS_MD_BYTES) return [];
const raw = readFileSync(path, 'utf8');
const todayMatch = raw.match(/## Today[\s\S]*?(?=\n## |$)/);
const todayMatch = raw.match(/^##\s+(?:P\d\s*[—–-]\s*)?Today\b[\s\S]*?(?=\n##\s|$(?![\s\S]))/m);
if (!todayMatch) return [];
const lines = todayMatch[0].split('\n');
const open: string[] = [];
for (const line of lines) {
// Match unchecked task lines: - [ ] **task name** ...
const m = line.match(/^\s*-\s*\[ \]\s*\*\*(.+?)\*\*/);
// Match unchecked task lines. Legacy bold form first (extracts just
// the task name, dropping trailing metadata), then the documented
// plain form (whole line body is the task).
const m =
line.match(/^\s*-\s*\[ \]\s*\*\*(.+?)\*\*/) ??
line.match(/^\s*-\s*\[ \]\s*(.+?)\s*$/);
if (m) open.push(sanitizeForPrompt(m[1].trim()));
}
return open.slice(0, 5); // cap at 5 to keep prompt lean
+4 -13
View File
@@ -28,7 +28,7 @@ import { ensureWellFormed } from './text-safe.ts';
* OR updated_at > links_extracted_at`. It is an ISO-8601 string (NOT a number) —
* the column is TIMESTAMPTZ and the predicate binds it as `::timestamptz`.
*/
export const LINK_EXTRACTOR_VERSION_TS = '2026-07-21T00:00:00Z';
export const LINK_EXTRACTOR_VERSION_TS = '2026-05-31T00:00:00Z';
// ─── Entity references ──────────────────────────────────────────
@@ -80,10 +80,10 @@ export type LinkResolutionType = 'qualified' | 'unqualified';
* Directory prefix whitelist. These are the top-level slug dirs the extractor
* recognizes as entity references. Upstream canonical + our extensions:
* - Gbrain canonical: people, companies, meetings, concepts, deal, civic, project, source, media, yc, projects
* - Our domain extensions: tech, finance, personal, openclaw, ops (domain-organized wikis)
* - Our domain extensions: tech, finance, personal, openclaw (domain-organized wikis)
* - Our entity prefix: entities (we kept some legacy entities/projects/ pages)
*/
const DIR_PATTERN = '(?:people|companies|meetings|concepts|deal|civic|project|projects|source|media|yc|tech|finance|personal|openclaw|entities|ops)';
const DIR_PATTERN = '(?:people|companies|meetings|concepts|deal|civic|project|projects|source|media|yc|tech|finance|personal|openclaw|entities)';
/**
* Match `[Name](path)` markdown links pointing to entity directories.
@@ -865,16 +865,7 @@ export function queryBasenameIndex(idx: Map<string, string[]>, name: string): st
if (!name || typeof name !== 'string') return [];
const trimmed = name.trim();
if (!trimmed) return [];
let hit = idx.get(trimmed) ?? idx.get(trimmed.toLowerCase()) ?? idx.get(normalizeBasename(trimmed));
// Issue #2576 bug 2: path-style refs (`runbooks/2026-05-01-x`) from dirs
// outside DIR_PATTERN reach here, but normalizeBasename strips slashes
// into a garbage key (`runbooks2026-05-01-x`) that can never hit the
// tail-keyed index. Fall back to the path tail so qualified refs resolve
// by basename like everything else.
if (!hit && trimmed.includes('/')) {
const tail = trimmed.slice(trimmed.lastIndexOf('/') + 1).trim();
if (tail) hit = idx.get(tail) ?? idx.get(tail.toLowerCase()) ?? idx.get(normalizeBasename(tail));
}
const hit = idx.get(trimmed) ?? idx.get(trimmed.toLowerCase()) ?? idx.get(normalizeBasename(trimmed));
return hit ? [...hit].sort(basenameSort) : [];
}
+22
View File
@@ -322,6 +322,28 @@ describe('gbrain-context engine', () => {
expect(result.systemPromptAddition).not.toContain('Something later');
});
it('injects documented "## P1 — Today" plain tasks from ops/tasks.md (#2186)', async () => {
tmpDir = makeWorkspace({
heartbeat: { garryAwake: true },
tasks: `# Tasks\n\n## P0 — Urgent\n- [ ] **Escalate outage**\n\n## P1 — Today\n- [ ] Call Alice about launch plan\n- [ ] **Review Bob contract** — due Friday\n- [x] Completed item\n\n## P2 — This Week\n- [ ] Should not surface`,
});
const engine = createGBrainContextEngine({ workspaceDir: tmpDir });
const result = await engine.assemble({
sessionId: 'test-session',
messages: [],
});
expect(result.systemPromptAddition).toContain('Open tasks');
expect(result.systemPromptAddition).toContain('Call Alice about launch plan');
// Bold form still extracts just the task name, not trailing metadata.
expect(result.systemPromptAddition).toContain('Review Bob contract');
expect(result.systemPromptAddition).not.toContain('due Friday');
expect(result.systemPromptAddition).not.toContain('Escalate outage');
expect(result.systemPromptAddition).not.toContain('Completed item');
expect(result.systemPromptAddition).not.toContain('Should not surface');
});
it('no activity section when calendar is empty and no tasks', async () => {
tmpDir = makeWorkspace({
heartbeat: { garryAwake: true },
-18
View File
@@ -209,24 +209,6 @@ describe('gbrain extract --stale', () => {
expect(usRows[0]?.eq).toBe(true);
});
test('version-arm floor: page edited BEFORE LINK_EXTRACTOR_VERSION_TS clears after --stale (issue #2576 bug 3)', async () => {
// A page whose updated_at predates the version watermark used to be
// stamped at its updated_at (< versionTs), so the version arm re-fired
// every run — permanently stale. The sweep now floors the stamp at
// versionTs. (The #1768 test above also covers this since the v0.42.x
// VERSION_TS bump moved its date below the watermark, but this pins the
// behavior explicitly so a date "repair" there can't drop coverage.)
await engine.putPage('people/alice', personPage('Alice'));
await engine.executeRaw(`UPDATE pages SET updated_at = '2000-01-01T00:00:00Z' WHERE slug = 'people/alice'`);
expect(await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS })).toBe(1);
await runExtract(engine, ['--stale']);
// Pre-floor this stayed 1 forever (stamp < versionTs → version arm re-fires).
expect(await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS })).toBe(0);
await runExtract(engine, ['--stale']);
expect(await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS })).toBe(0);
});
test('CDX-4 (D2): a link-flush throw aborts the sweep and leaves pages UNSTAMPED', async () => {
await engine.putPage('people/alice', personPage('Alice'));
await engine.putPage('companies/acme', companyPage('Acme', '[Alice](people/alice) founded [Acme](companies/acme).'));
-18
View File
@@ -140,15 +140,6 @@ describe('extractEntityRefs', () => {
expect(wikiRefs[0].needsResolution).toBe(true);
});
test('recognizes ops/ qualified wikilinks (issue #2576 bug 2)', () => {
// `ops` was missing from DIR_PATTERN, so [[ops/...]] fell through to
// the generic 2c pass (needsResolution) instead of being a real ref.
const refs = extractEntityRefs('Deployed via [[ops/services/pointer-agent]].');
expect(refs.length).toBe(1);
expect(refs[0].slug).toBe('ops/services/pointer-agent');
expect(refs[0].needsResolution).toBeUndefined();
});
test('skips qualified-syntax tokens (those belong to 2a)', () => {
// [[wiki:topics/ai]] looks like 2a's qualified shape — even though
// it wouldn't satisfy DIR_PATTERN, 2c must not claim it either
@@ -1078,15 +1069,6 @@ describe('makeResolver — fallback chain', () => {
]);
});
test('resolveBasenameMatches: path-style ref falls back to the tail (issue #2576 bug 2)', async () => {
// normalizeBasename strips slashes, so `runbooks/2026-05-01-pointer-agent`
// used to normalize to a garbage key that never hit the tail-keyed index.
const engine = makeFakeEngineWithSlugs(['ops/changes/2026-05-01-pointer-agent']);
const r = makeResolver(engine);
expect(await r.resolveBasenameMatches!('runbooks/2026-05-01-pointer-agent'))
.toEqual(['ops/changes/2026-05-01-pointer-agent']);
});
test('resolveBasenameMatches: case-insensitive fallback', async () => {
const engine = makeFakeEngineWithSlugs(['companies/fast-weigh']);
const r = makeResolver(engine);