askfaro-progressive-context 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- askfaro_progressive_context-0.1.0/.github/workflows/publish.yml +47 -0
- askfaro_progressive_context-0.1.0/.gitignore +7 -0
- askfaro_progressive_context-0.1.0/CHANGELOG.md +123 -0
- askfaro_progressive_context-0.1.0/LICENSE +21 -0
- askfaro_progressive_context-0.1.0/PKG-INFO +144 -0
- askfaro_progressive_context-0.1.0/README.md +112 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/__init__.py +57 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/__init__.py +33 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/_frontmatter.py +62 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/adapters/__init__.py +21 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/adapters/base.py +33 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/adapters/docs.py +61 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/adapters/memory.py +56 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/adapters/skills.py +62 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/adapters/tools.py +82 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/compiler.py +71 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/cost.py +47 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/descriptors.py +329 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/emit.py +120 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/build/ir.py +69 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/cli.py +175 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/eval.py +141 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/llm.py +132 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/navigator.py +98 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/py.typed +0 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/runtime.py +288 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/session.py +100 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/tokenizer.py +36 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/types.py +167 -0
- askfaro_progressive_context-0.1.0/askfaro_progressive_context/validate.py +112 -0
- askfaro_progressive_context-0.1.0/docs/troubleshooting.md +57 -0
- askfaro_progressive_context-0.1.0/examples/skills/cases.json +10 -0
- askfaro_progressive_context-0.1.0/examples/skills/manifest.pcx.4k.json +135 -0
- askfaro_progressive_context-0.1.0/legacy-shim/README.md +16 -0
- askfaro_progressive_context-0.1.0/legacy-shim/faro_progressive_context/__init__.py +51 -0
- askfaro_progressive_context-0.1.0/legacy-shim/pyproject.toml +29 -0
- askfaro_progressive_context-0.1.0/pyproject.toml +47 -0
- askfaro_progressive_context-0.1.0/schema/pcx-0.1.schema.json +123 -0
- askfaro_progressive_context-0.1.0/tests/conftest.py +23 -0
- askfaro_progressive_context-0.1.0/tests/fixtures/docs/guide/getting-started.md +4 -0
- askfaro_progressive_context-0.1.0/tests/fixtures/docs/reference/cli.md +4 -0
- askfaro_progressive_context-0.1.0/tests/fixtures/memory/likes-tea.md +8 -0
- askfaro_progressive_context-0.1.0/tests/fixtures/memory/ship-on-fridays.md +8 -0
- askfaro_progressive_context-0.1.0/tests/fixtures/skills/draft-post.md +11 -0
- askfaro_progressive_context-0.1.0/tests/fixtures/skills/schedule-post.md +11 -0
- askfaro_progressive_context-0.1.0/tests/fixtures/skills/web-research.md +11 -0
- askfaro_progressive_context-0.1.0/tests/fixtures/tools.json +27 -0
- askfaro_progressive_context-0.1.0/tests/test_adapters.py +44 -0
- askfaro_progressive_context-0.1.0/tests/test_build.py +101 -0
- askfaro_progressive_context-0.1.0/tests/test_eval.py +39 -0
- askfaro_progressive_context-0.1.0/tests/test_incremental.py +80 -0
- askfaro_progressive_context-0.1.0/tests/test_runtime.py +77 -0
- askfaro_progressive_context-0.1.0/tests/test_session.py +64 -0
- askfaro_progressive_context-0.1.0/tests/test_validate.py +43 -0
- askfaro_progressive_context-0.1.0/tests/test_views.py +51 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI via OIDC trusted publishing (no API token stored).
|
|
4
|
+
# Configure the trusted publisher on PyPI once (Project → Settings → Publishing):
|
|
5
|
+
# owner: poolside-ventures repo: askfaro-progressive-context
|
|
6
|
+
# workflow: publish.yml environment: pypi
|
|
7
|
+
# Then publishing happens automatically when a GitHub Release is published.
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
release:
|
|
11
|
+
types: [published]
|
|
12
|
+
workflow_dispatch: {}
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
test:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: astral-sh/setup-uv@v6
|
|
20
|
+
- run: uv venv --python 3.12
|
|
21
|
+
- run: uv pip install -e ".[dev,schema,llm]"
|
|
22
|
+
- run: uv run pytest -q
|
|
23
|
+
|
|
24
|
+
build:
|
|
25
|
+
needs: test
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- uses: astral-sh/setup-uv@v6
|
|
30
|
+
- run: uv build
|
|
31
|
+
- uses: actions/upload-artifact@v4
|
|
32
|
+
with:
|
|
33
|
+
name: dist
|
|
34
|
+
path: dist/
|
|
35
|
+
|
|
36
|
+
publish:
|
|
37
|
+
needs: build
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
environment: pypi
|
|
40
|
+
permissions:
|
|
41
|
+
id-token: write # OIDC token for trusted publishing
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/download-artifact@v4
|
|
44
|
+
with:
|
|
45
|
+
name: dist
|
|
46
|
+
path: dist/
|
|
47
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- **Docs: document `NavSession`.** Added a README section for the agent-loop API
|
|
6
|
+
(`index` / `look` / `open` / `close`, the `local` / `remote` modes, and the
|
|
7
|
+
`shown_tokens` / `budget_remaining` accounting). No functional change.
|
|
8
|
+
|
|
9
|
+
## 0.1.0 - renamed to askfaro-progressive-context (2026-06-17)
|
|
10
|
+
|
|
11
|
+
- **Package renamed.** Distribution `faro-progressive-context` is now
|
|
12
|
+
`askfaro-progressive-context`, and the import name `faro_progressive_context`
|
|
13
|
+
is now `askfaro_progressive_context`, to match the AskFaro brand. Update
|
|
14
|
+
imports and `pip install askfaro-progressive-context`. The old name ships one
|
|
15
|
+
final `0.0.8` release that re-exports this package and warns on import. No
|
|
16
|
+
functional change.
|
|
17
|
+
|
|
18
|
+
## 0.0.7 — first clean public release (2026-06-11)
|
|
19
|
+
|
|
20
|
+
- Genericized examples and docs for the public release (neutral fixtures; no
|
|
21
|
+
product-specific references). No functional change vs 0.0.6. 0.0.6 is yanked.
|
|
22
|
+
|
|
23
|
+
## 0.0.6 — incremental rebuilds (2026-06-11)
|
|
24
|
+
|
|
25
|
+
- **Reuse descriptors for unchanged content.** `compile_source(prior_manifest=...)`
|
|
26
|
+
builds a `content_hash`-keyed cache (`cache_from_manifest`) and reuses the
|
|
27
|
+
prior descriptor for any node whose content is unchanged. Because a node's
|
|
28
|
+
hash rolls up its whole subtree, a change re-describes only that node and its
|
|
29
|
+
ancestor path; only sibling groups whose parent changed are re-contrasted, and
|
|
30
|
+
only regenerated nodes are re-graded. Stats: `reused` / `regenerated`.
|
|
31
|
+
- Net effect: an unchanged full-catalog rebuild (213 tools) makes **0 model
|
|
32
|
+
calls**; a one-tool change makes a handful — so the LLM-quality manifest is
|
|
33
|
+
cheap to keep fresh on every catalog change (seed once, refresh incrementally).
|
|
34
|
+
|
|
35
|
+
## 0.0.5 — self-describing manifests + concurrent builds (unreleased)
|
|
36
|
+
|
|
37
|
+
- **Self-description (`usage`).** Every manifest now ships a top-level `usage`
|
|
38
|
+
block — a plain-language explanation of the navigation protocol — so a cold
|
|
39
|
+
external agent that has never seen the format knows how to navigate it
|
|
40
|
+
(descriptors vs content, `node://` refs, the budget, index/open/look). The
|
|
41
|
+
llms.txt export gets a matching "How to read this index" header.
|
|
42
|
+
- **Concurrent descriptor generation.** `generate_descriptors`/`compile_source`
|
|
43
|
+
take `max_workers`; the three phases parallelize (level-by-level so a branch's
|
|
44
|
+
children are always ready). Brings a ~280-node catalog build from ~35 min to
|
|
45
|
+
~8 min. Deterministic-equivalent to sequential.
|
|
46
|
+
- **More robust LLM parsing.** Extract the first balanced JSON object (handles
|
|
47
|
+
trailing "Extra data"); `describe_leaf/branch` degrade to a hint-based
|
|
48
|
+
descriptor on parse failure instead of crashing a large build.
|
|
49
|
+
|
|
50
|
+
## 0.0.4 — navigation policy (NavSession + modes) (unreleased)
|
|
51
|
+
|
|
52
|
+
- **`NavSession`** — the agent-facing navigation policy with three verbs:
|
|
53
|
+
`index()` (frontier, shortest-useful view), `look(ids)` (escalate candidates
|
|
54
|
+
to the full descriptor without committing), `open(id)` (drill a branch /
|
|
55
|
+
splice a leaf). The model's choice of verb is the confidence signal.
|
|
56
|
+
- **Explicit `local`/`remote` modes** encoding the tokens-vs-round-trips
|
|
57
|
+
tradeoff: `local` opens at a `brief` index and escalates (round-trips ~free);
|
|
58
|
+
`remote` discloses a `full` index and inlines small leaves to cut round-trips.
|
|
59
|
+
- Runtime is now **view-level aware** in its budget accounting (`view_level`),
|
|
60
|
+
with `disclose_more()` charging only the escalation delta. `shown_tokens`
|
|
61
|
+
on the session is the real length the model saw.
|
|
62
|
+
|
|
63
|
+
## 0.0.3 — length, escalation, locality, error guidance (unreleased)
|
|
64
|
+
|
|
65
|
+
- **Length is now a first-class metric.** The eval reports `first_view_tokens`,
|
|
66
|
+
`tokens_to_answer`, and a `disclosure_ratio` vs loading everything — alongside
|
|
67
|
+
accuracy. (bake-off (24-tool catalog): pcx reaches the answer at ~2.2k tokens vs
|
|
68
|
+
~15k to load all schemas, 6.9× less, while being more accurate *and* shorter.)
|
|
69
|
+
- **Shortest-first-view + escalation.** `Runtime.frontier_view(level)` /
|
|
70
|
+
`frontier_tokens(level)` render the frontier at `title` → `brief` → `full`.
|
|
71
|
+
A `title`-first view is ~14× smaller than `full` on a 24-tool catalog; the agent
|
|
72
|
+
escalates only when it can't decide.
|
|
73
|
+
- **Latency/locality.** `Runtime(resolver=...)` resolves leaves from a local
|
|
74
|
+
in-memory store so `expand` is an O(1) splice, not a network fetch; missing
|
|
75
|
+
leaves error loudly. `dict_resolver` for the common case.
|
|
76
|
+
- **Error guidance.** Actionable messages for the common setup mistakes
|
|
77
|
+
(missing `[llm]` extra, no endpoint/model, empty API key, unknown adapter
|
|
78
|
+
kind, missing source, reserve ≥ budget) + `docs/troubleshooting.md`.
|
|
79
|
+
|
|
80
|
+
## 0.0.2 — Phase 1 (unreleased)
|
|
81
|
+
|
|
82
|
+
The compiler: `pcx build` turns content into manifest variants.
|
|
83
|
+
|
|
84
|
+
- **Adapters** for the four already-hierarchical source kinds — `tools` (JSON
|
|
85
|
+
schemas, grouped by namespace), `docs` (markdown tree), `skills` (per-skill
|
|
86
|
+
markdown, grouped by category), `memory` (one-fact files, grouped by type).
|
|
87
|
+
No clustering/structure-inference yet (that's Phase 3).
|
|
88
|
+
- **Descriptor engine** (the moat): bottom-up generation, a contrastive sibling
|
|
89
|
+
pass that rewrites each `when` to discriminate from its siblings, and a
|
|
90
|
+
self-grade + repair loop. `DescriptorModel` is pluggable — `FakeDescriptorModel`
|
|
91
|
+
for offline/CI, `LLMDescriptorModel` for a real (Flash-class) model via any
|
|
92
|
+
OpenAI-compatible endpoint.
|
|
93
|
+
- **Cost annotation**: per-node `tokens`/`desc_tokens`, bottom-up
|
|
94
|
+
`subtree_tokens` rollup, and `content_hash` for incremental rebuilds.
|
|
95
|
+
- **Emit**: one manifest per `--budgets` variant + an `llms.txt` export.
|
|
96
|
+
- **CLI**: `pcx build <path> --kind ... --budgets ... [--fake | --endpoint --model]`.
|
|
97
|
+
|
|
98
|
+
### Not yet
|
|
99
|
+
- Per-budget frontier-depth/verbosity shaping (variants currently share the tree).
|
|
100
|
+
- `website`/`file` adapters + embedding-based grouping (Phase 3).
|
|
101
|
+
- The host-side wiring + the real bake-off vs flat manifests (consumer task).
|
|
102
|
+
|
|
103
|
+
## 0.0.1 — Phase 0 (unreleased)
|
|
104
|
+
|
|
105
|
+
Spec freeze + eval harness, ahead of the compiler.
|
|
106
|
+
|
|
107
|
+
- **Format**: `pcx` v0.1 progressive-context manifest, defined as JSON Schema
|
|
108
|
+
(`schema/pcx-0.1.schema.json`). Tiered nodes with `what`/`when` descriptors,
|
|
109
|
+
per-node and subtree token costs, branch/leaf split, verbatim leaf pointers,
|
|
110
|
+
and pre-generated per-budget variants.
|
|
111
|
+
- **Validation**: zero-dependency structural checks + optional full JSON Schema
|
|
112
|
+
validation (`pcx validate`).
|
|
113
|
+
- **Expansion runtime**: `peek` / `expand` / `collapse` / `search` with hard
|
|
114
|
+
budget enforcement, a runtime `reserve` for host headroom, optional LRU
|
|
115
|
+
auto-eviction, and a pluggable search backend.
|
|
116
|
+
- **Navigators**: `KeywordNavigator` (deterministic, offline baseline) and
|
|
117
|
+
`LLMNavigator` (bring-your-own model).
|
|
118
|
+
- **Eval harness**: `navigation-success @ budget`, first-hop precision, and
|
|
119
|
+
average hops (`pcx eval`), with a `skills` example fixture.
|
|
120
|
+
|
|
121
|
+
### Not yet
|
|
122
|
+
- `pcx build` — the compiler (adapters, descriptor generation, cost annotation).
|
|
123
|
+
Lands in Phase 1.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Faro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: askfaro-progressive-context
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Compile any content into a tiered, budget-aware, agent-navigable progressive-disclosure manifest, plus an on-demand expansion protocol — for small/on-device context windows
|
|
5
|
+
Project-URL: Homepage, https://github.com/poolside-ventures/askfaro-progressive-context
|
|
6
|
+
Project-URL: Repository, https://github.com/poolside-ventures/askfaro-progressive-context
|
|
7
|
+
Project-URL: Issues, https://github.com/poolside-ventures/askfaro-progressive-context/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/poolside-ventures/askfaro-progressive-context/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Faro
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,context,context-window,llm,llms-txt,on-device,progressive-disclosure,tokens
|
|
13
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Requires-Dist: pyyaml>=6; extra == 'dev'
|
|
23
|
+
Provides-Extra: llm
|
|
24
|
+
Requires-Dist: httpx>=0.27; extra == 'llm'
|
|
25
|
+
Provides-Extra: schema
|
|
26
|
+
Requires-Dist: jsonschema>=4.21; extra == 'schema'
|
|
27
|
+
Provides-Extra: tokenize
|
|
28
|
+
Requires-Dist: tiktoken>=0.7; extra == 'tokenize'
|
|
29
|
+
Provides-Extra: yaml
|
|
30
|
+
Requires-Dist: pyyaml>=6; extra == 'yaml'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# askfaro-progressive-context
|
|
34
|
+
|
|
35
|
+
**Compile any content into a tiered, budget-aware, agent-navigable progressive-disclosure manifest — for small / on-device context windows.**
|
|
36
|
+
|
|
37
|
+
On-device models have tiny context windows (~4k today, ~32k near-term). Stuffing everything in — or lossily compressing it — loses information. The alternative is **progressive disclosure**: give the model a compact, accurate *index* of what exists and *when each piece is relevant*, then let it fetch detail on demand within a hard token budget.
|
|
38
|
+
|
|
39
|
+
`askfaro-progressive-context` is the open-source compiler + format + runtime for that index. It is the **agent-navigated** half of Faro's context tooling (the model reads the index and decides what to expand); its sibling [`askfaro-embedded-search`](https://github.com/poolside-ventures/askfaro-embedded-search) is the retrieval-driven half. The two stay independent — this library has no hard dependency on it.
|
|
40
|
+
|
|
41
|
+
> Status: **Phase 1 (pre-alpha).** The format, expansion runtime, eval harness, **and the compiler (`pcx build`)** are here and tested — adapters for `tools`/`docs`/`skills`/`memory`, the descriptor engine (bottom-up + contrastive + self-grade), cost annotation, and per-budget emit. Still to come: per-budget frontier shaping, `website`/`file` adapters with clustering (Phase 3), and the hosted Faro registry (Phase 5).
|
|
42
|
+
|
|
43
|
+
## The idea in one screen
|
|
44
|
+
|
|
45
|
+
A **progressive-context manifest** (`pcx.json`) is a tree of nodes. Every node carries:
|
|
46
|
+
|
|
47
|
+
- a **descriptor** — `what` (one line: what this is) and `when` (one line: when it's relevant). These are the *navigation index*, and their quality is the whole game.
|
|
48
|
+
- **token costs** — so the runtime can plan expansion against a budget *without fetching anything*.
|
|
49
|
+
- either **children** (a branch) or a **payload pointer** (a leaf). Leaves are never inlined and always **verbatim** — no information is lost to a summary.
|
|
50
|
+
|
|
51
|
+
Variants are **pre-generated per budget** (`pcx.4k.json`, `pcx.32k.json`, …); budgets are arbitrary integers, so a developer who needs headroom for their own content can build a `31k` variant — or reserve it at runtime.
|
|
52
|
+
|
|
53
|
+
## What's here (Phase 0)
|
|
54
|
+
|
|
55
|
+
| Module | What it does |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `schema/pcx-0.1.schema.json` | the format, as JSON Schema |
|
|
58
|
+
| `askfaro_progressive_context.types` | `Manifest` / `Node` / `Payload` dataclasses |
|
|
59
|
+
| `askfaro_progressive_context.validate` | structural (zero-dep) + JSON Schema validation |
|
|
60
|
+
| `askfaro_progressive_context.runtime` | the expansion protocol: `peek` / `expand` / `collapse` / `search`, with **hard budget enforcement** and a runtime `reserve` |
|
|
61
|
+
| `askfaro_progressive_context.navigator` | `KeywordNavigator` (deterministic baseline, no model) and `LLMNavigator` (bring your own `complete()`) |
|
|
62
|
+
| `askfaro_progressive_context.eval` | the **`navigation-success @ budget`** harness — the headline quality metric |
|
|
63
|
+
|
|
64
|
+
## Quick start
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install -e ".[dev,schema]"
|
|
68
|
+
|
|
69
|
+
# validate a manifest
|
|
70
|
+
pcx validate examples/skills/manifest.pcx.4k.json --schema
|
|
71
|
+
|
|
72
|
+
# score navigation-success @ budget with the deterministic baseline navigator
|
|
73
|
+
pcx eval examples/skills/manifest.pcx.4k.json examples/skills/cases.json -v
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from askfaro_progressive_context import Manifest, Runtime
|
|
78
|
+
|
|
79
|
+
m = Manifest.from_dict(json.load(open("examples/skills/manifest.pcx.4k.json")))
|
|
80
|
+
rt = Runtime(m, reserve=1024) # leave 1k for your own content
|
|
81
|
+
|
|
82
|
+
rt.peek() # frontier: tier-1 descriptors + budget_remaining
|
|
83
|
+
rt.expand("recurring") # reveal a branch's children (charged against budget)
|
|
84
|
+
ref = rt.expand("recurring.create") # splice a leaf's verbatim payload; raises if over budget
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## The expansion protocol
|
|
88
|
+
|
|
89
|
+
The runtime — not the model — is the budget authority. `effective_budget = variant.budget − reserve`, and every `expand` is checked against it. When full it auto-collapses LRU leaves (opt-in) or refuses and tells the agent to choose. **The budget is never silently exceeded.**
|
|
90
|
+
|
|
91
|
+
## Driving it from an agent loop: `NavSession`
|
|
92
|
+
|
|
93
|
+
`Runtime` is the low-level budget authority; `NavSession` wraps it with the three verbs an agent loop actually drives, plus mode-aware defaults. The model's *choice of verb is the confidence signal* — there is no threshold to tune:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from askfaro_progressive_context import Manifest, NavSession
|
|
97
|
+
|
|
98
|
+
s = NavSession(manifest, mode="local", reserve=1024)
|
|
99
|
+
|
|
100
|
+
s.index() # current frontier, shortest-useful view first
|
|
101
|
+
s.look(["recurring", "one_off"]) # escalate candidates to full descriptors WITHOUT opening them
|
|
102
|
+
s.open("recurring") # branch -> drill into its children;
|
|
103
|
+
# leaf -> splice the verbatim content (budget-enforced)
|
|
104
|
+
s.close("recurring") # collapse a node to reclaim budget
|
|
105
|
+
|
|
106
|
+
s.shown_tokens # everything the model has seen this session (the real "length")
|
|
107
|
+
s.budget_remaining
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
If the index is enough, the model calls `open`; if it can't decide, it calls `look` first. **Modes** encode the tokens-vs-round-trips tradeoff:
|
|
111
|
+
|
|
112
|
+
| mode | frontier view | small leaves | use when |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| `local` (default) | `brief` | resolved on demand (O(1) resident splice) | on-device / resident manifest — round-trips are ~free, so take many tiny steps |
|
|
115
|
+
| `remote` | `full` | inlined into `index()` (≤200 tokens) | network-backed — each hop costs latency, so disclose more per step to need fewer |
|
|
116
|
+
|
|
117
|
+
Pass `config=ModeConfig(...)` for a custom policy; an unknown `mode` raises with the valid options.
|
|
118
|
+
|
|
119
|
+
## Why a benchmark, not vibes
|
|
120
|
+
|
|
121
|
+
The moat is descriptor quality, so quality is measured, not asserted. The eval harness gives a navigator *only* the manifest and a budget plus `(query → correct leaf)` cases, and reports **navigation-success @ budget**, **first-hop precision**, and **average hops**. The deterministic `KeywordNavigator` establishes an offline floor; swap in an `LLMNavigator` to score a real model.
|
|
122
|
+
|
|
123
|
+
## Length is the point
|
|
124
|
+
|
|
125
|
+
Accuracy without length misses why this exists. The eval reports
|
|
126
|
+
`first_view_tokens` and `tokens_to_answer` next to accuracy, and the runtime
|
|
127
|
+
renders the frontier at progressively shorter levels (`title` → `brief` →
|
|
128
|
+
`full`) so the agent opens with the **shortest** view and escalates only when
|
|
129
|
+
unsure. On a 24-tool catalog, a `title`-first view is ~14× smaller than the full
|
|
130
|
+
descriptor set, and the model reaches the right tool having seen ~6.9× less
|
|
131
|
+
context than loading every schema.
|
|
132
|
+
|
|
133
|
+
Progressive disclosure trades tokens for round-trips, so the whole artifact is
|
|
134
|
+
meant to stay **resident**: `Runtime(resolver=...)` resolves leaves from a local
|
|
135
|
+
store, making every `expand` an O(1) splice rather than a network fetch.
|
|
136
|
+
|
|
137
|
+
## Troubleshooting
|
|
138
|
+
|
|
139
|
+
Setup mistakes fail with actionable messages; see [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
|
140
|
+
for the full table (missing extras, model config, architecture mismatches).
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT © Faro
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# askfaro-progressive-context
|
|
2
|
+
|
|
3
|
+
**Compile any content into a tiered, budget-aware, agent-navigable progressive-disclosure manifest — for small / on-device context windows.**
|
|
4
|
+
|
|
5
|
+
On-device models have tiny context windows (~4k today, ~32k near-term). Stuffing everything in — or lossily compressing it — loses information. The alternative is **progressive disclosure**: give the model a compact, accurate *index* of what exists and *when each piece is relevant*, then let it fetch detail on demand within a hard token budget.
|
|
6
|
+
|
|
7
|
+
`askfaro-progressive-context` is the open-source compiler + format + runtime for that index. It is the **agent-navigated** half of Faro's context tooling (the model reads the index and decides what to expand); its sibling [`askfaro-embedded-search`](https://github.com/poolside-ventures/askfaro-embedded-search) is the retrieval-driven half. The two stay independent — this library has no hard dependency on it.
|
|
8
|
+
|
|
9
|
+
> Status: **Phase 1 (pre-alpha).** The format, expansion runtime, eval harness, **and the compiler (`pcx build`)** are here and tested — adapters for `tools`/`docs`/`skills`/`memory`, the descriptor engine (bottom-up + contrastive + self-grade), cost annotation, and per-budget emit. Still to come: per-budget frontier shaping, `website`/`file` adapters with clustering (Phase 3), and the hosted Faro registry (Phase 5).
|
|
10
|
+
|
|
11
|
+
## The idea in one screen
|
|
12
|
+
|
|
13
|
+
A **progressive-context manifest** (`pcx.json`) is a tree of nodes. Every node carries:
|
|
14
|
+
|
|
15
|
+
- a **descriptor** — `what` (one line: what this is) and `when` (one line: when it's relevant). These are the *navigation index*, and their quality is the whole game.
|
|
16
|
+
- **token costs** — so the runtime can plan expansion against a budget *without fetching anything*.
|
|
17
|
+
- either **children** (a branch) or a **payload pointer** (a leaf). Leaves are never inlined and always **verbatim** — no information is lost to a summary.
|
|
18
|
+
|
|
19
|
+
Variants are **pre-generated per budget** (`pcx.4k.json`, `pcx.32k.json`, …); budgets are arbitrary integers, so a developer who needs headroom for their own content can build a `31k` variant — or reserve it at runtime.
|
|
20
|
+
|
|
21
|
+
## What's here (Phase 0)
|
|
22
|
+
|
|
23
|
+
| Module | What it does |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `schema/pcx-0.1.schema.json` | the format, as JSON Schema |
|
|
26
|
+
| `askfaro_progressive_context.types` | `Manifest` / `Node` / `Payload` dataclasses |
|
|
27
|
+
| `askfaro_progressive_context.validate` | structural (zero-dep) + JSON Schema validation |
|
|
28
|
+
| `askfaro_progressive_context.runtime` | the expansion protocol: `peek` / `expand` / `collapse` / `search`, with **hard budget enforcement** and a runtime `reserve` |
|
|
29
|
+
| `askfaro_progressive_context.navigator` | `KeywordNavigator` (deterministic baseline, no model) and `LLMNavigator` (bring your own `complete()`) |
|
|
30
|
+
| `askfaro_progressive_context.eval` | the **`navigation-success @ budget`** harness — the headline quality metric |
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install -e ".[dev,schema]"
|
|
36
|
+
|
|
37
|
+
# validate a manifest
|
|
38
|
+
pcx validate examples/skills/manifest.pcx.4k.json --schema
|
|
39
|
+
|
|
40
|
+
# score navigation-success @ budget with the deterministic baseline navigator
|
|
41
|
+
pcx eval examples/skills/manifest.pcx.4k.json examples/skills/cases.json -v
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from askfaro_progressive_context import Manifest, Runtime
|
|
46
|
+
|
|
47
|
+
m = Manifest.from_dict(json.load(open("examples/skills/manifest.pcx.4k.json")))
|
|
48
|
+
rt = Runtime(m, reserve=1024) # leave 1k for your own content
|
|
49
|
+
|
|
50
|
+
rt.peek() # frontier: tier-1 descriptors + budget_remaining
|
|
51
|
+
rt.expand("recurring") # reveal a branch's children (charged against budget)
|
|
52
|
+
ref = rt.expand("recurring.create") # splice a leaf's verbatim payload; raises if over budget
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## The expansion protocol
|
|
56
|
+
|
|
57
|
+
The runtime — not the model — is the budget authority. `effective_budget = variant.budget − reserve`, and every `expand` is checked against it. When full it auto-collapses LRU leaves (opt-in) or refuses and tells the agent to choose. **The budget is never silently exceeded.**
|
|
58
|
+
|
|
59
|
+
## Driving it from an agent loop: `NavSession`
|
|
60
|
+
|
|
61
|
+
`Runtime` is the low-level budget authority; `NavSession` wraps it with the three verbs an agent loop actually drives, plus mode-aware defaults. The model's *choice of verb is the confidence signal* — there is no threshold to tune:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from askfaro_progressive_context import Manifest, NavSession
|
|
65
|
+
|
|
66
|
+
s = NavSession(manifest, mode="local", reserve=1024)
|
|
67
|
+
|
|
68
|
+
s.index() # current frontier, shortest-useful view first
|
|
69
|
+
s.look(["recurring", "one_off"]) # escalate candidates to full descriptors WITHOUT opening them
|
|
70
|
+
s.open("recurring") # branch -> drill into its children;
|
|
71
|
+
# leaf -> splice the verbatim content (budget-enforced)
|
|
72
|
+
s.close("recurring") # collapse a node to reclaim budget
|
|
73
|
+
|
|
74
|
+
s.shown_tokens # everything the model has seen this session (the real "length")
|
|
75
|
+
s.budget_remaining
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
If the index is enough, the model calls `open`; if it can't decide, it calls `look` first. **Modes** encode the tokens-vs-round-trips tradeoff:
|
|
79
|
+
|
|
80
|
+
| mode | frontier view | small leaves | use when |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| `local` (default) | `brief` | resolved on demand (O(1) resident splice) | on-device / resident manifest — round-trips are ~free, so take many tiny steps |
|
|
83
|
+
| `remote` | `full` | inlined into `index()` (≤200 tokens) | network-backed — each hop costs latency, so disclose more per step to need fewer |
|
|
84
|
+
|
|
85
|
+
Pass `config=ModeConfig(...)` for a custom policy; an unknown `mode` raises with the valid options.
|
|
86
|
+
|
|
87
|
+
## Why a benchmark, not vibes
|
|
88
|
+
|
|
89
|
+
The moat is descriptor quality, so quality is measured, not asserted. The eval harness gives a navigator *only* the manifest and a budget plus `(query → correct leaf)` cases, and reports **navigation-success @ budget**, **first-hop precision**, and **average hops**. The deterministic `KeywordNavigator` establishes an offline floor; swap in an `LLMNavigator` to score a real model.
|
|
90
|
+
|
|
91
|
+
## Length is the point
|
|
92
|
+
|
|
93
|
+
Accuracy without length misses why this exists. The eval reports
|
|
94
|
+
`first_view_tokens` and `tokens_to_answer` next to accuracy, and the runtime
|
|
95
|
+
renders the frontier at progressively shorter levels (`title` → `brief` →
|
|
96
|
+
`full`) so the agent opens with the **shortest** view and escalates only when
|
|
97
|
+
unsure. On a 24-tool catalog, a `title`-first view is ~14× smaller than the full
|
|
98
|
+
descriptor set, and the model reaches the right tool having seen ~6.9× less
|
|
99
|
+
context than loading every schema.
|
|
100
|
+
|
|
101
|
+
Progressive disclosure trades tokens for round-trips, so the whole artifact is
|
|
102
|
+
meant to stay **resident**: `Runtime(resolver=...)` resolves leaves from a local
|
|
103
|
+
store, making every `expand` an O(1) splice rather than a network fetch.
|
|
104
|
+
|
|
105
|
+
## Troubleshooting
|
|
106
|
+
|
|
107
|
+
Setup mistakes fail with actionable messages; see [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
|
108
|
+
for the full table (missing extras, model config, architecture mismatches).
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT © Faro
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""askfaro-progressive-context: compile any content into a tiered, budget-aware,
|
|
2
|
+
agent-navigable progressive-disclosure manifest, plus an expansion protocol."""
|
|
3
|
+
|
|
4
|
+
from .eval import CaseResult, EvalReport, NavCase, run_case, run_eval
|
|
5
|
+
from .llm import LLMClient, OpenAICompatibleClient
|
|
6
|
+
from .navigator import KeywordNavigator, LLMNavigator, Navigator
|
|
7
|
+
from .runtime import (
|
|
8
|
+
VIEW_LEVELS,
|
|
9
|
+
BudgetExceeded,
|
|
10
|
+
FrontierEntry,
|
|
11
|
+
LeafResolver,
|
|
12
|
+
Runtime,
|
|
13
|
+
SearchBackend,
|
|
14
|
+
dict_resolver,
|
|
15
|
+
render_descriptor,
|
|
16
|
+
)
|
|
17
|
+
from .session import LOCAL, REMOTE, ModeConfig, NavSession
|
|
18
|
+
from .tokenizer import make_tokenizer
|
|
19
|
+
from .types import PROTOCOL_USAGE, Manifest, Node, Payload, Variant, estimate_tokens
|
|
20
|
+
from .validate import schema_errors, structural_errors, validate
|
|
21
|
+
|
|
22
|
+
__version__ = "0.0.7"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"BudgetExceeded",
|
|
26
|
+
"CaseResult",
|
|
27
|
+
"EvalReport",
|
|
28
|
+
"FrontierEntry",
|
|
29
|
+
"KeywordNavigator",
|
|
30
|
+
"LLMClient",
|
|
31
|
+
"LLMNavigator",
|
|
32
|
+
"LOCAL",
|
|
33
|
+
"REMOTE",
|
|
34
|
+
"LeafResolver",
|
|
35
|
+
"Manifest",
|
|
36
|
+
"ModeConfig",
|
|
37
|
+
"NavCase",
|
|
38
|
+
"NavSession",
|
|
39
|
+
"Navigator",
|
|
40
|
+
"Node",
|
|
41
|
+
"OpenAICompatibleClient",
|
|
42
|
+
"PROTOCOL_USAGE",
|
|
43
|
+
"Payload",
|
|
44
|
+
"Runtime",
|
|
45
|
+
"SearchBackend",
|
|
46
|
+
"VIEW_LEVELS",
|
|
47
|
+
"Variant",
|
|
48
|
+
"dict_resolver",
|
|
49
|
+
"estimate_tokens",
|
|
50
|
+
"make_tokenizer",
|
|
51
|
+
"render_descriptor",
|
|
52
|
+
"run_case",
|
|
53
|
+
"run_eval",
|
|
54
|
+
"schema_errors",
|
|
55
|
+
"structural_errors",
|
|
56
|
+
"validate",
|
|
57
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The compiler: source content -> annotated tree -> pcx manifest variants.
|
|
2
|
+
|
|
3
|
+
Pipeline: an Adapter yields a SourceTree (native structure, verbatim leaves),
|
|
4
|
+
the descriptor engine generates what/when/keywords, cost annotation tokenizes
|
|
5
|
+
and rolls up subtree costs, and emit writes one manifest per budget variant
|
|
6
|
+
plus an llms.txt export.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .compiler import BuildResult, compile_source
|
|
10
|
+
from .descriptors import (
|
|
11
|
+
Descriptor,
|
|
12
|
+
DescriptorModel,
|
|
13
|
+
FakeDescriptorModel,
|
|
14
|
+
Grade,
|
|
15
|
+
LLMDescriptorModel,
|
|
16
|
+
cache_from_manifest,
|
|
17
|
+
generate_descriptors,
|
|
18
|
+
)
|
|
19
|
+
from .ir import SourceNode, SourceTree
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BuildResult",
|
|
23
|
+
"Descriptor",
|
|
24
|
+
"DescriptorModel",
|
|
25
|
+
"FakeDescriptorModel",
|
|
26
|
+
"Grade",
|
|
27
|
+
"LLMDescriptorModel",
|
|
28
|
+
"SourceNode",
|
|
29
|
+
"SourceTree",
|
|
30
|
+
"cache_from_manifest",
|
|
31
|
+
"compile_source",
|
|
32
|
+
"generate_descriptors",
|
|
33
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Tiny frontmatter reader for markdown sources.
|
|
2
|
+
|
|
3
|
+
Uses PyYAML if available; otherwise a minimal parser that handles the shapes
|
|
4
|
+
our adapters need: top-level `key: value`, inline lists `[a, b]`, and a single
|
|
5
|
+
nested mapping block (e.g. a one-fact memory store's `metadata:`). Keeps the core
|
|
6
|
+
dependency-free.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
15
|
+
if not text.startswith("---"):
|
|
16
|
+
return {}, text
|
|
17
|
+
end = text.find("\n---", 3)
|
|
18
|
+
if end == -1:
|
|
19
|
+
return {}, text
|
|
20
|
+
block = text[3:end].strip("\n")
|
|
21
|
+
body = text[end + 4 :].lstrip("\n")
|
|
22
|
+
return _parse_yaml(block), body
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_yaml(block: str) -> dict[str, Any]:
|
|
26
|
+
try:
|
|
27
|
+
import yaml # type: ignore
|
|
28
|
+
|
|
29
|
+
data = yaml.safe_load(block)
|
|
30
|
+
return data if isinstance(data, dict) else {}
|
|
31
|
+
except ImportError:
|
|
32
|
+
return _minimal_parse(block)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _scalar(v: str) -> Any:
|
|
36
|
+
v = v.strip()
|
|
37
|
+
if v.startswith("[") and v.endswith("]"):
|
|
38
|
+
inner = v[1:-1].strip()
|
|
39
|
+
return [x.strip().strip("\"'") for x in inner.split(",")] if inner else []
|
|
40
|
+
return v.strip("\"'")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _minimal_parse(block: str) -> dict[str, Any]:
|
|
44
|
+
out: dict[str, Any] = {}
|
|
45
|
+
parent: str | None = None
|
|
46
|
+
for line in block.splitlines():
|
|
47
|
+
if not line.strip() or line.strip().startswith("#"):
|
|
48
|
+
continue
|
|
49
|
+
indented = line[0] in " \t"
|
|
50
|
+
key, _, val = line.strip().partition(":")
|
|
51
|
+
key = key.strip()
|
|
52
|
+
if indented and parent is not None:
|
|
53
|
+
if not isinstance(out.get(parent), dict):
|
|
54
|
+
out[parent] = {}
|
|
55
|
+
out[parent][key] = _scalar(val)
|
|
56
|
+
elif val.strip() == "":
|
|
57
|
+
out[key] = {}
|
|
58
|
+
parent = key
|
|
59
|
+
else:
|
|
60
|
+
out[key] = _scalar(val)
|
|
61
|
+
parent = None
|
|
62
|
+
return out
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Adapters turn a source on disk into a SourceTree.
|
|
2
|
+
|
|
3
|
+
Phase 1 ships the four already-hierarchical kinds (no structure inference):
|
|
4
|
+
docs, skills, tools, memory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .base import Adapter, get_adapter, register_adapter
|
|
8
|
+
from .docs import DocsAdapter
|
|
9
|
+
from .memory import MemoryAdapter
|
|
10
|
+
from .skills import SkillsAdapter
|
|
11
|
+
from .tools import ToolsAdapter
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Adapter",
|
|
15
|
+
"DocsAdapter",
|
|
16
|
+
"MemoryAdapter",
|
|
17
|
+
"SkillsAdapter",
|
|
18
|
+
"ToolsAdapter",
|
|
19
|
+
"get_adapter",
|
|
20
|
+
"register_adapter",
|
|
21
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from ..ir import SourceTree
|
|
7
|
+
|
|
8
|
+
_REGISTRY: dict[str, "Adapter"] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Adapter(Protocol):
|
|
12
|
+
kind: str
|
|
13
|
+
|
|
14
|
+
def load(self, path: Path, *, source_id: str | None = None) -> SourceTree:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_adapter(adapter: "Adapter") -> "Adapter":
|
|
19
|
+
_REGISTRY[adapter.kind] = adapter
|
|
20
|
+
return adapter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_adapter(kind: str) -> "Adapter":
|
|
24
|
+
if kind not in _REGISTRY:
|
|
25
|
+
raise KeyError(f"unknown adapter kind {kind!r}; known: {sorted(_REGISTRY)}")
|
|
26
|
+
return _REGISTRY[kind]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def slugify(text: str) -> str:
|
|
30
|
+
out = "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
|
|
31
|
+
while "--" in out:
|
|
32
|
+
out = out.replace("--", "-")
|
|
33
|
+
return out or "node"
|