vibefpmgui.ai_context

What a chat with the AI sends the model each time, so a conversation fits in its context window (Ollama doesn't fail when it doesn't: it drops the start, system prompt included).

  • The file once per version. A question carries the open file only if it changed since it was last sent; earlier copies in the history are replaced by a placeholder.
  • What became of the edits proposed in an answer is said at the start of the next question (see vibefpmgui.ai_edits.report); answers are sent back as the model wrote them.
  • The oldest turns go first when the estimate is over the budget (75% of the context, the rest left for the answer); the system prompt, the current file and the new question always stay.
  • Big files are sent in part: the selection, or the procedure around the cursor, in full, plus an outline of the rest (file_attachment).

Pure functions (no Qt).

  1"""What a chat with the AI sends the model each time, so a conversation
  2fits in its context window (Ollama doesn't fail when it doesn't: it drops
  3the start, system prompt included).
  4
  5- **The file once per version.** A question carries the open file only if
  6  it changed since it was last sent; earlier copies in the history are
  7  replaced by a placeholder.
  8- **What became of the edits** proposed in an answer is said at the start
  9  of the next question (see `vibefpmgui.ai_edits.report`); answers are sent
 10  back as the model wrote them.
 11- **The oldest turns go first** when the estimate is over the budget (75%
 12  of the context, the rest left for the answer); the system prompt, the
 13  current file and the new question always stay.
 14- **Big files are sent in part:** the selection, or the procedure around
 15  the cursor, in full, plus an outline of the rest (`file_attachment`).
 16
 17Pure functions (no Qt).
 18"""
 19
 20from __future__ import annotations
 21
 22import re
 23from dataclasses import dataclass, field
 24
 25from .ai_edits import Edit, report
 26
 27BUDGET = 0.75           # of the context window, for what's sent (the rest: the answer)
 28BIG_FILE_LINES = 600    # bigger files are sent in part
 29BIG_FILE_SHARE = 0.4    # …and so are files taking more than this share of the budget
 30WINDOW_PADDING = 20     # lines shown around the selection or procedure
 31WINDOW_MAX_LINES = 400
 32
 33PROCEDURE_RE = re.compile(r"^(?!\s*end\b)\s*(?:[\w(),=*\s]*?\s)?(subroutine|function)\s+\w+", re.IGNORECASE)
 34PROCEDURE_END_RE = re.compile(r"^\s*end\s*(subroutine|function)\b|^\s*end\s*(!.*)?$", re.IGNORECASE)
 35OUTLINE_RE = re.compile(
 36    r"^\s*(module|submodule|program|use|implicit|contains|interface|abstract\s+interface|"
 37    r"end\s*(module|submodule|program|interface|type|subroutine|function)\b|"
 38    r"type\s*(,|::|\s\w)|(?!end\b)(?:[\w(),=*\s]*?\s)?(subroutine|function)\s+\w+)",
 39    re.IGNORECASE)
 40
 41
 42def estimate_tokens(text: str, ratio: float = 1.0) -> int:
 43    """A rough token count (about 3 characters each for code), scaled by
 44    ``ratio``: measured counts over estimated ones, from earlier answers."""
 45    return int(len(text) / 3 * ratio) + 4
 46
 47
 48@dataclass
 49class Attachment:
 50    """The open file as sent with a question: all of it, or part of it."""
 51    name: str
 52    file_text: str  # the whole file when it was sent
 53    shown: str      # what the prompt contains
 54    partial: bool = False
 55
 56
 57@dataclass
 58class Turn:
 59    question: str               # as the user typed it
 60    request: str                # what's sent besides the file (the question, plus notes)
 61    attachment: Attachment | None = None  # the file wanted with it (sent if it changed)
 62    answer: str = ""
 63    edits: list[Edit] = field(default_factory=list)
 64
 65
 66def placeholder(attachment: Attachment) -> str:
 67    return f"[{attachment.name} as it was then: replaced by the newer version below]"
 68
 69
 70def compose(past: list[Turn], turn: Turn, system: str, context: int,
 71            ratio: float = 1.0) -> tuple[list[dict], int, int]:
 72    """The messages for ``turn`` after the ``past`` ones: (messages, the
 73    estimated tokens, how many of the oldest turns were left out)."""
 74    kept = list(past)
 75    budget = int(context * BUDGET)
 76    while True:
 77        messages = _render(kept, turn, system)
 78        tokens = sum(estimate_tokens(m["content"], ratio) for m in messages)
 79        if tokens <= budget or not kept:
 80            return messages, tokens, len(past) - len(kept)
 81        kept.pop(0)
 82
 83
 84def _render(kept: list[Turn], turn: Turn, system: str) -> list[dict]:
 85    last = next((t.attachment for t in reversed(kept) if t.attachment is not None), None)
 86    send = turn.attachment is not None and turn.attachment != last
 87    full = None if send else next((i for i in range(len(kept) - 1, -1, -1) if kept[i].attachment), None)
 88    messages = [{"role": "system", "content": system}]
 89    previous: Turn | None = None
 90    for i, t in enumerate(kept):
 91        user = _with_report(previous, t.request)
 92        if t.attachment is not None:
 93            user += "\n\n" + (t.attachment.shown if i == full else placeholder(t.attachment))
 94        messages.append({"role": "user", "content": user})
 95        messages.append({"role": "assistant", "content": t.answer})
 96        previous = t
 97    request = _with_report(previous, turn.request)
 98    if send:
 99        request += "\n\n" + turn.attachment.shown
100    elif turn.attachment is not None:
101        request += f"\n\n(My file, {turn.attachment.name}, is as I sent it above.)"
102    messages.append({"role": "user", "content": request})
103    return messages
104
105
106def _with_report(previous: Turn | None, request: str) -> str:
107    note = report(previous.edits) if previous is not None else ""
108    return f"{note}\n\n{request}" if note else request
109
110
111# ------------------------------------------------------------- the file
112def numbered(lines: list[str], first: int = 1) -> str:
113    width = len(str(first + len(lines) - 1))
114    return "\n".join(f"{n:>{width}}| {line}" for n, line in enumerate(lines, first))
115
116
117def file_lines(text: str) -> list[str]:
118    lines = text.split("\n")
119    if len(lines) > 1 and lines[-1] == "":
120        lines.pop()  # (a final newline doesn't start another line)
121    return lines
122
123
124def file_attachment(name: str, text: str, numbers: bool, context: int, ratio: float = 1.0,
125                    selection: tuple[int, int] | None = None, cursor_line: int = 1) -> Attachment:
126    """The file as sent: with line numbers (for answers that point at lines)
127    or as it is (for edits, whose old text must be copied exactly). A big
128    file is sent in part: the ``selection`` (first, last line) or the
129    procedure around ``cursor_line``, plus an outline of the rest."""
130    lines = file_lines(text)
131    whole = numbered(lines) if numbers else "\n".join(lines)
132    big = len(lines) > BIG_FILE_LINES or estimate_tokens(whole, ratio) > context * BUDGET * BIG_FILE_SHARE
133    if not big:
134        how = "line numbers on the left" if numbers else "exactly as it is"
135        return Attachment(name, text, f"My file, {name} ({how}):\n\n```fortran\n{whole}\n```")
136    first, last = window(lines, selection, cursor_line)
137    part = lines[first - 1:last]
138    shown = numbered(part, first) if numbers else "\n".join(part)
139    outline = [(n, line) for n, line in enumerate(lines, 1)
140               if not first <= n <= last and OUTLINE_RE.match(line)]
141    outline_text = "\n".join(f"line {n}: {line.strip()}" for n, line in outline) or "(nothing)"
142    return Attachment(name, text, (
143        f"My file, {name}, has {len(lines)} lines: too long to send all of it. Only lines "
144        f"{first}–{last} are shown in full; for the rest, only an outline. Base changes on the "
145        f"lines shown in full.\n\nOutline of the rest:\n{outline_text}\n\n"
146        f"Lines {first}–{last}{' (line numbers on the left)' if numbers else ''}:\n\n"
147        f"```fortran\n{shown}\n```"), partial=True)
148
149
150def window(lines: list[str], selection: tuple[int, int] | None, cursor_line: int) -> tuple[int, int]:
151    """The lines to send in full (1-based, inclusive): the selection, or the
152    procedure around the cursor, with some lines around it."""
153    if selection is not None:
154        first, last = selection
155    else:
156        first, last = enclosing_procedure(lines, cursor_line)
157    first, last = max(1, first - WINDOW_PADDING), min(len(lines), last + WINDOW_PADDING)
158    if last - first + 1 > WINDOW_MAX_LINES:  # (a huge procedure: around the cursor in it)
159        center = cursor_line if selection is None else selection[0]
160        first = max(1, min(center - WINDOW_MAX_LINES // 2, len(lines) - WINDOW_MAX_LINES + 1))
161        last = min(len(lines), first + WINDOW_MAX_LINES - 1)
162    return first, last
163
164
165def enclosing_procedure(lines: list[str], line: int) -> tuple[int, int]:
166    """The subroutine or function around ``line`` (first, last), or the line
167    itself when it's in none."""
168    line = min(max(1, line), max(1, len(lines)))
169    start = next((n for n in range(line, 0, -1) if PROCEDURE_RE.match(lines[n - 1])), None)
170    if start is None or any(PROCEDURE_END_RE.match(lines[n - 1]) for n in range(start + 1, line)):
171        return line, line  # (in none, or between two)
172    end = next((n for n in range(max(line, start + 1), len(lines) + 1) if PROCEDURE_END_RE.match(lines[n - 1])),
173               None)
174    if end is None or (end < line):
175        return line, line
176    return start, end
BUDGET = 0.75
BIG_FILE_LINES = 600
BIG_FILE_SHARE = 0.4
WINDOW_PADDING = 20
WINDOW_MAX_LINES = 400
PROCEDURE_RE = re.compile('^(?!\\s*end\\b)\\s*(?:[\\w(),=*\\s]*?\\s)?(subroutine|function)\\s+\\w+', re.IGNORECASE)
PROCEDURE_END_RE = re.compile('^\\s*end\\s*(subroutine|function)\\b|^\\s*end\\s*(!.*)?$', re.IGNORECASE)
OUTLINE_RE = re.compile('^\\s*(module|submodule|program|use|implicit|contains|interface|abstract\\s+interface|end\\s*(module|submodule|program|interface|type|subroutine|function)\\b|type\\s*(,|::|\\s\\w)|(?!end\\b)(?:[\\w(),, re.IGNORECASE)
def estimate_tokens(text: str, ratio: float = 1.0) -> int:
43def estimate_tokens(text: str, ratio: float = 1.0) -> int:
44    """A rough token count (about 3 characters each for code), scaled by
45    ``ratio``: measured counts over estimated ones, from earlier answers."""
46    return int(len(text) / 3 * ratio) + 4

A rough token count (about 3 characters each for code), scaled by ratio: measured counts over estimated ones, from earlier answers.

@dataclass
class Attachment:
49@dataclass
50class Attachment:
51    """The open file as sent with a question: all of it, or part of it."""
52    name: str
53    file_text: str  # the whole file when it was sent
54    shown: str      # what the prompt contains
55    partial: bool = False

The open file as sent with a question: all of it, or part of it.

Attachment(name: str, file_text: str, shown: str, partial: bool = False)
name: str
file_text: str
shown: str
partial: bool = False
@dataclass
class Turn:
58@dataclass
59class Turn:
60    question: str               # as the user typed it
61    request: str                # what's sent besides the file (the question, plus notes)
62    attachment: Attachment | None = None  # the file wanted with it (sent if it changed)
63    answer: str = ""
64    edits: list[Edit] = field(default_factory=list)
Turn( question: str, request: str, attachment: Attachment | None = None, answer: str = '', edits: list[vibefpmgui.ai_edits.Edit] = <factory>)
question: str
request: str
attachment: Attachment | None = None
answer: str = ''
def placeholder(attachment: Attachment) -> str:
67def placeholder(attachment: Attachment) -> str:
68    return f"[{attachment.name} as it was then: replaced by the newer version below]"
def compose( past: list[Turn], turn: Turn, system: str, context: int, ratio: float = 1.0) -> tuple[list[dict], int, int]:
71def compose(past: list[Turn], turn: Turn, system: str, context: int,
72            ratio: float = 1.0) -> tuple[list[dict], int, int]:
73    """The messages for ``turn`` after the ``past`` ones: (messages, the
74    estimated tokens, how many of the oldest turns were left out)."""
75    kept = list(past)
76    budget = int(context * BUDGET)
77    while True:
78        messages = _render(kept, turn, system)
79        tokens = sum(estimate_tokens(m["content"], ratio) for m in messages)
80        if tokens <= budget or not kept:
81            return messages, tokens, len(past) - len(kept)
82        kept.pop(0)

The messages for turn after the past ones: (messages, the estimated tokens, how many of the oldest turns were left out).

def numbered(lines: list[str], first: int = 1) -> str:
113def numbered(lines: list[str], first: int = 1) -> str:
114    width = len(str(first + len(lines) - 1))
115    return "\n".join(f"{n:>{width}}| {line}" for n, line in enumerate(lines, first))
def file_lines(text: str) -> list[str]:
118def file_lines(text: str) -> list[str]:
119    lines = text.split("\n")
120    if len(lines) > 1 and lines[-1] == "":
121        lines.pop()  # (a final newline doesn't start another line)
122    return lines
def file_attachment( name: str, text: str, numbers: bool, context: int, ratio: float = 1.0, selection: tuple[int, int] | None = None, cursor_line: int = 1) -> Attachment:
125def file_attachment(name: str, text: str, numbers: bool, context: int, ratio: float = 1.0,
126                    selection: tuple[int, int] | None = None, cursor_line: int = 1) -> Attachment:
127    """The file as sent: with line numbers (for answers that point at lines)
128    or as it is (for edits, whose old text must be copied exactly). A big
129    file is sent in part: the ``selection`` (first, last line) or the
130    procedure around ``cursor_line``, plus an outline of the rest."""
131    lines = file_lines(text)
132    whole = numbered(lines) if numbers else "\n".join(lines)
133    big = len(lines) > BIG_FILE_LINES or estimate_tokens(whole, ratio) > context * BUDGET * BIG_FILE_SHARE
134    if not big:
135        how = "line numbers on the left" if numbers else "exactly as it is"
136        return Attachment(name, text, f"My file, {name} ({how}):\n\n```fortran\n{whole}\n```")
137    first, last = window(lines, selection, cursor_line)
138    part = lines[first - 1:last]
139    shown = numbered(part, first) if numbers else "\n".join(part)
140    outline = [(n, line) for n, line in enumerate(lines, 1)
141               if not first <= n <= last and OUTLINE_RE.match(line)]
142    outline_text = "\n".join(f"line {n}: {line.strip()}" for n, line in outline) or "(nothing)"
143    return Attachment(name, text, (
144        f"My file, {name}, has {len(lines)} lines: too long to send all of it. Only lines "
145        f"{first}–{last} are shown in full; for the rest, only an outline. Base changes on the "
146        f"lines shown in full.\n\nOutline of the rest:\n{outline_text}\n\n"
147        f"Lines {first}–{last}{' (line numbers on the left)' if numbers else ''}:\n\n"
148        f"```fortran\n{shown}\n```"), partial=True)

The file as sent: with line numbers (for answers that point at lines) or as it is (for edits, whose old text must be copied exactly). A big file is sent in part: the selection (first, last line) or the procedure around cursor_line, plus an outline of the rest.

def window( lines: list[str], selection: tuple[int, int] | None, cursor_line: int) -> tuple[int, int]:
151def window(lines: list[str], selection: tuple[int, int] | None, cursor_line: int) -> tuple[int, int]:
152    """The lines to send in full (1-based, inclusive): the selection, or the
153    procedure around the cursor, with some lines around it."""
154    if selection is not None:
155        first, last = selection
156    else:
157        first, last = enclosing_procedure(lines, cursor_line)
158    first, last = max(1, first - WINDOW_PADDING), min(len(lines), last + WINDOW_PADDING)
159    if last - first + 1 > WINDOW_MAX_LINES:  # (a huge procedure: around the cursor in it)
160        center = cursor_line if selection is None else selection[0]
161        first = max(1, min(center - WINDOW_MAX_LINES // 2, len(lines) - WINDOW_MAX_LINES + 1))
162        last = min(len(lines), first + WINDOW_MAX_LINES - 1)
163    return first, last

The lines to send in full (1-based, inclusive): the selection, or the procedure around the cursor, with some lines around it.

def enclosing_procedure(lines: list[str], line: int) -> tuple[int, int]:
166def enclosing_procedure(lines: list[str], line: int) -> tuple[int, int]:
167    """The subroutine or function around ``line`` (first, last), or the line
168    itself when it's in none."""
169    line = min(max(1, line), max(1, len(lines)))
170    start = next((n for n in range(line, 0, -1) if PROCEDURE_RE.match(lines[n - 1])), None)
171    if start is None or any(PROCEDURE_END_RE.match(lines[n - 1]) for n in range(start + 1, line)):
172        return line, line  # (in none, or between two)
173    end = next((n for n in range(max(line, start + 1), len(lines) + 1) if PROCEDURE_END_RE.match(lines[n - 1])),
174               None)
175    if end is None or (end < line):
176        return line, line
177    return start, end

The subroutine or function around line (first, last), or the line itself when it's in none.