diff --git a/lib/gstack-memory-helpers.ts b/lib/gstack-memory-helpers.ts index 11a3f2239..66f2e3397 100644 --- a/lib/gstack-memory-helpers.ts +++ b/lib/gstack-memory-helpers.ts @@ -396,6 +396,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null { const globM = body.match(/(?:^|\n)\s*glob\s*:\s*"?([^"\n]+?)"?\s*$/m); const sortM = body.match(/(?:^|\n)\s*sort\s*:\s*([^\n]+)/); const tailM = body.match(/(?:^|\n)\s*tail\s*:\s*(\d+)/); + const filterMap = parseFilterMap(body); if (idM) q.id = idM[1].trim(); if (kindM) { @@ -408,6 +409,7 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null { if (globM) q.glob = globM[1].trim(); if (sortM) q.sort = sortM[1].trim(); if (tailM) q.tail = parseInt(tailM[1], 10); + if (filterMap) q.filter = filterMap; if (q.id && q.kind && q.render_as) { queries.push(q as GbrainManifestQuery); @@ -418,6 +420,39 @@ function extractGbrainBlock(frontmatter: string): GbrainManifest | null { return { schema, context_queries: queries }; } +/** + * Parse a nested `filter:` block map out of a single context_queries item body. + * + * The block is a YAML map nested under the `filter:` key: + * + * filter: + * type: timeline + * tags_contains: "repo:{repo_slug}" + * + * Each sub-key sits one indent level deeper than `filter:`. Surrounding quotes + * are stripped and template vars ({repo_slug}, now-7d, ...) are left intact for + * downstream substitution, matching how dispatchList stringifies each value + * into a `--filter k=v` argument. Returns undefined when there is no `filter:` + * block or it is empty. + */ +function parseFilterMap(body: string): Record | undefined { + const lines = body.split("\n"); + const filterIdx = lines.findIndex((l) => /^\s*filter\s*:\s*$/.test(l)); + if (filterIdx === -1) return undefined; + const filterIndent = lines[filterIdx].match(/^\s*/)![0].length; + + const filter: Record = {}; + for (let i = filterIdx + 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === "") continue; // tolerate blank lines within the block + const indent = line.match(/^\s*/)![0].length; + if (indent <= filterIndent) break; // dedent to a sibling key ends the block + const kv = line.match(/^\s*([A-Za-z0-9_]+)\s*:\s*"?(.*?)"?\s*$/); + if (kv) filter[kv[1]] = kv[2].trim(); + } + return Object.keys(filter).length > 0 ? filter : undefined; +} + // ── Public: withErrorContext ────────────────────────────────────────────── const ERROR_LOG_PATH = join(gstackHome(), ".gbrain-errors.jsonl"); diff --git a/test/gstack-memory-helpers.test.ts b/test/gstack-memory-helpers.test.ts index 7b356a99c..04edd8fe8 100644 --- a/test/gstack-memory-helpers.test.ts +++ b/test/gstack-memory-helpers.test.ts @@ -244,6 +244,62 @@ body expect(m!.context_queries[0].id).toBe("complete"); rmSync(dir, { recursive: true, force: true }); }); + + it("parses a nested filter: block on a list query", () => { + const dir = mkdtempSync(join(tmpdir(), "gstack-test-")); + const file = join(dir, "filtered.md"); + writeFileSync( + file, + `--- +name: investigate +gbrain: + schema: 1 + context_queries: + - id: prior-investigations + kind: list + filter: + type: timeline + tags_contains: "repo:{repo_slug}" + content_contains: "investigate" + sort: updated_at_desc + limit: 5 + render_as: "## Prior investigations in this repo" + - id: recent-no-filter + kind: list + sort: created_at_desc + limit: 3 + render_as: "## Recent (no filter)" +--- + +body +` + ); + + const m = parseSkillManifest(file); + expect(m).not.toBeNull(); + expect(m!.context_queries).toHaveLength(2); + + // The filter: sub-block is parsed into a key/value map, with quotes + // stripped and template vars left intact for downstream substitution. + const filtered = m!.context_queries[0]; + expect(filtered.id).toBe("prior-investigations"); + expect(filtered.filter).toEqual({ + type: "timeline", + tags_contains: "repo:{repo_slug}", + content_contains: "investigate", + }); + // Sibling fields on the same item still parse alongside the filter. + expect(filtered.sort).toBe("updated_at_desc"); + expect(filtered.limit).toBe(5); + expect(filtered.render_as).toBe("## Prior investigations in this repo"); + + // A list query with no filter: leaves filter undefined (no regression). + expect(m!.context_queries[1].id).toBe("recent-no-filter"); + expect(m!.context_queries[1].filter).toBeUndefined(); + expect(m!.context_queries[1].sort).toBe("created_at_desc"); + + rmSync(dir, { recursive: true, force: true }); + }); }); // ── withErrorContext ───────────────────────────────────────────────────────