diff --git a/CLAUDE.md b/CLAUDE.md index 0897628..61c78eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # MonkeyLLM agent guide Knowledge forest navigable by an SLM: markdown + indexes, traversed through -**Vine**'s MCP primitives. `docs/monkeyllm-spec-v0.78.md` is normative +**Vine**'s MCP primitives. `docs/monkeyllm-spec-v0.79.md` is normative (earlier versions are archived) **the spec is the truth**; any contract change requires a new spec version before code. @@ -77,6 +77,38 @@ Local models (llama.cpp on the 3090): see `docs/local-inference.md`. ## Conventions and pitfalls +- **The walk had no clock and no window (spec J.10.5 r3/r4 + C.13.1 r7 + + C.13.3 + J.10.7 + J.5.19, v0.79)**: an operator asked a walk for "the two + screenshots I uploaded today"; `coverage` counted two media nodes in hop + 1, the walk then read the root, scanned it FLAT (0), grepped every body + for the literal word "media" (four task docs) and answered that the + product keeps no pictures. Nothing was missing on the wire: `since`/ + `until`/`date_field` on `locate`/`sniff`/`scan`, `type_filter`, + `filter: {type}`, `recursive` all existed and the loop forwards the + model's args verbatim — the MENU named none of them (v0.67's rule about + tools, applied to arguments), `calendar` was not on the whitelist, and + NO prompt said what day it was, so "hoje" was unresolvable with every + window parameter in the world. Now: `FORAGE_TOOLS` gains `calendar` + (bounded by the caller's window like the searching calls; `coverage` + stays unbounded), the menu names every parameter, `FORAGE_CLOCK` states + `host_today()` — `date.today()`, the SAME clock that stamps + `created`/`updated`, never a second one — and that date enters the + WALK's J.10.7 key only (`build_key(today=)`; the sweep's prompt states + no date and a date in its key would expire every stored answer + nightly). The caller's bound lands LAST (`{**args, **bounds}`), so a + model-authored `since` is replaced, not merged, and the prompt says so; + the record shows the NORMALISED bound (`2020-01-01`, C.13.1 r6). `HOP_ARGS` + gains `filter`/`type_filter`/`since`/`until`/`date_field`/`recursive`/ + `granularity` — the arguments that decide whether a listing is EMPTY, + which the console could not show; `_outcome` reports `buckets` for + `calendar`. Gotcha: `consult_walk_store` is a closure that never + imported `inference` — a bare name there is a 400 "answer failed: name + 'inference' is not defined" on every walk, caught by the store suite. + Ask console (J.5.19): two date inputs, `since`/`until` sent only when + set, read from the ADDRESS at mount like `q` and never a browser + preference (v0.76's lesson), answer labelled by the response's + `window` echo, history badges a bounded run. F.173 in + `tests/test_v079_walk_window.py` (+ `apps/studio/check-ask-window.mjs`). - **The passport travels with the bytes (spec J.8.4, v0.78)**: the agent that followed v0.77's path uploaded a screenshot it had already looked at, and the scent it knew had nowhere to go until a second call and a diff --git a/apps/station/monkeyllm_station/answer_store.py b/apps/station/monkeyllm_station/answer_store.py index 4e4f7bb..6c19712 100644 --- a/apps/station/monkeyllm_station/answer_store.py +++ b/apps/station/monkeyllm_station/answer_store.py @@ -83,7 +83,8 @@ def build_key(*, question: str, terms, k: int, hops, hybrid: bool, binding: dict, policy, head: str | None = None, reply_tokens: int | None = None, window: dict | None = None, - include_superseded: bool = False) -> str: + include_superseded: bool = False, + today: str | None = None) -> str: """The closed list of J.10.7 — and nothing off it. Every component here can change the answer; nothing else may enter, @@ -103,6 +104,11 @@ def build_key(*, question: str, terms, k: int, hops, hybrid: bool, that same reason: the flag decides whether a replaced document is in the material at all, so the history view and the current view are two questions — and off, which is the default, keys exactly as before. + `today` (J.10.5 rule 3, v0.79) is the walk's alone: its prompt states + the host's date, so a hunt asked on two days is two hunts and an entry + served across midnight would answer "today" with yesterday's walk. A + sweep passes None — its prompt states no date, and a date in its key + would expire every stored answer nightly for nothing. """ material = json.dumps({ "question": normalize(question), @@ -130,6 +136,7 @@ def build_key(*, question: str, terms, k: int, hops, hybrid: bool, "date_field": window.get("date_field")}} if window else {}), **({"include_superseded": True} if include_superseded else {}), + **({"today": str(today)} if today else {}), }, sort_keys=True, ensure_ascii=False) return hashlib.sha256(material.encode("utf-8")).hexdigest() diff --git a/apps/station/monkeyllm_station/app.py b/apps/station/monkeyllm_station/app.py index 945cf85..c7a04c0 100644 --- a/apps/station/monkeyllm_station/app.py +++ b/apps/station/monkeyllm_station/app.py @@ -2157,18 +2157,23 @@ def consult_walk_store(sample, forest, vine, policy, binding, payload, return None from monkeyllm.harvest import derive_terms + from monkeyllm_station import inference + t0 = time.perf_counter() head = _git(Path(vine.forest.root), "rev-parse", "HEAD") # The walk's `k` is not capped by C.6c and keys as given. Its # effective terms are the derived ones and can be nothing else: # `terms` beside `hops` is refused before this point (J.10.3), so # this IS the "or the sweep derived them" half of J.10.7 rule 2. + # J.10.5 rule 3 (v0.79): the walk's prompt states the host's date, + # so the date is part of what the model was asked — read off the + # same clock the prompt reads, never a second one. key = answer_store.build_key( question=question, terms=derive_terms(question), k=k, hops=budget, window=window, hybrid=bool(getattr(vine, "hybrid_locate", False)), binding=binding, policy=policy, head=head, - reply_tokens=reply_tokens) + reply_tokens=reply_tokens, today=inference.host_today()) store = answer_store.AnswerStore(Path(vine.forest.root)) sample["cache_store"] = {"store": store, "key": key, "question": question, diff --git a/apps/station/monkeyllm_station/inference.py b/apps/station/monkeyllm_station/inference.py index 9d57468..2b1b72c 100644 --- a/apps/station/monkeyllm_station/inference.py +++ b/apps/station/monkeyllm_station/inference.py @@ -15,6 +15,7 @@ from __future__ import annotations +import datetime as dt import json import logging import time @@ -356,8 +357,38 @@ def answer(scoped_vine, question: str, binding: dict, k: int = 3, # decide to call it, which left a walk with no move but to read one document # and describe the corpus from it. Nothing widens: it opens no body (C.17 # rule 1) and every number in it is the calling policy's own (rule 7). +# +# `calendar` joined in v0.79 (J.10.5) for the same reason: a question with a +# period in it — today, this week, since the last release — is decided by +# where the material sits in time, and C.13.3 is the read built for that: +# periods and counts off the catalog, no body opened, every count the +# calling policy's own. Without it a walk asked for "today" could only guess +# a date, or read the whole forest to find one. FORAGE_TOOLS = ("locate", "sniff", "look", "move", "pick", "scan", "query", - "coverage") + "coverage", "calendar") + + +def host_today() -> str: + """The host's date, as the loop's prompt states it (J.10.5 rule 3). + + One clock, deliberately: `date.today()` is what stamps `created` and + `updated` on every node this host writes, so "today" in the question + and "today" in the passports are one day. The walk's store key reads + the same function (J.10.7, v0.79), never a second clock. + """ + return dt.date.today().isoformat() + + +# J.10.5 rule 3 (v0.79). A model holding every window parameter there is can +# write `since` and has nothing to write in it: no prompt said what day it +# was, so "today" was unresolvable. Appended per call, because the constant +# below is served for as long as the process lives. +FORAGE_CLOCK = ( + "\n\nToday is {today} — the host's date, the same clock that stamps " + "`created` and `updated` on every node this host writes. Resolve a " + "relative period in the question (today, yesterday, this week, last " + "month) against it, and pass the result as `since`/`until`." +) MAX_HOPS = 16 @@ -365,16 +396,24 @@ def answer(scoped_vine, question: str, binding: dict, k: int = 3, question using ONLY the tools below. Never invent facts: navigate, read, answer. Always respond with a SINGLE JSON object, nothing else: -- {"tool": "locate", "args": {"query": "...", "k": 5}} -> entry points by curated metadata -- {"tool": "sniff", "args": {"terms": ["..."], "scope": null}} -> literal grep on BODIES: an exact - term (code, name, number) -> node + section + snippet. `scope` restricts to a branch or node. +- {"tool": "locate", "args": {"query": "...", "k": 5, "type_filter": null, "since": null, + "until": null}} -> entry points by curated metadata (titles, summaries, tags; never bodies) +- {"tool": "sniff", "args": {"terms": ["..."], "scope": null, "type_filter": null, "since": null, + "until": null}} -> literal grep on BODIES: an exact term (code, name, number) -> node + section + + snippet. `scope` restricts to a branch or node. - {"tool": "look", "args": {"id": "..."}} -> cheap digest of a node: summary, neighbours, outline - {"tool": "move", "args": {"id": "...", "rel": null}} -> neighbours ("children" lists a branch's) - {"tool": "pick", "args": {"id": "...", "section": null}} -> the body, or one section of it -- {"tool": "scan", "args": {"parent_id": "...", "filter": {}}} -> filter children by metadata +- {"tool": "scan", "args": {"parent_id": "...", "filter": {"type": "media"}, "recursive": false, + "since": null, "until": null}} -> list a branch's children by metadata. `filter` keys are + passport fields (type, tags, ...); `recursive: true` walks the whole subtree — without it only + the DIRECT children are listed, and a root's direct children are its branches, not documents. - {"tool": "query", "args": {"id": "...", "sql": "SELECT ..."}} -> read-only SQL on type:dataset nodes - {"tool": "coverage", "args": {}} -> what this forest HOLDS: every root with its node count, date range and source, plus totals by type. Counts and curated metadata, no search and no bodies. +- {"tool": "calendar", "args": {"granularity": "month", "scope": null, "since": null, + "until": null}} -> which periods hold material and how much (day|week|month|year), most recent + first; every bucket carries the exact `since`/`until` a search takes. Counts only, no bodies. - {"tool": "answer", "args": {"text": "...", "answer_nodes": ["full/id"]}} -> the final answer Strategy: an exact rare term (code, proper name, number)? sniff first — it lands in the section and @@ -388,6 +427,16 @@ def answer(scoped_vine, question: str, binding: dict, k: int = 3, dropped, never that they failed the filter: never present a truncated result as the complete set, and never state a count from one. Read the "hint" and ask again, narrower. - Repeating the same call with the same arguments returns the same result. Change tool or terms. +- Time: `since`/`until` on locate, sniff, scan and calendar take YYYY, YYYY-MM or YYYY-MM-DD + (inclusive) and bound by each node's `created` date (`"date_field": "updated"` for the other). + A question about a period — today, this week, last month, since X — is a window: resolve it + from the date stated below, use calendar when you need to see which periods hold anything, and + pass the window on the searching calls. An empty windowed read says whether the window was the + reason (`matched_window`), so read that before concluding nothing exists. +- A KIND of node — the pictures, the datasets, the decisions — is found by its filter: + `type_filter` on locate/sniff, `filter: {"type": "..."}` on scan (with `recursive: true` when + it may sit anywhere in the branch). Sniffing for the type's NAME is not that: it greps bodies + for a word and returns every document that merely mentions it. - type:dataset nodes answer through SQL: read the manual in look, then query. Aggregates are not in the prose. A "notes" field on a dataset is what its operator wrote about how to read it — follow it. Never `SELECT *` on a wide table: results are token-budgeted, so name the columns @@ -444,8 +493,15 @@ def json_block(text: str) -> str | None: # What a hop is allowed to report about itself. Small scalars the model # chose, so a reader can see the *decision*, not just the tool name — "sniff" # says nothing; `sniff terms=[architecture]` says what it was thinking. +# J.10.5 (v0.79): the second row is the arguments that decide whether a +# listing is EMPTY. Without them `scan _index -> 0 node(s)` read on a console +# as an empty root, when it was a flat scan with a type filter over five +# branches — the record was hiding the one argument that explained it. An +# argument the model did not set is absent, never a default written in. HOP_ARGS = ("query", "terms", "sql", "section", "rel", "scope", "parent_id", - "direction", "k") + "direction", "k", + "filter", "type_filter", "since", "until", "date_field", + "recursive", "granularity") def _hop_args(args: dict) -> dict: @@ -521,6 +577,10 @@ def _outcome(tool: str, result: dict) -> dict: # under the field a reader already knows, rather than as a word only # this one tool would ever emit. return {"nodes": result.get("total")} + if tool == "calendar": + # C.13.3's answer is periods, not nodes: the number that says what + # came back is how many buckets hold anything. + return {"buckets": len(result.get("buckets") or [])} if tool == "pick": return {"tokens": result.get("body_tokens")} if tool == "query": @@ -654,7 +714,7 @@ def forage(scoped_vine, question: str, binding: dict, k: int = 3, the hunt — a deadline turn forces an answer from what was already read. """ max_hops = max(1, min(int(max_hops or 1), MAX_HOPS)) - system = FORAGE_SYSTEM + system = FORAGE_SYSTEM + FORAGE_CLOCK.format(today=host_today()) # J.10.8 (amended v0.63): the cap bounds every turn and the note aims at # the answer, which is the turn it exists for. A navigating turn is short; # the ANSWER turn is an object carrying the text AND `answer_nodes`, and @@ -678,7 +738,8 @@ def forage(scoped_vine, question: str, binding: dict, k: int = 3, f"{bounds.get('since') or 'the beginning'} … " f"{bounds.get('until') or 'now'} " f"({bounds.get('date_field', 'created')} date). Material outside " - "that window is not available to you; say so if the answer needs it.") + "that window is not available to you; say so if the answer needs it. " + "That bound replaces any since/until you send.") messages = [ {"role": "system", "content": system}, {"role": "user", "content": @@ -757,11 +818,15 @@ def forage(scoped_vine, question: str, binding: dict, k: int = 3, asked.add(key) h0 = time.perf_counter() - # The searching calls, and only those: `coverage` takes no window - # (C.17 counts a whole scope, and `date_field` is the caller's, not - # the hunt's), and `harvest` is not on the whitelist — it was refused - # above long before this line could ever see it. - if bounds and tool in ("locate", "sniff", "scan"): + # The searching calls and the map, and only those: `coverage` takes + # no window (C.17 counts a whole scope, and `date_field` is the + # caller's, not the hunt's), and `harvest` is not on the whitelist — + # it was refused above long before this line could ever see it. + # `calendar` IS bounded (v0.79): the map the model sees must be the + # map of what it may reach. The caller's bound lands LAST, so a + # window the model authored on the same call is replaced, not + # merged — C.13.1 rule 7, and the prompt says so. + if bounds and tool in ("locate", "sniff", "scan", "calendar"): args = {**args, **bounds} result = scoped_vine.call(tool, **args) hop_ms = (time.perf_counter() - h0) * 1000 diff --git a/apps/station/monkeyllm_station/mcp_surface.py b/apps/station/monkeyllm_station/mcp_surface.py index 2f3cc1d..b223f59 100644 --- a/apps/station/monkeyllm_station/mcp_surface.py +++ b/apps/station/monkeyllm_station/mcp_surface.py @@ -367,7 +367,8 @@ async def locate(forest: str, query: str, k: int = 5, scope: str = "all", anything: an empty window says so explicitly rather than looking like an empty forest. `lang` filters by the node's declared language tag (A.3.2), exact match — a node that declares none is - in no language filter.""" + in no language filter. `type_filter` narrows to one node type: the + pictures are `type_filter="media"`, not a query for the word.""" return await call(forest, "locate", query=query, k=k, scope=scope, type_filter=type_filter, include=include, since=since, until=until, date_field=date_field, @@ -467,7 +468,11 @@ async def scan(forest: str, parent_id: str, filter: dict | None = None, id/type/summary/body_tokens) — and it is the PAGE lever: the token budget cuts the page, so fewer fields per item means more items per page (`fields=["id"]` enumerates a large forest in a fraction - of the calls). `since`/`until` bound it by date.""" + of the calls). `filter` matches passport fields: `{"type": "media"}` + lists the pictures, `{"source": "agent"}` what agents wrote. Without + `recursive` only the DIRECT children are listed, and a root's direct + children are its branches, not its documents. `since`/`until` bound + it by date.""" return await call(forest, "scan", parent_id=parent_id, filter=filter, fields=fields, recursive=recursive, limit=limit, after=after, since=since, @@ -475,7 +480,8 @@ async def scan(forest: str, parent_id: str, filter: dict | None = None, @mcp.tool() async def sniff(forest: str, terms: list[str], scope: str | None = None, - k: int = 5, since: str | None = None, + k: int = 5, type_filter: str | None = None, + since: str | None = None, until: str | None = None, date_field: str | None = None, lang: str | None = None): @@ -485,8 +491,14 @@ async def sniff(forest: str, terms: list[str], scope: str | None = None, name it; omit it to search the whole forest. `_meta/` is the dialect, not content — pick("_meta/schema") reads it. `since`/`until` bound it by date, and here that is also the cheapest thing you can - do: a windowed sniff opens the files of those days and no others.""" + do: a windowed sniff opens the files of those days and no others. + + A KIND of node — the pictures, the datasets, the decisions — is + `type_filter` ("media", "dataset", "note"), never a term: sniffing + for the word "media" greps bodies for that word and returns every + document that mentions it, and not one picture.""" return await call(forest, "sniff", terms=terms, scope=scope, k=k, + type_filter=type_filter, since=since, until=until, date_field=date_field, lang=lang) diff --git a/apps/studio/check-ask-window.mjs b/apps/studio/check-ask-window.mjs new file mode 100644 index 0000000..3ed4d2a --- /dev/null +++ b/apps/studio/check-ask-window.mjs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026 Jimmy Wesley + +/* J.5.19 acceptance (F.173, the console's half): the question's period, + * checked. + * + * Studio has no test runner and this file is not one — it reads the Ask + * console's source and asks it F.173's questions: that `since`/`until` are + * offered, sent as C.13.1's own parameters on `answer`, prefilled from the + * address, and written to no browser preference. A Python test runs it; a + * non-zero exit is a failed criterion, named on stdout. + * + * The boundary is F.137's. What a reader of the source can see is the + * decision layer: where the two values come from, which call carries them + * and under what condition, what the label beside the answer is computed + * from, and where they are NOT written. What it cannot see is a rendered + * date picker or a badge on a screen — those want a browser, and asserting + * them from the source would only assert that a string is present. Every + * check that asserts a PRESENCE was verified to fail with the v0.78 view + * put back; the ones that assert an absence (nothing written to storage or + * the address, nothing validated by the console) hold on that source too + * and are kept because they are what the next edit could break. Pass + * another Ask source as the first argument to repeat the control. */ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const askPath = process.argv[2] || join(here, 'src/views/Ask.jsx') +const src = readFileSync(askPath, 'utf8') +const locale = (lang) => JSON.parse( + readFileSync(join(here, `src/locales/ask/${lang}.json`), 'utf8')) + +let failed = 0 +const ok = (n, c, extra = '') => { + if (!c) failed++ + console.log(`${c ? 'PASS' : 'FAIL'} ${n}${extra ? ' ' + extra : ''}`) +} + +/* One function's body, found by its declaration and closed by brace depth, + so a check reads THAT function and not a coincidence elsewhere. */ +const bodyOf = (signature) => { + const start = src.indexOf(signature) + if (start < 0) return '' + let depth = 0 + // The signature ends with the body's own brace: a destructured parameter + // list carries braces of its own, and opening on the first one found + // would return the parameter list and call it the function. + let i = src.indexOf('{', start + signature.length - 1) + for (; i < src.length; i++) { + if (src[i] === '{') depth++ + else if (src[i] === '}' && --depth === 0) break + } + return src.slice(start, i + 1) +} +/* A slice between two markers, for the things that are not functions. */ +const between = (from, to) => { + const a = src.indexOf(from) + if (a < 0) return '' + const b = src.indexOf(to, a) + return b < 0 ? '' : src.slice(a, b) +} +const count = (re) => (src.match(re) || []).length + +/* -- (a) read from the address at mount, exactly as `q` is --------------- */ + +const fromAddress = (name) => new RegExp( + `const \\[${name}, set\\w+\\] = useState\\(\\s*\\(\\) => new URLSearchParams\\(window\\.location\\.search\\)\\.get\\('${name}'\\) \\|\\| ''\\)`) +ok('F.173 `since` is read from the address once, at mount, like `q`', + fromAddress('since').test(src)) +ok('F.173 `until` is read from the address once, at mount, like `q`', + fromAddress('until').test(src)) +ok('F.173 the console never writes the address (J.5.8: it restores a page, never a call)', + !/useRouteState|router\.js|history\.(push|replace)|searchParams\.set|location\.search\s*=/.test(src)) + +/* -- (b) sent as C.13.1's parameters on `answer`, only when set ---------- */ + +const askBody = bodyOf('async function ask(text) {') +/* The object the `answer` POST is built from, and nothing before it: the + fallback harvest in the same function carries the same spread, and a + check that found the first occurrence would pass on the wrong call. */ +const paramsBlock = between('const params = {', 'const t0 = performance.now()') +const sinceSpread = /\.\.\.\(since \? \{ since \} : \{\}\)/ +const untilSpread = /\.\.\.\(until \? \{ until \} : \{\}\)/ +ok('F.173 the `answer` request carries `since` only when set', + askBody.includes('const params = {') && sinceSpread.test(paramsBlock)) +ok('F.173 the `answer` request carries `until` only when set', + askBody.includes('const params = {') && untilSpread.test(paramsBlock)) +ok('F.173 `date_field` is never sent — `created` is the console\'s question', + !/date_field\s*:/.test(askBody) && !/date_field/.test(paramsBlock.replace(/\/\/.*$/gm, ''))) +const fallback = between("api.call(forest, 'harvest'", '.then(') +ok('F.173 the J.5.15 fallback harvest is bounded like the answer it stands in for', + fallback.length > 0 && sinceSpread.test(fallback) && untilSpread.test(fallback)) +ok('F.173 the request is the one place the window is sent from (one `answer` call)', + count(/api\.timedCall\(forest, 'answer'/g) === 1) + +/* -- (c) never a browser preference ---------------------------------------- */ + +ok('F.173 no `savePrefs(` call carries `since` or `until`', + !/savePrefs\(\{[^}]*\b(since|until)\b/.test(src)) +ok('F.173 `loadPrefs()` is never read for `since` or `until`', + !/loadPrefs\(\)\.(since|until)/.test(src) + && !/\b(since|until)\b/.test(bodyOf('function loadPrefs() {'))) +ok('F.173 no browser-storage write names the window at all', + !/localStorage\.setItem\([^)]*\b(since|until)\b/.test(src) + && !/sessionStorage/.test(src)) +ok('F.173 restoring a saved run puts the run\'s own window back (J.5.9), not a preference', + /setSince\(run\.params\?\.since \|\| ''\)/.test(bodyOf('async function restore(id) {')) + && /setUntil\(run\.params\?\.until \|\| ''\)/.test(bodyOf('async function restore(id) {'))) + +/* -- (d) two date controls, each named -------------------------------------- */ + +const sinceInput = /]*value=\{since\}[^>]*aria-label=\{t\('ask\.window_since'\)\}/ +const untilInput = /]*value=\{until\}[^>]*aria-label=\{t\('ask\.window_until'\)\}/ +ok('F.173 the console offers `since` as a real date input, named', + sinceInput.test(src)) +ok('F.173 the console offers `until` as a real date input, named', + untilInput.test(src)) +ok('F.173 the two controls are named once each, not shared', + count(/ask\.window_since/g) === 1 && count(/ask\.window_until/g) === 1) +ok('F.173 the pair is grouped under the period\'s name for assistive technology', + /role="group"\s+aria-label=\{t\('ask\.window'\)\}/.test(src)) +ok('F.173 the inputs validate nothing of their own (a bad bound is C.13.1 rule 4\'s refusal)', + !/]*\b(min|max|pattern|required)=/.test(src) + && !/E_SCHEMA[^\n]*(since|until)|(since|until)[^\n]*E_SCHEMA/.test(src.replace(/\/\/.*$/gm, '').replace(/\/\*[^]*?\*\//g, ''))) + +/* -- (e) the cURL carries the window ---------------------------------------- */ + +const curl = between('const curl = `curl', '`\n') +ok('F.173 the cURL the console offers carries `since` and `until` when set', + /\.\.\.\(since \? \{ since \} : \{\}\)/.test(curl) + && /\.\.\.\(until \? \{ until \} : \{\}\)/.test(curl)) + +/* -- (f) the label is the echo, never the inputs ---------------------------- */ + +const usedCall = between("t('ask.window_used'", '})') +ok('F.173 the "window used" label exists and is gated on the response\'s `window`', + /result\.window && \(/.test(src) && usedCall.length > 0) +ok('F.173 the label reads `result.window.since` / `result.window.until` (C.13.1 rule 6)', + /since: result\.window\.since/.test(usedCall) && /until: result\.window\.until/.test(usedCall)) +ok('F.173 the label never reads the inputs\' own strings', + !/since: since|until: until|\bsince,|\buntil,/.test(usedCall)) +ok('F.173 the exported .md is labelled by the same echo', + /result\.window\s*\?\s*\[`- Window: \$\{result\.window\.since/.test( + bodyOf('function downloadMarkdown(result, question, forest) {'))) + +/* -- (g) a bounded run is badged in the history ----------------------------- */ + +const historyBody = bodyOf('function History({ open, onClose, principal, forest, onPick }) {') +ok('F.173 the run history badges a bounded run off what was SENT', + /\(run\.params\?\.since \|\| run\.params\?\.until\) && \(/.test(historyBody) + && /t\('ask\.history_window'\)/.test(historyBody)) + +/* -- the hop panel reads v0.79's record (J.10.5) ---------------------------- */ + +const pathBody = bodyOf('function Path({ hops }) {') +ok('F.173 a hop\'s object argument (`filter`) renders as compact JSON, arrays still join', + /typeof v === 'object' \? JSON\.stringify\(v\)/.test(pathBody) + && /Array\.isArray\(v\) \? v\.join\(', '\)/.test(pathBody)) +ok('F.173 a `calendar` hop\'s `buckets` outcome is labelled', + /\['buckets', 'ask\.hop_buckets'\]/.test(pathBody) + && ['en', 'pt', 'es'].every((lang) => /\{n\}/.test(locale(lang)['ask.hop_buckets'] || ''))) + +/* -- (h) the words exist in the three languages ----------------------------- */ + +const KEYS = ['ask.window', 'ask.window_hint', 'ask.window_since', 'ask.window_until', + 'ask.window_used', 'ask.window_clear', 'ask.history_window'] +for (const lang of ['en', 'pt', 'es']) { + const d = locale(lang) + ok(`F.173 every window key exists and is non-empty in ${lang}`, + KEYS.every((k) => typeof d[k] === 'string' && d[k].trim().length > 0), + KEYS.filter((k) => !d[k]).join(' ')) + ok(`F.173 ${lang}'s "window used" carries both placeholders`, + /\{since\}/.test(d['ask.window_used'] || '') && /\{until\}/.test(d['ask.window_used'] || '')) + ok(`F.173 ${lang} names the two ends differently`, + d['ask.window_since'] !== d['ask.window_until']) +} + +if (failed) { + console.log(`\n${failed} criterion(s) failed`) + process.exit(1) +} +console.log('\nall F.173 (console) criteria hold') diff --git a/apps/studio/check-skill.mjs b/apps/studio/check-skill.mjs index 25b1248..f0d05fc 100644 --- a/apps/studio/check-skill.mjs +++ b/apps/studio/check-skill.mjs @@ -147,6 +147,8 @@ ok('F.172 the saving block teaches the passport beside the bytes', all.some((f) => f.path.endsWith('saving.md') && f.text.includes('passport:') && f.text.includes('`## Notes`') && f.text.includes('never sent'))) +ok('F.173 the core teaches that a kind of node is a filter, not a word', + core1.includes('type_filter: "media"') && core1.includes('filter: {"type": "media"}')) ok('F.127 the core states the min_score pairing', core1.includes('below_min_score') && core1.includes('min_evidence: 1')) ok('F.127 two selections, two names', diff --git a/apps/studio/src/locales/ask/en.json b/apps/studio/src/locales/ask/en.json index 7cec096..13b5510 100644 --- a/apps/studio/src/locales/ask/en.json +++ b/apps/studio/src/locales/ask/en.json @@ -31,7 +31,9 @@ "ask.history_title": "Questions already asked", "ask.history_today": "Today", "ask.history_unavailable": "This browser is not keeping runs: storage is unavailable (a private window, or storage turned off). Answers are unaffected.", + "ask.history_window": "windowed", "ask.history_yesterday": "Yesterday", + "ask.hop_buckets": "{n} period(s)", "ask.hop_children": "{n} child(ren)", "ask.hop_edges": "{n} edge(s)", "ask.hop_error": "refused · {code}", @@ -90,6 +92,12 @@ "ask.trail_sub": "Where this question went, on the forest it came out of.", "ask.trail_view_hint": "Scroll to zoom, drag to pan, double-click to fit", "ask.trail_waiting": "Searching — the first stages appear before the reply does.", + "ask.window": "Period", + "ask.window_clear": "Whole forest (clear the period)", + "ask.window_hint": "Bounds the question to material created between these dates; either end may be left open. Only what arrived inside the window is searched, and the answer names the window actually used. Part of the question, not a preference: it travels with the link and is not remembered.", + "ask.window_since": "Created from", + "ask.window_until": "Created until", + "ask.window_used": "Window used: {since} → {until}", "ask.working_hops": "Foraging searching, reading, deciding where next…", "ask.working_sweep": "Searching the forest…" } diff --git a/apps/studio/src/locales/ask/es.json b/apps/studio/src/locales/ask/es.json index 32b53bd..d2dffbc 100644 --- a/apps/studio/src/locales/ask/es.json +++ b/apps/studio/src/locales/ask/es.json @@ -31,7 +31,9 @@ "ask.history_title": "Preguntas ya hechas", "ask.history_today": "Hoy", "ask.history_unavailable": "Este navegador no está guardando ejecuciones: el almacenamiento no está disponible (una ventana privada, o el almacenamiento desactivado). Las respuestas no se ven afectadas.", + "ask.history_window": "con período", "ask.history_yesterday": "Ayer", + "ask.hop_buckets": "{n} período(s)", "ask.hop_children": "{n} hijo(s)", "ask.hop_edges": "{n} arista(s)", "ask.hop_error": "rechazado · {code}", @@ -90,6 +92,12 @@ "ask.trail_sub": "Por dónde pasó esta pregunta, en el bosque del que salió.", "ask.trail_view_hint": "Use la rueda para acercar, arrastre para mover, doble clic para encuadrar", "ask.trail_waiting": "Buscando — las primeras etapas aparecen antes que la respuesta.", + "ask.window": "Período", + "ask.window_clear": "Bosque entero (quitar el período)", + "ask.window_hint": "Acota la pregunta al material creado entre estas fechas; cualquiera de los extremos puede quedar abierto. Solo se busca lo que llegó dentro de la ventana, y la respuesta dice la ventana realmente usada. Es parte de la pregunta, no una preferencia: viaja con el enlace y no se recuerda.", + "ask.window_since": "Creado desde", + "ask.window_until": "Creado hasta", + "ask.window_used": "Ventana usada: {since} → {until}", "ask.working_hops": "Forrajeando buscando, leyendo, decidiendo a dónde ir…", "ask.working_sweep": "Buscando en el bosque…" } diff --git a/apps/studio/src/locales/ask/pt.json b/apps/studio/src/locales/ask/pt.json index 1f6c13f..7374ac0 100644 --- a/apps/studio/src/locales/ask/pt.json +++ b/apps/studio/src/locales/ask/pt.json @@ -31,7 +31,9 @@ "ask.history_title": "Perguntas já feitas", "ask.history_today": "Hoje", "ask.history_unavailable": "Este navegador não está guardando execuções: o armazenamento não está disponível (janela anônima, ou armazenamento desligado). As respostas seguem funcionando.", + "ask.history_window": "com período", "ask.history_yesterday": "Ontem", + "ask.hop_buckets": "{n} período(s)", "ask.hop_children": "{n} filho(s)", "ask.hop_edges": "{n} aresta(s)", "ask.hop_error": "recusado · {code}", @@ -90,6 +92,12 @@ "ask.trail_sub": "Por onde esta pergunta passou, na floresta de onde ela saiu.", "ask.trail_view_hint": "Role para dar zoom, arraste para mover, duplo clique para enquadrar", "ask.trail_waiting": "Buscando — as primeiras etapas aparecem antes da resposta.", + "ask.window": "Período", + "ask.window_clear": "Floresta inteira (limpar o período)", + "ask.window_hint": "Limita a pergunta ao material criado entre estas datas; qualquer uma das pontas pode ficar aberta. Só o que chegou dentro da janela é buscado, e a resposta diz a janela de fato usada. É parte da pergunta, não uma preferência: viaja com o link e não fica lembrada.", + "ask.window_since": "Criado a partir de", + "ask.window_until": "Criado até", + "ask.window_used": "Janela usada: {since} → {until}", "ask.working_hops": "Forrageando buscando, lendo, decidindo para onde ir…", "ask.working_sweep": "Buscando na floresta…" } diff --git a/apps/studio/src/skill.js b/apps/studio/src/skill.js index 89fe13b..1722df9 100644 --- a/apps/studio/src/skill.js +++ b/apps/studio/src/skill.js @@ -273,7 +273,10 @@ after:`)} the concatenated pages are the body, byte for byte. \`look\` also says who and when: \`source\`, \`created\`, \`updated\`, \`aliases\`, \`origin\`. - \`sniff(forest, terms)\` — literal text search inside bodies (substring, - not regex). + not regex). A KIND of node is a filter, not a word: \`type_filter: "media"\` + on \`locate\`/\`sniff\`, \`filter: {"type": "media"}\` on \`scan\` (with + \`recursive: true\` to reach every branch). Sniffing for the word "media" + greps bodies for that word and returns every document that mentions it. - \`scan(forest, parent_id)\`, \`move(forest, id)\` — list a branch's nodes by metadata; follow a node's typed edges. \`scan(forest, "_index", recursive: true)\` maps a whole forest cheaply; to walk one completely, diff --git a/apps/studio/src/views/Ask.jsx b/apps/studio/src/views/Ask.jsx index 720c89d..7f7ecd9 100644 --- a/apps/studio/src/views/Ask.jsx +++ b/apps/studio/src/views/Ask.jsx @@ -14,7 +14,7 @@ import { import { Markdown } from '../design/markdown.jsx' import { Ask as AskIcon, Clock, Collapse, Download, Expand, Eye, Graph as GraphIcon, - Printer, Sparkle, Trash, + Printer, Sparkle, Trash, X, } from '../design/icons.jsx' import { PayloadImage } from './files.jsx' import { evidenceFromHops, mergeEvidence } from '../trailmap.js' @@ -75,6 +75,16 @@ export default function Ask({ forest, grant, me, goto }) { // once at mount; typing never writes it back. const [question, setQuestion] = useState( () => new URLSearchParams(window.location.search).get('q') || '') + // J.5.19: the question has a period. `since`/`until` are PART of the + // question, not a taste — so they ride the address beside `q` (a shared + // link asks the same bounded question), are read exactly as `q` is (once + // at mount, nothing fires, typing writes nothing back), and go through + // loadPrefs/savePrefs NEVER: a window remembered from yesterday opens + // tomorrow's forest empty, which is v0.76's lesson in J.5.4 applied here. + const [since, setSince] = useState( + () => new URLSearchParams(window.location.search).get('since') || '') + const [until, setUntil] = useState( + () => new URLSearchParams(window.location.search).get('until') || '') const [k, setK] = useState(3) // J.10.8: how long an answer this person likes. Restored from the saved // preference at mount; dragging the slider is what writes it back. @@ -208,8 +218,13 @@ export default function Ask({ forest, grant, me, goto }) { // draws — and a current one pays for no extra read at all. fallback = setTimeout(() => { if (arrived) return + // The window rides here too: the fallback draws what the answer + // WILL see (J.5.15 rule 1), and a sweep bounded to June drawn + // from an unbounded harvest is a picture of a retrieval that + // never ran — rule 3's invention, one panel over. api.call(forest, 'harvest', - { query: q, k, ...(hybrid ? { hybrid: true } : {}) }) + { query: q, k, ...(hybrid ? { hybrid: true } : {}), + ...(since ? { since } : {}), ...(until ? { until } : {}) }) .then((data) => { if (!arrived) setPreview(data) }) .catch(() => {}) }, CHANNEL_GRACE_MS) @@ -223,6 +238,13 @@ export default function Ask({ forest, grant, me, goto }) { ...(reply ? { reply_tokens: reply } : {}), ...(hybrid ? { hybrid: true } : {}), ...(hops ? { hops: true } : {}), ...(cache ? {} : { cache: false }), + // J.5.19: C.13.1's own parameters, sent only when set, so a call + // without them is byte-identical to the console's before this + // version. `date_field` is never sent — the console asks when + // material ARRIVED, which is `created`, the engine's default. And + // nothing is validated here: an unparseable bound is the engine's + // E_SCHEMA (C.13.1 rule 4), rendered like any other refusal. + ...(since ? { since } : {}), ...(until ? { until } : {}), } const t0 = performance.now() try { @@ -265,6 +287,12 @@ export default function Ask({ forest, grant, me, goto }) { setHybrid(!!run.params?.hybrid) setHops(!!run.params?.hops) setCache(run.params?.cache !== false) + // The run's own window (J.5.19): a record of a bounded question put + // back unbounded would make "ask again" ask a different question. + // This is the record's, never a preference — a run with no window + // clears the inputs, exactly as it clears `hops`. + setSince(run.params?.since || '') + setUntil(run.params?.until || '') setPreview(null); setLive(null) setResult(run.result) setRestored({ ts: run.ts, model: run.result?.model }) @@ -284,6 +312,9 @@ export default function Ask({ forest, grant, me, goto }) { ...(reply ? { reply_tokens: reply } : {}), ...(hybrid ? { hybrid: true } : {}), ...(hops ? { hops: true } : {}), ...(cache ? {} : { cache: false }), + // J.5.19: the window is carried because everything else the form + // would send is. + ...(since ? { since } : {}), ...(until ? { until } : {}), })}'` return ( @@ -327,6 +358,36 @@ export default function Ask({ forest, grant, me, goto }) { { value: 2, label: '2' }, { value: 3, label: '3' }, { value: 6, label: '6' }, ]} /> + {/* J.5.19: the question's period. Two dates on `created` — + when material ARRIVED, J.5.4's window one console over — + beside the depth because both decide what retrieval may + reach. Deliberately NOT a Toggle in the flags list below + and NOT a preference like the slider beside it: a shared + link must ask the same bounded question, and a window + remembered from yesterday is wrong tomorrow. The inputs + validate nothing (a bad bound is the engine's refusal), + and the answer is labelled by the engine's echo, never by + these strings. */} +
+ {t('ask.window')} + setSince(e.target.value)} /> + → + setUntil(e.target.value)} /> + {/* A way back to the whole forest that does not depend on + the browser's own date widget having one. */} + {(since || until) && ( + + )} +
{/* J.10.8: dragged per person, remembered per person. "Auto" sends nothing — the forest binding's own size rules. */}