Skip to content

Commit 1563085

Browse files
feat: Sign in with ChatGPT provider, chess demo set and animated README demo
- providers/chatgpt.py: OAuth (PKCE, loopback) + Responses API streaming, charged to the user's plan - Preferences: ChatGPT plan option with sign in / sign out - tests against fake OAuth and Responses servers - data/demo/chess documents; make start uses them - docs/demo.svg generated from the real core (make demo-svg), embedded in README Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent aa31b4e commit 1563085

19 files changed

Lines changed: 1140 additions & 17 deletions

‎Makefile‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ METAINFODIR := $(DATADIR)/metainfo
1818

1919
INSTALL := install
2020

21-
.PHONY: all help check test run dev start install uninstall clean dist flatpak
21+
.PHONY: all help check test run dev start demo-svg install uninstall clean dist flatpak
2222

2323
all: help
2424

@@ -51,6 +51,9 @@ dev: run
5151
SANDBOX := sandbox
5252
DEMO := $(SANDBOX)/demo
5353

54+
demo-svg:
55+
$(PYTHON) build-aux/dev/make_demo_svg.py docs/demo.svg
56+
5457
start:
5558
@mkdir -p $(SANDBOX)/config
5659
@if [ ! -f $(DEMO)/gknowledge.json ]; then \

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
The goal is not to build yet another chat wrapper.
88
The goal is to build a serious native app for Linux desktops that makes knowledge curation, synthesis, and retrieval feel like a first-class operating system capability.
99

10+
<p align="center">
11+
<img src="docs/demo.svg" alt="gknowledge in action: drop documents, get a linked wiki, ask questions with citations" width="860">
12+
</p>
13+
14+
<sub>The animation is generated from the real core on the chess sample documents in `data/demo/chess` (offline engine, no API key). Regenerate it with `make demo-svg`.</sub>
15+
1016
## gknowledge is an alternative to…
1117

1218
`gknowledge` sits at the intersection of *LLM-powered knowledge bases*, *local-first note tools*, and *document RAG systems*. If you have used any of the tools below, you already understand the shape of the problem — `gknowledge` addresses it as a **native GNOME/GTK application** that is **local-first, provider-agnostic, and usable fully offline** (no API key required, thanks to a built-in heuristic extraction engine).

‎TODO.md‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,15 @@ Facts from OpenAI's docs (read 2026-10-13; the program is two weeks old, re-chec
8181

8282
Tasks:
8383
- [ ] Read the "errors and recovery" and terms pages; confirm that a Python implementation of the raw OAuth flow (no JS DevKit) is allowed.
84-
- [ ] `gknowledge/providers/chatgpt_plan.py`: OAuth flow (loopback listener, PKCE, id-token verification with JWKS), refresh, Responses API client with streaming.
85-
- [ ] Open the browser through `Gtk.UriLauncher` (portal-friendly); show a progress and cancel state in Preferences.
86-
- [ ] Store tokens with libsecret / the Secret portal, never in `settings.json`; delete on sign-out and call the revocation endpoint.
87-
- [ ] Persist `ext_agent_host_id` and the issued `client_id` per install, in the app config (not in shared project folders).
88-
- [ ] Adapt the extraction prompts to the Responses API rules (system text goes in `instructions`).
89-
- [ ] Surface "Using ChatGPT plan", cap-reached and not-eligible states with a "Manage usage" link; fall back to the offline engine automatically.
90-
- [ ] Tests with a fake OAuth server; no network in CI.
84+
- [x] `gknowledge/providers/chatgpt.py`: OAuth flow (loopback listener, PKCE, id-token claims check), refresh, revoke, Responses API client with streaming. JWKS signature verification is skipped on purpose (token comes straight from the token endpoint over TLS, OIDC Core 3.1.3.7); add it if a crypto dependency is ever accepted.
85+
- [x] Open the browser through `Gtk.UriLauncher`; Preferences shows waiting, signed-in and error states. Cancel on dialog save/close is basic.
86+
- [ ] Move tokens from `~/.config/gknowledge/chatgpt.json` (0600, atomic write, outside projects) to libsecret / the Secret portal. Sign-out already revokes and clears.
87+
- [x] Persist `ext_agent_host_id` and the issued `client_id` per install, in the app config (not in project folders).
88+
- [x] Adapt requests to the Responses API rules (system text goes in `instructions`, no temperature, stream, store false).
89+
- [x] Cap-reached and not-eligible errors are reported; extraction falls back to the offline engine automatically.
90+
- [ ] Show a persistent "Using ChatGPT plan" indicator in the main window and a "Manage usage" link.
91+
- [ ] Verify against the real service with a Plus or Pro account (model list, default model, `store: false` behaviour). Only fake servers were tested so far.
92+
- [x] Tests with fake OAuth and Responses servers (`tests/test_chatgpt.py`); no network in CI.
9193
- [ ] Document the privacy model: documents are sent to OpenAI only when this provider is selected.
9294

9395
## P2: agent-ready (v0.4)
@@ -143,6 +145,9 @@ Tasks:
143145

144146
## Known gaps in the current MVP
145147

148+
- [ ] Offline entity extraction is noisy on prose (for example SAN move fragments such as `Ba4 Nf6`, sentence-initial verbs, generic nouns, `Entity referenced by ingested sources.` boilerplate in answers). Filter chess/move patterns and stopword-led capitalised words, and improve extractive answers.
149+
- [ ] Demo animation (`docs/demo.svg`) currently shows the offline engine; re-record with an LLM provider once the ChatGPT flow is verified.
150+
146151
- [ ] No UI for editing or deleting knowledge pages.
147152
- [ ] Offline heuristics are keyword-based; add language detection beyond English.
148153
- [ ] `reviews/` directory is reserved but unused.

‎build-aux/dev/make_demo_svg.py‎

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
"""Generate docs/demo.svg: an animated (SMIL) walkthrough of gknowledge.
2+
3+
Everything shown comes from the real core: the chess sample documents are
4+
ingested with the offline engine and the page titles, summaries, counts and
5+
answer text are read back from the resulting project. Regenerate with:
6+
7+
python3 build-aux/dev/make_demo_svg.py [output.svg]
8+
"""
9+
from __future__ import annotations
10+
11+
import html
12+
import sys
13+
import tempfile
14+
import textwrap
15+
from pathlib import Path
16+
17+
ROOT = Path(__file__).resolve().parents[2]
18+
sys.path.insert(0, str(ROOT))
19+
20+
from gknowledge.core.ingestion import IngestionManager # noqa: E402
21+
from gknowledge.core.knowledge import KnowledgeWriter # noqa: E402
22+
from gknowledge.core.project import Project, Settings # noqa: E402
23+
from gknowledge.core.query import QueryEngine # noqa: E402
24+
from gknowledge.providers import build_provider # noqa: E402
25+
26+
OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "docs" / "demo.svg"
27+
QUESTION = "What is the Ruy Lopez?"
28+
T = 24.0 # loop length in seconds
29+
import os
30+
FRAME = float(os.environ["FRAME"]) if os.environ.get("FRAME") else None # debug: static frame
31+
32+
# Palette measured from Nautilus on Ubuntu (dark).
33+
SIDEBAR, CONTENT, TEXT, DIM = "#303030", "#1e1e1e", "#ffffff", "#959595"
34+
ICON, SELECT, ACCENT, BORDER = "#c0c0c0", "#444444", "#3584e4", "#1f1f1f"
35+
FONT = "Cantarell, 'Noto Sans', 'Ubuntu', 'DejaVu Sans', sans-serif"
36+
37+
38+
def esc(s: str) -> str:
39+
return html.escape(s, quote=True)
40+
41+
42+
def clean(s: str, title: str = "") -> str:
43+
s = " ".join(s.replace("#", " ").split())
44+
if title and s.startswith(title + " "): # drop the repeated heading
45+
s = s[len(title) + 1:]
46+
return s
47+
48+
49+
# -- real data ----------------------------------------------------------------
50+
def collect():
51+
tmp = Path(tempfile.mkdtemp())
52+
proj = Project.create(tmp / "p", name="Chess demo")
53+
proj.save_settings(Settings(provider="offline"))
54+
mgr = IngestionManager(proj)
55+
files = sorted((ROOT / "data" / "demo" / "chess").glob("*.md"))
56+
for f in files:
57+
mgr.import_file(f)
58+
mgr.process_pending()
59+
pages = {p.title: p for p in proj.store.list_pages()}
60+
w = KnowledgeWriter(proj)
61+
page = pages["Ruy Lopez"]
62+
body = w.read_body(page)
63+
summary, points = "", []
64+
section = ""
65+
for line in body.splitlines():
66+
if line.startswith("## "):
67+
section = line[3:].strip()
68+
elif section == "Summary" and line.strip():
69+
summary += " " + line.strip()
70+
elif section == "Key points" and line.startswith("- "):
71+
points.append(clean(line[2:], "Ruy Lopez"))
72+
engine = QueryEngine(proj, build_provider(proj.settings.as_dict()))
73+
res = engine.query(QUESTION)
74+
first = next(l for l in res.answer.splitlines() if l.startswith("- #"))
75+
answer = clean(first[2:], "Ruy Lopez")
76+
answer = answer.rsplit(" [", 1)[0]
77+
cites, seen = [], set()
78+
for c in res.citations:
79+
if c.title not in seen:
80+
seen.add(c.title)
81+
cites.append((c.number, c.title, c.kind))
82+
chosen = [t for t in ("Ruy Lopez", "Deep Blue versus Kasparov", "Deep Blue",
83+
"Garry Kasparov", "Vladimir Kramnik", "Bobby Fischer",
84+
"Sicilian Defence", "Wilhelm Steinitz") if t in pages]
85+
titles = [s.title for s in sorted(proj.store.list_sources(), key=lambda s: s.title)]
86+
return {
87+
"files": titles,
88+
"n_pages": len(pages),
89+
"pages": [(t, pages[t].kind.value) for t in chosen],
90+
"summary": clean(summary, "Ruy Lopez"),
91+
"points": points[:3],
92+
"answer": answer, "cites": cites[:3],
93+
}
94+
95+
96+
# -- svg helpers -----------------------------------------------------------------
97+
def vis(start: float, end: float, fade: float = 0.25) -> str:
98+
"""opacity animation: hidden, fade in at start, fade out at end."""
99+
s, e = start / T, min(end, T) / T
100+
f = fade / T
101+
kt = [0, max(s - 0.0001, 0), min(s + f, 1), max(e - f, 0), min(e, 1), 1]
102+
kt = sorted(set(round(k, 5) for k in kt))
103+
pts = []
104+
for k in kt:
105+
on = (s + f <= k <= e - f) or (s < k < e)
106+
pts.append("1" if on else "0")
107+
if start <= 0.01:
108+
pts[0] = "1"
109+
return (f'<animate attributeName="opacity" dur="{T}s" repeatCount="indefinite" '
110+
f'keyTimes="{";".join(str(k) for k in kt)}" values="{";".join(pts)}"/>')
111+
112+
113+
def g(content: str, start: float, end: float) -> str:
114+
if FRAME is not None:
115+
return f'<g opacity="{1 if start <= FRAME < end else 0}">{content}</g>'
116+
return f'<g opacity="0">{vis(start, end)}{content}</g>'
117+
118+
119+
def text(x, y, s, size=13, fill=TEXT, anchor="start", weight="400", extra=""):
120+
return (f'<text x="{x}" y="{y}" font-size="{size}" fill="{fill}" '
121+
f'text-anchor="{anchor}" font-weight="{weight}" {extra}>{esc(s)}</text>')
122+
123+
124+
def wrapped(x, y, s, width, size=13, fill=TEXT, lh=19, max_lines=6):
125+
lines = textwrap.wrap(s, width=width)[:max_lines]
126+
if len(textwrap.wrap(s, width=width)) > max_lines:
127+
lines[-1] = lines[-1].rstrip(".,; ") + "…"
128+
return "".join(text(x, y + i * lh, ln, size, fill) for i, ln in enumerate(lines))
129+
130+
131+
def prog_w() -> float:
132+
if FRAME is None:
133+
return 0
134+
return max(0.0, min(1.0, (FRAME - 4.6) / 2.0)) * 256
135+
136+
137+
def typed_w(n: int) -> float:
138+
if FRAME is None:
139+
return 0
140+
return max(0.0, min(1.0, (FRAME - 15.4) / 1.8)) * n * 8
141+
142+
143+
def build(d) -> str:
144+
W, H, SB, HH = 900, 560, 280, 48
145+
CX = SB + (W - SB) / 2
146+
o = []
147+
o.append(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" '
148+
f'width="{W}" height="{H}" font-family="{FONT}" role="img" '
149+
f'aria-label="gknowledge turns documents into a linked wiki and answers questions with citations">')
150+
o.append(f'<rect width="{W}" height="{H}" rx="14" fill="{CONTENT}"/>')
151+
o.append(f'<path d="M14 0H{SB}V{H - 44}H0V14A14 14 0 0 1 14 0Z" fill="{SIDEBAR}"/>')
152+
o.append(f'<rect x="{SB}" y="0" width="1" height="{H - 44}" fill="{BORDER}"/>')
153+
# sidebar header
154+
o.append(text(26, 31, "+", 22, ICON, "middle"))
155+
o.append(text(SB / 2, 22, "Chess demo", 13, TEXT, "middle", "700"))
156+
o.append(text(SB / 2, 38, "Offline (local heuristics)", 10, DIM, "middle"))
157+
o.append(text(SB - 24, 30, "≡", 20, ICON, "middle"))
158+
# tabs
159+
tabs = [("Sources", 470), ("Knowledge", 570), ("Assistant", 670), ("Review", 760)]
160+
spans = [(0.2, 8.5), (8.5, 15.0), (15.0, T), (99, 99)]
161+
for (name, x), (a, b) in zip(tabs, spans):
162+
o.append(g(f'<rect x="{x - 42}" y="10" width="84" height="30" rx="8" fill="#3a3a3a"/>', a, b))
163+
o.append(text(x, 30, name, 13, TEXT, "middle", "700"))
164+
# window controls
165+
for i, sym in enumerate(("–", "□", "✕")):
166+
o.append(f'<circle cx="{W - 70 + i * 28}" cy="25" r="11" fill="#3a3a3a"/>')
167+
o.append(text(W - 70 + i * 28, 30, sym, 11, TEXT, "middle"))
168+
169+
# ---- scene 1: sources
170+
files = d["files"]
171+
rows = []
172+
for i, name in enumerate(files):
173+
y = 72 + i * 46
174+
t0 = 1.2 + 0.45 * i
175+
row = (f'<circle cx="30" cy="{y - 4}" r="9" fill="none" stroke="{ICON}" stroke-width="1.5"/>'
176+
+ text(50, y, name, 13)
177+
+ g(text(50, y + 16, "queued", 10, DIM), 0, 7.0)
178+
+ g(text(50, y + 16, "ingested", 10, DIM), 7.0, 8.5))
179+
rows.append(g(row, t0, 8.5))
180+
o.append("".join(rows))
181+
o.append(g(f'<rect x="12" y="{H - 44 - 60}" width="{SB - 24}" height="36" rx="8" fill="{ACCENT}"/>'
182+
+ text(SB / 2, H - 44 - 37, "Ingest Queued", 13, TEXT, "middle", "700"), 1.2, 8.5))
183+
o.append(g(f'<rect x="12" y="{H - 44 - 78}" width="{SB - 24}" height="6" rx="3" fill="#444"/>'
184+
f'<rect x="12" y="{H - 44 - 78}" width="{prog_w()}" height="6" rx="3" fill="{ACCENT}">'
185+
+ ("" if FRAME is not None else f'<animate attributeName="width" dur="{T}s" repeatCount="indefinite" '
186+
f'keyTimes="0;{4.6 / T:.4f};{6.6 / T:.4f};1" values="0;0;{SB - 24};{SB - 24}"/>')
187+
+ '</rect>', 4.4, 7.2))
188+
o.append(g(text(CX, 250, "Drop files here", 20, DIM, "middle", "700")
189+
+ text(CX, 276, "Markdown, text, PDF, HTML", 13, DIM, "middle"), 0.2, 3.0))
190+
o.append(g(text(CX, 240, f"{len(files)} sources ingested", 22, TEXT, "middle", "700")
191+
+ text(CX, 268, f"{d['n_pages']} pages created, linked and traced to their sources", 14, DIM, "middle"),
192+
7.0, 8.5))
193+
194+
# ---- scene 2: knowledge
195+
kr = []
196+
for i, (title, kind) in enumerate(d["pages"]):
197+
y = 72 + i * 46
198+
row = ""
199+
if i == 0:
200+
row += f'<rect x="8" y="{y - 24}" width="{SB - 16}" height="40" rx="8" fill="{SELECT}"/>'
201+
row += (f'<circle cx="30" cy="{y - 4}" r="9" fill="none" stroke="{ICON}" stroke-width="1.5"/>'
202+
+ text(50, y, title, 13) + text(50, y + 16, kind, 10, DIM)
203+
+ text(SB - 24, y, "AI", 10, DIM, "middle"))
204+
kr.append(g(row, 8.8 + 0.3 * i, 15.0))
205+
o.append("".join(kr))
206+
page = [text(310, 100, "Ruy Lopez", 24, TEXT, weight="700"),
207+
text(310, 124, "type: source · generated · linked to ruy-lopez.md", 11, DIM),
208+
text(310, 160, "Summary", 14, TEXT, weight="700"),
209+
wrapped(310, 182, d["summary"], 78, 12.5, TEXT, 18, 5),
210+
text(310, 292, "Key points", 14, TEXT, weight="700")]
211+
for i, pt in enumerate(d["points"]):
212+
page.append(wrapped(322, 314 + i * 40, "• " + pt, 74, 12.5, TEXT, 18, 2))
213+
page.append(text(310, 440, "Linked pages", 14, TEXT, weight="700"))
214+
for i, nm in enumerate(["Garry Kasparov", "Vladimir Kramnik", "Sicilian Defence"]):
215+
x = 310 + i * 160
216+
page.append(f'<rect x="{x}" y="452" width="148" height="26" rx="13" fill="#2f4a73"/>'
217+
+ text(x + 74, 470, "[[" + nm + "]]", 11, "#cfe0ff", "middle"))
218+
o.append(g("".join(page), 9.2, 15.0))
219+
220+
# ---- scene 3: assistant
221+
o.append(g(text(SB / 2, 90, "No saved answers yet.", 13, DIM, "middle"), 15.0, 20.0))
222+
saved = (f'<rect x="8" y="48" width="{SB - 16}" height="40" rx="8" fill="{SELECT}"/>'
223+
+ f'<circle cx="30" cy="68" r="9" fill="none" stroke="{ICON}" stroke-width="1.5"/>'
224+
+ text(50, 72, QUESTION, 13) + text(50, 88, "saved answer", 10, DIM))
225+
o.append(g(saved, 20.2, T))
226+
o.append(g(f'<rect x="310" y="{H - 44 - 58}" width="440" height="36" rx="8" fill="#2a2a2a"/>'
227+
f'<rect x="760" y="{H - 44 - 58}" width="52" height="36" rx="8" fill="{ACCENT}"/>'
228+
+ text(786, H - 44 - 35, "Ask", 13, TEXT, "middle", "700")
229+
+ f'<rect x="820" y="{H - 44 - 58}" width="52" height="36" rx="8" fill="#3a3a3a"/>'
230+
+ text(846, H - 44 - 35, "Save", 13, TEXT, "middle", "700"), 15.0, T))
231+
# typed question
232+
n = len(QUESTION)
233+
o.append(f'<clipPath id="typed"><rect x="320" y="0" width="{typed_w(n)}" height="{H}">'
234+
+ ("" if FRAME is not None else f'<animate attributeName="width" dur="{T}s" repeatCount="indefinite" '
235+
f'keyTimes="0;{15.4 / T:.4f};{17.2 / T:.4f};1" values="0;0;{n * 8};{n * 8}"/>')
236+
+ '</rect></clipPath>')
237+
o.append(g(f'<g clip-path="url(#typed)">{text(322, H - 44 - 35, QUESTION, 13)}</g>', 15.0, T))
238+
ans = [text(310, 90, "Answer", 14, TEXT, weight="700"),
239+
wrapped(310, 114, d["answer"], 78, 13, TEXT, 20, 6)]
240+
n_lines = min(6, len(textwrap.wrap(d["answer"], width=78)))
241+
top = 114 + n_lines * 20 + 22
242+
ans.append(text(310, top, "Sources", 14, TEXT, weight="700"))
243+
for i, (_num, title, kind) in enumerate(d["cites"]):
244+
ans.append(text(310, top + 24 + i * 22, f"[{i + 1}] {title} · {kind}", 12, "#8ab4f8"))
245+
o.append(g("".join(ans), 17.6, T))
246+
247+
# ---- caption bar
248+
o.append(f'<path d="M0 {H - 44}H{W}V{H - 14}A14 14 0 0 1 {W - 14} {H}H14A14 14 0 0 1 0 {H - 14}Z" fill="#141414"/>')
249+
caps = [(0.2, 8.5, "1 Drop in your documents. Nothing leaves your machine."),
250+
(8.5, 15.0, "2 gknowledge builds a linked wiki, as plain Markdown with provenance."),
251+
(15.0, T, "3 Ask questions. Every answer cites the pages and sources it used.")]
252+
for a, b, s in caps:
253+
o.append(g(text(24, H - 17, s, 14, "#ffffff", weight="700"), a, b))
254+
o.append(text(W - 20, H - 17, "native GNOME · offline by default", 11, DIM, "end"))
255+
o.append("</svg>")
256+
return "\n".join(o)
257+
258+
259+
if __name__ == "__main__":
260+
data = collect()
261+
OUT.parent.mkdir(parents=True, exist_ok=True)
262+
OUT.write_text(build(data), "utf-8")
263+
print(f"Wrote {OUT} ({OUT.stat().st_size // 1024} KiB), "
264+
f"{len(data['files'])} sources, {data['n_pages']} pages")

‎build-aux/dev/seed_demo.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Create a demo project, import data/demo/*.md and ingest it offline.
22
3-
Usage: python3 build-aux/dev/seed_demo.py <project-dir>
3+
Usage: python3 build-aux/dev/seed_demo.py <project-dir> [chess|energy]
44
"""
55
import sys
66
from pathlib import Path
@@ -12,10 +12,11 @@
1212
from gknowledge.core.project import Project, Settings # noqa: E402
1313

1414
target = Path(sys.argv[1])
15-
proj = Project.create(target, name="Demo")
15+
proj = Project.create(target, name="Chess demo" if len(sys.argv) < 3 or sys.argv[2] == "chess" else "Demo")
1616
proj.save_settings(Settings(provider="offline"))
1717
mgr = IngestionManager(proj)
18-
for md in sorted((ROOT / "data" / "demo").glob("*.md")):
18+
topic = sys.argv[2] if len(sys.argv) > 2 else "chess"
19+
for md in sorted((ROOT / "data" / "demo" / topic).glob("*.md")):
1920
mgr.import_file(md)
2021
print(f"Ingested {mgr.process_pending()} source(s) into {target}")
2122
proj.close()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Deep Blue versus Kasparov
2+
3+
IBM built the chess computer Deep Blue to challenge world champion Garry Kasparov. The first match took place in Philadelphia in February 1996. Kasparov won it 4 to 2, although Deep Blue won the first game, the first time a computer beat a reigning world champion under classical time controls.
4+
5+
The rematch was held in New York in May 1997 against an upgraded Deep Blue. This time the machine won 3.5 to 2.5. The decisive moment was Game 6, in which Kasparov played the Caro-Kann Defence and lost in only 19 moves.
6+
7+
Deep Blue searched about 200 million positions per second using custom chips. Kasparov accused IBM of unfair assistance and demanded a rematch that never happened. The matches became a landmark in the history of artificial intelligence.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Endgame Principles
2+
3+
In the endgame the king becomes an active piece. Centralise it early and use it to attack pawns and support passed pawns. Opposition, where the kings face each other with one square between them, often decides king and pawn endings.
4+
5+
Two rook endgame positions are essential knowledge. The Lucena position is a winning technique for the side with the pawn on the seventh rank, known as building a bridge. The Philidor position is a defensive setup that holds a draw by keeping the rook on the sixth rank.
6+
7+
The Tarrasch rule says to place rooks behind passed pawns, whether they are your own or your opponent's. Triangulation is a manoeuvre where a king loses a tempo to pass the move to the opponent. Practising these ideas matters more than memorising openings for improving players.

0 commit comments

Comments
 (0)