Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
638bcad
feat(scoring): adaptive sequence learning and anchoring
versenilvis Sep 28, 2026
b2285b8
feat(ui): display command prediction hint with right-arrow expansion
versenilvis Sep 28, 2026
270fe49
feat(scoring): bootstrap command sequences from history and add prefi…
versenilvis Sep 28, 2026
975706a
feat(ui): maintain prediction continuation across tab selection
versenilvis Sep 28, 2026
5fe8dc7
refactor(ui): move prediction symbol to icons and remove config option
versenilvis Sep 28, 2026
d0659d9
fix(ui): prevent duplicate ghost text when history item has extra whi…
versenilvis Sep 28, 2026
710ca13
fix(ui): stop duplicate ghost text and unrelated prediction hint
versenilvis Sep 28, 2026
b2e1fad
fix(ui): only show prediction hint when buffer is empty
versenilvis Sep 28, 2026
fe6026d
fix(ui): preserve prediction hint and align right arrow expansion
versenilvis Sep 28, 2026
b1197b3
test(tui): add prediction hint display and expansion tests
versenilvis Sep 29, 2026
317da75
fix(ui): prioritize prediction on right arrow instead of accepting me…
versenilvis Sep 29, 2026
02b13aa
refactor: remove scratch tests and redundant sequence fallback query
versenilvis Sep 29, 2026
9eefa76
feat(scoring): add workspace root detection and schema migration
versenilvis Sep 29, 2026
5c80029
feat(scoring): implement candidate tiering and scope ranking
versenilvis Sep 30, 2026
124181e
perf(scoring): optimize global query and make scope count lazy
versenilvis Sep 30, 2026
c14c64a
fix(scoring): create database backup only during schema migration
versenilvis Sep 30, 2026
f36fb6f
feat(ctxcheck): context validation engine and ast parser
versenilvis Sep 30, 2026
39df9f3
feat(predict): wire context validation and atomic buffer matching
versenilvis Sep 30, 2026
b5b5824
test(tui): add scope prediction integration tests and ci shell checks
versenilvis Sep 30, 2026
1103691
docs(dev): document prediction architecture and scoping rules
versenilvis Sep 30, 2026
dc75953
docs(dev): update scoring architecture with workspace tiers and seque…
versenilvis Sep 30, 2026
a6d949b
test: fix rows.Err checks and string concatenation in tui test harnesses
versenilvis Sep 30, 2026
f5d3782
fix(db): use VACUUM INTO for WAL-consistent backups and secure log pe…
versenilvis Sep 30, 2026
b3cbb33
fix(db): enforce 0600 VACUUM INTO, abort on failure, and isolate pred…
versenilvis Sep 30, 2026
751e900
fix(predict): preserve predict.log across sessions and support test -…
versenilvis Sep 30, 2026
632a0ae
fix(ui): preserve space separator in ghost text suffix
versenilvis Sep 30, 2026
5db05ec
fix(predict): ignore repeating navigation commands in sequence predic…
versenilvis Sep 30, 2026
de07247
fix(input): forward right arrow when no prediction is available
versenilvis Sep 30, 2026
8e797bf
fix(input): tab accepts prediction when no explicit menu selection
versenilvis Sep 30, 2026
2f0d07d
fix(history): fix prefix search ranking and expand ghost text on tab/…
versenilvis Sep 30, 2026
753ebd7
fix(input): tab accepts menu selection when menu is visible
versenilvis Sep 30, 2026
b0be3d1
docs(scoring): update frecency weights, workspace tiers, and scope co…
versenilvis Sep 30, 2026
34f27b7
fix(ctxcheck): skip option values for make flags and handle attached …
versenilvis Sep 30, 2026
d418c82
fix(ctxcheck): classify bun test as free and return unknown for unrec…
versenilvis Sep 30, 2026
a3eeb30
fix(ctxcheck): only treat fish and/or as connectors at command position
versenilvis Sep 30, 2026
19696df
fix(ctxcheck): return free for interpreter module and inline-code flags
versenilvis Sep 30, 2026
3a48809
test(scoring): copy history.db to temp dir in real db benchmark
versenilvis Sep 30, 2026
c62ad11
fix(scoring): track bootstrap sequences with bgWg and bounded timeout
versenilvis Sep 30, 2026
be03b93
fix(scoring): normalize cwd across writes, lookups, and fallback queries
versenilvis Sep 30, 2026
87eadc7
fix(wrapper): snapshot naiveBuffer under bufferMu in prediction accep…
versenilvis Sep 30, 2026
fff86df
fix(wrapper): fallback to menu ghost text on right arrow when no rela…
versenilvis Sep 30, 2026
bcd24df
refactor(scoring): consolidate duplicate candidate queries and backfi…
versenilvis Sep 30, 2026
c213643
refactor(wrapper): deduplicate acceptLine in tab and right arrow paths
versenilvis Sep 30, 2026
5c199ba
fix(test): typo
versenilvis Sep 30, 2026
fa5ae7a
fix(ci): prevent zsh compinit prompt hang in tui tests
versenilvis Sep 30, 2026
9e35b10
fix(test): populate shell history for menu prediction test
versenilvis Sep 30, 2026
7712476
fix(shell): fast-path fish config dir when XDG_CONFIG_HOME is set
versenilvis Sep 30, 2026
d02390b
fix(tui): clean up pty and subshell lifecycle on teardown
versenilvis Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,15 @@ jobs:
with:
version: v2.11.4

- name: Install Shells for Testing
run: |
sudo apt-get update
sudo apt-get install -y zsh fish
sudo chmod -R go-w /usr/local/share

- name: Run Tests
env:
IRIS_REQUIRE_SHELLS: "1"
run: go test -v ./...

- name: Create vendor tarball
Expand Down Expand Up @@ -89,3 +97,19 @@ jobs:
git commit -m "docs(changelog): update for ${GITHUB_REF_NAME} [skip ci]"
git push origin main
fi

test-macos:
name: Tests (macOS)
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
cache: true

- name: Run Tests
run: go test -v ./internal/workspace/... ./internal/ctxcheck/...
1 change: 1 addition & 0 deletions docs/dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ This directory contains code architecture guides, engine design notes, and devel
- [Spec & completion engine](spec.md): Static specifications, priority-based flag gating, and Cobra `__complete` dynamic completion
- [File & path generator](filegen.md): File system traversal, extension filtering, and directory slash preservation
- [History provider](history.md): Shell history indexing, search algorithms, and caching
- [Prediction & context validation engine](prediction.md): Directory-scoped ghost text, validation tiers, and admission gate
- [Auto updater](updater.md): Release tracking, version comparison, and atomic binary updates
63 changes: 63 additions & 0 deletions docs/dev/prediction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Directory-Scoped Ghost Text Prediction (`internal/ctxcheck` & `root/wrapper.go`)

This document describes the design and implementation of context-aware, directory-scoped ghost text prediction in IRIS.

## Overview

Iris predicts commands based on shell history, transitions, and frecency. To prevent cross-project command leakage (e.g. suggesting `just reload` in projects without a `justfile`), predictions are evaluated through a tiered scoping model and context validation engine.

## Core Concepts

### 1. Project Scoping (`project_id`)

A workspace scope is identified by `project_id`, calculated by `workspace.DetectProjectIDCached(cwd)`:
- Traverses upward from `cwd` searching for `.git` (or project markers).
- Canonicalizes paths resolving symlinks (`/var/folders` to `/private/var` on macOS).
- Commands in non-git directories fall back to directory path (`COALESCE(project_id, cwd)`).

### 2. Candidate Tiering

Tiers are calculated in Go during candidate retrieval (`internal/scoring/frecency.go`):
- **Tier 4 (Local CWD)**: Exact directory match (`cwd == current_cwd`).
- **Tier 3 (Subdir to Ancestor)**: Current directory is descendant of command's recorded working directory within the same project.
- **Tier 2 (Ancestor to Subdir)**: Current directory is ancestor of command's recorded working directory within the same project.
- **Tier 1 (Project Siblings)**: Same project (`project_id`), different directory branch.
- **Tier 0 (Foreign / Global)**: Different project or non-matching directories.

### 3. Lazy Scope Count (`ScopeCount`)

Rather than running expensive `COUNT(DISTINCT)` aggregates across all candidates in the global SQL query, `ScopeCount` is evaluated lazily only when candidate validation reaches Tier 0:
- Commands with `Tier > 0` bypass scope count evaluation.
- Commands at `Tier 0` query the number of distinct scopes (`project_id` or `cwd`) where the command was executed.

### 4. Validation Engine & Verdicts (`internal/ctxcheck`)

The validator parses candidate commands against the local filesystem with a 15ms deadline:
- **`Valid`**: Contextually valid in current directory (e.g. recipe exists in `justfile`, script in `package.json`, target in `Makefile`, executable/directory exists).
- **`Invalid`**: Target explicitly missing (e.g. `just non_existent_recipe`, `cd nonexistent_dir`). Blocked across all tiers.
- **`Free`**: Generic shell commands or commands without known manifests (`cargo run`, `git status`, `./app`).
- **`Unknown`**: Indeterminate or timed-out parsing (e.g. complex compound commands with `cd`, network-mounted slow disks).

### 5. Admission Matrix (`ctxcheck.Allow`)

| Tier | Verdict `Valid` | Verdict `Invalid` | Verdict `Free` | Verdict `Unknown` |
| :--- | :--- | :--- | :--- | :--- |
| **Tier 4** (Exact CWD) | Allowed | Blocked | Allowed | Allowed |
| **Tier 1–3** (Same Project) | Allowed | Blocked | Allowed | Blocked |
| **Tier 0** (Foreign Project) | Allowed | Blocked | ScopeCount >= 3 | Blocked |

### 6. Known Limitations

- **Non-existent destination targets**: Commands like `cp source ./dest` where `dest` does not yet exist are classified as `Invalid`.
- **Non-git directories**: If `cwd` is outside a git repository, cross-directory sharing relies on exact directory paths or global scope threshold (Case 2 requires `project_id`).
- **Shell aliases and functions**: Custom shell aliases or functions without matching binaries are treated as `Free`.
- **Nested monorepos**: Repositories containing nested `.git` directories or submodules treat each git boundary as a distinct project scope.

## Debugging & Observability

### `IRIS_DEBUG_PREDICT`

Set `IRIS_DEBUG_PREDICT=1` to log prediction candidate evaluations:
- Logs current directory, prefix, chosen prediction, and candidate breakdown (tier, scopes, verdict, allow).
- **Warning**: Log entries include verbatim command strings, which may contain sensitive arguments, tokens, or passwords.
- Only enable during dogfooding/troubleshooting sessions and remove log files after analysis.
58 changes: 44 additions & 14 deletions docs/dev/scoring.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,58 @@
# Scoring & ranking architecture (`internal/scoring/`)

The scoring engine ranks suggestions by combining frecency algorithms, workflow sequence learning, and item-type priority rules.
The scoring engine ranks suggestions and completion candidates by combining frecency algorithms, workflow sequence learning, workspace tiering, and item-type priority rules.

## Core concepts

### 1. Frecency calculation (`internal/scoring/frecency.go`)
### 1. Spec mode composite scoring (`internal/scoring/scorer.go`)

Frecency combines execution **frequency** with **recency** decay:
In spec mode, suggestions from static command specs are evaluated against runtime signals and ranked through a composite score:

$$\text{Score} = \text{Frequency} \times e^{-\lambda \Delta t}$$
$$\text{FinalScore} = \sum w_i \cdot S_i$$

Commands executed recently receive a higher score multiplier that decays over time.
Default weights:
- **BasePriority** ($w_1 = 0.20$): Subcommands default to 30, flags default to 10 (boosted to 80 when typing `-` or `--`).
- **ContextBonus** ($w_2 = 0.20$): Active directory context and argument hints.
- **Frecency** ($w_3 = 0.20$): Normalized frecency score from execution history.
- **Transition** ($w_4 = 0.20$): Sequential pattern match based on previous command skeleton.
- **MatchQuality** ($w_5 = 0.20$): Exact match vs prefix match vs substring proximity.

### 2. Workflow sequence learning (`internal/scoring/context_rules.go`)
### 2. Frecency decay calculation (`internal/scoring/frecency.go`)

Iris tracks sequential command pairs to learn common developer workflows (e.g. `git add` $\rightarrow$ `git commit`, `go build` $\rightarrow$ `./iris`). When a parent command skeleton matches the previous command, related suggestions receive a priority boost.
Frecency combines execution count with step-based recency weights (`RawScore`):

### 3. Skeleton extraction (`internal/scoring/skeleton.go`)
$$\text{Score} = \text{Count} \times \text{Weight}(\Delta t)$$

`ExtractSkeleton(cmd)` normalizes full command strings into structural skeletons by removing specific arguments and flags (e.g. `git commit -m "feat: test"` $\rightarrow$ `git commit`).
- **$\le 1$ hour**: weight = 100
- **$\le 24$ hours**: weight = 50
- **$\le 7$ days**: weight = 20
- **$\le 30$ days**: weight = 5
- **$> 30$ days**: weight = 1
- Commands with non-zero exit codes are never recorded.

### 4. Spec priority & flag gating (`spec/lookup.go`)
### 3. Workflow sequence learning (`command_sequences` & `command_transitions`)

Within spec completion mode:
- Files and subcommands default to standard priority (`Priority = 30`).
- Flags and options default to low priority (`Priority = 10`) when typing arguments.
- When the user explicitly types `-` or `--`, flags are promoted (`Priority = 80`).
Iris tracks sequential command pairs to suggest developer workflows:
- **Skeleton transitions (`command_transitions`)**: Structural transitions between base commands (`git add` $\rightarrow$ `git commit`, `go build` $\rightarrow$ `./iris`).
- **Full command sequences (`command_sequences`)**: Exact command pairs $(C_{prev}, C_{next})$ preserving arguments, working directory, and `project_id`. Bootstrapped in the background from shell history.

### 4. Workspace candidate tiering for predictions

When retrieving candidates for ghost text prediction (`QuerySequenceCandidates` and `QueryHistoryCandidates`), results are categorized into workspace tiers calculated in Go:

- **Tier 4 (Exact CWD)**: Recorded working directory matches `cwd` exactly.
- **Tier 3 (Descendant)**: Recorded working directory is beneath the current directory within the same project.
- **Tier 2 (Ancestor)**: Recorded working directory is above the current directory within the same project.
- **Tier 1 (Project Siblings)**: Different directory branches sharing the same `project_id`.
- **Tier 0 (Foreign Scope)**: Outside the current project scope or different non-git directories.

### 5. Two-phase candidate retrieval & lazy scope gating

1. **Local phase**: Queries `history_entries` and `command_sequences` where `cwd = ? OR project_id = ?`, bounded by `cmd >= prefix AND cmd < prefixUpperBound` and `instr(cmd, prefix) = 1`.
2. **Global phase**: If local candidate pool is below threshold, queries foreign scopes (`project_id != ? OR project_id IS NULL`), deduplicating against local candidates.
3. **Lazy scope gate**: Instead of running expensive `COUNT(DISTINCT)` aggregates across all candidates in the global SQL query, `store.ScopeCount` queries `COUNT(DISTINCT COALESCE(NULLIF(project_id, ''), cwd))` only on-demand for Tier 0 candidates. Tier 0 candidates require `ScopeCount >= 3` to pass the admission gate.

### 6. Storage & migration

- SQLite database (`~/.local/share/iris/history.db`) runs with WAL mode and `PRAGMA busy_timeout = 5000`.
- Safe schema migration: adds `project_id` column dynamically with an automatic backup file (`history.db.bak`, permissions `0600`) created only when legacy schema migration runs.
66 changes: 41 additions & 25 deletions integration/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"database/sql"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
Expand All @@ -21,15 +22,15 @@ var (
sessionHistory []string
sessionHistoryMu sync.Mutex

historyCache []string
idMapCache map[string]int
historyCache []string
idMapCache map[string]int
sourceMapCache map[string]string
searcherCache *fuzzy.Searcher
mu sync.Mutex
lastModTime int64
searcherCache *fuzzy.Searcher
mu sync.Mutex
lastModTime int64

atuinCmds []string
atuinLastMod int64
atuinCmds []string
atuinLastMod int64
lastAtuinMode int = -1
)

Expand Down Expand Up @@ -57,6 +58,7 @@ type HistResult struct {
Cmd string
FuzzyScore int
Source string
Tier int
}

func init() {
Expand Down Expand Up @@ -252,8 +254,8 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
currentID := len(sessionHistory) + len(allCmds)

sessionHistoryMu.Lock()
for i := len(sessionHistory) - 1; i >= 0; i-- {
cmd := sessionHistory[i]
for _, cmd := range slices.Backward(sessionHistory) {

if !seen[cmd] {
historyCache = append(historyCache, cmd)
seen[cmd] = true
Expand All @@ -268,8 +270,8 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
}
sessionHistoryMu.Unlock()

for i := len(allCmds) - 1; i >= 0; i-- {
cmd := allCmds[i]
for _, cmd := range slices.Backward(allCmds) {

if !seen[cmd] {
historyCache = append(historyCache, cmd)
seen[cmd] = true
Expand All @@ -293,8 +295,8 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
for i := range limit {
cmd := historyCache[i]
results = append(results, HistResult{
ID: idMapCache[cmd],
Cmd: cmd,
ID: idMapCache[cmd],
Cmd: cmd,
Source: sourceMapCache[cmd],
})
}
Expand Down Expand Up @@ -330,9 +332,8 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
addMatches := func(q string) {
qLow := strings.ToLower(q)

// extract pure substring matches (all words present) based strictly on recency order (historyCache is newest-first)
// this ensures that long commands with exact substrings are never truncated by the fuzzy searcher's limit
strictMatches := 0
prefixMatches := 0
substringMatches := 0
words := strings.Fields(qLow)
if len(words) == 0 {
words = []string{qLow}
Expand All @@ -344,6 +345,25 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
}

cmdLow := strings.ToLower(cmd)
if strings.HasPrefix(cmdLow, qLow) {
seenCmds[cmd] = true
results = append(results, HistResult{
ID: idMapCache[cmd],
Cmd: cmd,
FuzzyScore: 10000,
Source: sourceMapCache[cmd],
})
prefixMatches++
if prefixMatches >= 100 && substringMatches >= 200 {
break
}
continue
}

if substringMatches >= 200 {
continue
}

matchAll := true
for _, w := range words {
if !strings.Contains(cmdLow, w) {
Expand All @@ -363,10 +383,7 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
FuzzyScore: 10000,
Source: sourceMapCache[cmd],
})
strictMatches++
if strictMatches >= 200 {
break
}
substringMatches++
}

matches := searcherCache.SearchWithScores(q, &fuzzy.SearchOptions{Limit: 1000})
Expand Down Expand Up @@ -420,14 +437,13 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
return bestTier
}

tiers := make([]int, len(results))
for i, r := range results {
tiers[i] = getTier(r.Cmd, query)
for i := range results {
results[i].Tier = getTier(results[i].Cmd, query)
}

sort.SliceStable(results, func(i, j int) bool {
tI := tiers[i]
tJ := tiers[j]
tI := results[i].Tier
tJ := results[j].Tier
if tI != tJ {
return tI < tJ
}
Expand Down
45 changes: 45 additions & 0 deletions integration/history_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package integration

import (
"fmt"
"os"
"path/filepath"
"testing"
)

Expand Down Expand Up @@ -50,3 +53,45 @@ func TestRecordSessionCommand_MergeAndDeduplicate(t *testing.T) {
t.Errorf("expected results[2] to be 'git status', got %q", results[2].Cmd)
}
}

func TestSearchHistory_Prefix(t *testing.T) {
histFile := filepath.Join(t.TempDir(), "history")
_ = os.WriteFile(histFile, []byte(""), 0600)
t.Setenv("HISTFILE", histFile)

sessionHistoryMu.Lock()
origSessionHistory := sessionHistory
sessionHistory = nil
sessionHistoryMu.Unlock()

mu.Lock()
origHistoryCache := historyCache
historyCache = nil
mu.Unlock()

t.Cleanup(func() {
sessionHistoryMu.Lock()
sessionHistory = origSessionHistory
sessionHistoryMu.Unlock()

mu.Lock()
historyCache = origHistoryCache
mu.Unlock()
})

RecordSessionCommand("npx tailwindcss -i input.css")
for i := range 250 {
RecordSessionCommand(fmt.Sprintf("git commit -m 'change %d'", i))
}

resN, err := SearchHistory("n", nil)
if err != nil {
t.Fatal(err)
}
if len(resN) == 0 {
t.Fatal("expected results, got 0")
}
if resN[0].Cmd != "npx tailwindcss -i input.css" {
t.Fatalf("expected prefix match 'npx tailwindcss -i input.css' at index 0, got %q", resN[0].Cmd)
}
}
4 changes: 4 additions & 0 deletions integration/icons.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,11 @@ var iconMap = map[string]string{
"system": "",
"root": "",
"mas": "",
"prediction": "›",
}

const PredictionSymbol = "›"

func lookupIcon(key string) string {
key = strings.ToLower(strings.TrimSpace(key))
if icon, ok := iconMap[key]; ok {
Expand All @@ -140,3 +143,4 @@ func lookupIcon(key string) string {
}
return "❯"
}

Loading
Loading