terminal-ai-helper 2.1.0 → 2.2.0

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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.1.0
1
+ 2.2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-ai-helper",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "The terminal that knows your next command Bash/Zsh suggestions that learn from your own history Offline and private with no account required",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,7 +24,7 @@ _TAI_INDEX_STAMP="${_TAI_INDEX_FILE}.stamp"
24
24
  # prints "value too great for base" on every keystroke instead of looking
25
25
  # anything up. A shell started before the first `tai refresh` still has to
26
26
  # be quiet and still gets the installed-command fallback.
27
- declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE
27
+ declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE
28
28
 
29
29
  # Is this file the index, or something that only looks like it?
30
30
  #
@@ -69,7 +69,7 @@ _tai_load_index() {
69
69
  # installed for the life of the shell — the same rule as plugins/tai.zsh, and
70
70
  # for the same reason: the file has to be the whole truth rather than a growing
71
71
  # superset of every index this shell has ever read.
72
- _TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=()
72
+ _TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=() _TAI_LIVE=()
73
73
  # The sorted-keys array is built on first use, not here: its one fork (a
74
74
  # `sort` over every first-word key) belonged to the shell's startup, which
75
75
  # is exactly the cost a plugin should not add — and most keystrokes never
@@ -201,7 +201,12 @@ _tai_complete() {
201
201
  for c in "${_TAI_LEARNED_LINES[@]}"; do
202
202
  # Folded, like the lookup that produced them: `ls down` offers
203
203
  # `ls Downloads`.
204
- [[ "${c,,}" == "${line,,}"* ]] && _tai_reply "$c"
204
+ [[ "${c,,}" == "${line,,}"* ]] || continue
205
+ # A whole learned line is committed whole, so every word in it that the
206
+ # index marked as a relative path has to resolve here — the same
207
+ # question the ghost asks about the line it hints.
208
+ _tai_line_here "$c" 0 || continue
209
+ _tai_reply "$c"
205
210
  done
206
211
  fi
207
212
  local -i names=0
@@ -211,7 +216,15 @@ _tai_complete() {
211
216
  fi
212
217
  local stem x
213
218
  while IFS= read -r c; do
214
- [[ -n "$c" && "${c,,}" == "${line,,}"* ]] && _tai_reply "${c##*[[:space:]]}"
219
+ [[ -n "$c" && "${c,,}" == "${line,,}"* ]] || continue
220
+ stem="${c##*[[:space:]]}"
221
+ # The word this completion offers is judged where the shell stands: a
222
+ # learned word that is unambiguously a relative path (the index marked it
223
+ # in _TAI_LIVE) was true where it was recorded and may exist nowhere else.
224
+ # Flags and bare words are never marked; the file and directory sources
225
+ # above read the disk itself.
226
+ _tai_here "$stem" || continue
227
+ _tai_reply "$stem"
215
228
  done <<< "$values"
216
229
  if (( dir_arg )); then
217
230
  local curw=""
@@ -161,6 +161,11 @@ _TAI_VALUES_ONE=0
161
161
  # replaces a walk over 2,600 lines with two or three tests.
162
162
  _tai_best() {
163
163
  local values="$1" prefix="$2" best="" best_score=-1 c s cl pl="${2,,}"
164
+ # The words tai adds are the words judged: the ghost writes the whole line it
165
+ # hints, so every marked word after what the user typed has to resolve here,
166
+ # or the hint is an error the user is offered.
167
+ _tai_typed_words "$prefix"
168
+ local typed=$_TAI_TYPED_WORDS
164
169
  # The extends-the-line and echo tests are case-insensitive — the lookup that
165
170
  # filled `values` is, and the two have to agree about what extends what.
166
171
  if [[ "${_TAI_VALUES_ONE:-0}" == "1" ]]; then
@@ -171,6 +176,7 @@ _tai_best() {
171
176
  # what it finds, and a byte that slips through is an escape sequence on
172
177
  # someone's terminal.
173
178
  [[ "$c" =~ [[:cntrl:]] ]] && continue
179
+ _tai_line_here "$c" "$typed" || continue
174
180
  best="$c"
175
181
  break
176
182
  done <<< "$values"
@@ -184,6 +190,7 @@ _tai_best() {
184
190
  # ghost text: the byte goes to the terminal as an escape sequence. The
185
191
  # store refuses at record time now, but this shell sources what it finds.
186
192
  [[ "$c" =~ [[:cntrl:]] ]] && continue
193
+ _tai_line_here "$c" "$typed" || continue
187
194
  s="${_TAI_SCORE[$c]:-0}"
188
195
  if (( s > best_score )); then best="$c"; best_score="$s"; fi
189
196
  done <<< "$values"
@@ -224,6 +231,53 @@ _tai_destination_live() {
224
231
  [[ -d "$t" ]]
225
232
  }
226
233
 
234
+ # The words the index marks in _TAI_LIVE are the learned words that are
235
+ # unambiguously *relative* filesystem paths — the one kind of word whose truth
236
+ # is a fact about the directory it is offered in. A flag is true everywhere and
237
+ # `~/.ollama` is true wherever this machine stands; `Data/INI` was true where it
238
+ # was recorded and may exist nowhere else, and offered from another directory it
239
+ # is the error the user reads the moment they take it. The shape test that
240
+ # decided what gets marked lives in tai/paths.py and is deliberately not
241
+ # re-implemented here — the shell only asks the one question left to it: does
242
+ # the word resolve where the user stands. One builtin stat, paid only on a word
243
+ # the index marked, on the paths that offer it.
244
+ _tai_here() {
245
+ [[ -n "${_TAI_LIVE[$1]:-}" ]] || return 0
246
+ [[ -e "$1" ]]
247
+ }
248
+
249
+ # The marked words *after* the $2 words already typed all resolve here. This is
250
+ # the question about a whole line — the ghost writes the rest of the line it
251
+ # hints and a first-word completion commits it — so the claim under test is not
252
+ # the next word but every word tai would add. The typed words are the user's
253
+ # own and get no opinion; a line that splits to fewer tokens than that adds
254
+ # nothing and passes. An empty _TAI_LIVE — an index that predates the map, or a
255
+ # history with no relative path in it — passes without a split at all.
256
+ _tai_line_here() {
257
+ local line="$1" n="$2" w
258
+ ((${#_TAI_LIVE[@]})) || return 0
259
+ local -a t=()
260
+ read -ra t <<< "$line"
261
+ for w in "${t[@]:n}"; do
262
+ _tai_here "$w" || return 1
263
+ done
264
+ return 0
265
+ }
266
+
267
+ # How many words of `$1` are complete: a trailing space finishes the word before
268
+ # it, so `ls ` has one and `ls Da` still has only the command. Answered in
269
+ # _TAI_TYPED_WORDS and not in the return status, because a pasted line can carry
270
+ # more words than a status can hold and a wrapped count is a wrong count.
271
+ _TAI_TYPED_WORDS=0
272
+ _tai_typed_words() {
273
+ local -a t=()
274
+ read -ra t <<< "$1"
275
+ local n=${#t[@]}
276
+ [[ "$1" == *' ' ]] || (( n -= 1 ))
277
+ (( n < 0 )) && n=0
278
+ _TAI_TYPED_WORDS=$n
279
+ }
280
+
227
281
  # Rank the line behind the wrapper and put the wrapper back. `sudo git ` completes
228
282
  # from the `git ...` the user has run.
229
283
  _tai_wrapped() {
@@ -15,7 +15,7 @@ _TAI_INDEX_STAMP="${_TAI_INDEX_FILE}.stamp"
15
15
  # Declare the maps even when the index file is missing, so a shell started before
16
16
  # the first `tai refresh` is quiet and still gets the installed-command
17
17
  # fallback.
18
- typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE
18
+ typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE
19
19
 
20
20
  # Is this file the index, or something that only looks like it?
21
21
  #
@@ -63,7 +63,7 @@ _tai_load_index() {
63
63
  # suggestion from the store, the next prompt re-read the index, and the stale
64
64
  # key was still there. Clearing first is what makes the file the whole truth
65
65
  # rather than a growing superset of every index this shell has ever seen.
66
- _TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=()
66
+ _TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=() _TAI_LIVE=()
67
67
  # A lookup may cache an answer about the lines it was asked from, so a reload
68
68
  # has to retire those answers: bumping the generation is what makes a cached
69
69
  # answer speak about the index that is installed now rather than the one that
@@ -64,6 +64,61 @@ _TAI_CTRL=$'[\x01-\x1f\x7f]'
64
64
  # shared by hundreds of commands, where the answer is a guess either way.
65
65
  typeset -gi _TAI_PREFIX_KEYS=64
66
66
 
67
+ # The words the index marks in _TAI_LIVE are the learned words that are
68
+ # unambiguously *relative* filesystem paths — the one kind of word whose truth
69
+ # is a fact about the directory it is offered in. A flag is true everywhere and
70
+ # `~/.ollama` is true wherever this machine stands; `Data/INI` was true where it
71
+ # was recorded and may exist nowhere else, and offered from another directory it
72
+ # is the error the user reads the moment they take it. The shape test that
73
+ # decided what gets marked lives in tai/paths.py and is deliberately not
74
+ # re-implemented here — the shell only asks the one question left to it: does
75
+ # the word resolve where the user stands. One builtin stat, paid only on a word
76
+ # the index marked, on the paths that offer it.
77
+ _tai_here() {
78
+ (( ${+_TAI_LIVE[$1]} )) || return 0
79
+ [[ -e "$1" ]]
80
+ }
81
+
82
+ # The marked words *after* the $2 words already typed all resolve here. This is
83
+ # the question about a whole line — the ghost writes the rest of the line it
84
+ # hints, a glimpse pick replaces the line, a first-word menu commits it — so the
85
+ # claim under test is not the next word but every word tai would add. The typed
86
+ # words are the user's own and get no opinion; a line that splits to fewer
87
+ # tokens than that adds nothing and passes. An empty _TAI_LIVE — an index that
88
+ # predates the map, or a history with no relative path in it — passes without a
89
+ # split at all.
90
+ _tai_line_here() {
91
+ local line="$1" w
92
+ integer n="$2"
93
+ (( ${#_TAI_LIVE} )) || return 0
94
+ local -a t
95
+ t=( ${=line} )
96
+ # zsh's own slice, not the bash-style `${t[@]:n}`: the colon there is a
97
+ # subscript flag the parser reads as a modifier and errors on.
98
+ for w in "${(@)t[n + 1, -1]}"; do
99
+ _tai_here "$w" || return 1
100
+ done
101
+ return 0
102
+ }
103
+
104
+ # How many words of `$1` are complete: a trailing space finishes the word before
105
+ # it, so `ls ` has one and `ls Da` still has only the command. The count the
106
+ # line question needs, so the words tai adds are exactly the words judged.
107
+ # Answered in _TAI_TYPED_WORDS and not in the return status, because a pasted
108
+ # line can carry more words than a status can hold and a wrapped count is a
109
+ # wrong count. The name is not the widgets' `_TAI_TYPED` — that one is the
110
+ # typing flag the arrow-down gate reads, and a lookup clobbering it on every
111
+ # keystroke was breaking the glimpse it opens.
112
+ _tai_typed_words() {
113
+ local -a t
114
+ t=( ${=1} )
115
+ integer n=${#t}
116
+ [[ "$1" == *' ' ]] || (( n -= 1 ))
117
+ (( n < 0 )) && n=0
118
+ _TAI_TYPED_WORDS=$n
119
+ }
120
+ typeset -gi _TAI_TYPED_WORDS=0
121
+
67
122
  # The candidate lines for a command name — the word before the first space — into
68
123
  # _TAI_LINES, newline-joined the way the index stores them.
69
124
  #
@@ -288,6 +343,19 @@ _tai_lines() {
288
343
  # _TAI_PREFIX_KEYS of them.
289
344
  _tai_best() {
290
345
  local prefix="$1" c line best="" best_score=-1 s
346
+ integer typed=0 leadw=0
347
+ # The words tai adds are the words judged: a candidate is hinted whole, so
348
+ # every marked word after what the user typed has to resolve here, or the
349
+ # hint is an error the user is offered. The lead carries the wrapper's words
350
+ # — typed by the user, absent from the stored line — so they come off the
351
+ # count before the test.
352
+ _tai_typed_words "$prefix"
353
+ typed=$_TAI_TYPED_WORDS
354
+ local -a lt
355
+ lt=( ${=_TAI_LINES_LEAD} )
356
+ leadw=${#lt}
357
+ (( typed -= leadw ))
358
+ (( typed < 0 )) && typed=0
291
359
  # The extends-the-line and echo tests are case-insensitive — the lookup that
292
360
  # filled _TAI_LINES is, and the two have to agree about what extends what.
293
361
  # `(#i)` needs extendedglob; localoptions hands this shell its own options
@@ -298,6 +366,7 @@ _tai_best() {
298
366
  for c in "${_TAI_LINES[@]}"; do
299
367
  line="$_TAI_LINES_LEAD$c"
300
368
  [[ -z "$c" || "$line" == (#i)"$prefix" ]] && continue
369
+ _tai_line_here "$c" "$typed" || continue
301
370
  best="$line"
302
371
  break
303
372
  done
@@ -305,6 +374,7 @@ _tai_best() {
305
374
  for c in "${_TAI_LINES[@]}"; do
306
375
  line="$_TAI_LINES_LEAD$c"
307
376
  [[ -z "$c" || "$line" == (#i)"$prefix" || "$line" != (#i)"$prefix"* ]] && continue
377
+ _tai_line_here "$c" "$typed" || continue
308
378
  s="${_TAI_SCORE[$c]:-0}"
309
379
  if (( s > best_score )); then best="$c"; best_score="$s"; fi
310
380
  done
@@ -457,6 +527,20 @@ _tai_query_do() {
457
527
  done
458
528
  _TAI_LOOSE_LINES=( "${live_l[@]}" )
459
529
  fi
530
+ # The same judgement for every other line the glimpse holds, because a pick
531
+ # here replaces the whole typed line: a relative path in a glimpse line was
532
+ # true where the line was recorded, and from this directory it can be the
533
+ # error the user is handed instead of a command. The cd lines above get the
534
+ # stricter destination test; these get the words test — the same rule, at
535
+ # the depth each line claims. The list is filtered fresh per query rather
536
+ # than inside the glimpse, whose cache outlives the directory.
537
+ local -a here_l
538
+ local gline
539
+ here_l=()
540
+ for gline in "${_TAI_LOOSE_LINES[@]}"; do
541
+ _tai_line_here "$gline" 0 && here_l+=( "$gline" )
542
+ done
543
+ _TAI_LOOSE_LINES=( "${here_l[@]}" )
460
544
  fi
461
545
 
462
546
  # A learned cd destination was judged for liveness where it was recorded,
@@ -271,6 +271,10 @@ _tai_menu_first_lines() {
271
271
  sorted=( "${(O)merged[@]}" )
272
272
  local m
273
273
  for m in "${(@)sorted[1,_TAI_MENU_FIRST_CAP]}"; do
274
+ # A whole line is committed whole, so every marked word in it is judged —
275
+ # the same question the ghost asks about the line it hints, answered the
276
+ # same way, so Tab and the grey text cannot disagree about what works here.
277
+ _tai_line_here "${m[10,-1]}" 0 || continue
274
278
  _TAI_MENU_LEARNED+=( "${m[10,-1]}" )
275
279
  done
276
280
  (( ${#_TAI_MENU_LEARNED[@]} ))
@@ -365,7 +369,15 @@ _tai_menu_open() {
365
369
  for c in "${_TAI_LINES[@]}"; do
366
370
  (( ${#c} > skip )) || continue
367
371
  rest="$_TAI_LINES_LEAD${c}"
368
- out+=( "${${rest[skip + 1, -1]}%% *}" ); outq+=( 0 )
372
+ c="${${rest[skip + 1, -1]}%% *}"
373
+ # The word the menu completes is judged where the menu stands: a learned
374
+ # word that is unambiguously a relative path (the index marked it in
375
+ # _TAI_LIVE) was true where it was recorded and may exist nowhere else,
376
+ # and Enter on it reads "no such file or directory". Flags and bare words
377
+ # are never marked; the file and glob sources below are the disk itself.
378
+ [[ -n "$c" ]] || continue
379
+ _tai_here "$c" || continue
380
+ out+=( "$c" ); outq+=( 0 )
369
381
  done
370
382
 
371
383
  # 1b. The first word has no `before` for the lookup above, and without this
package/tai/index.py CHANGED
@@ -11,6 +11,8 @@ Each snapshot stores:
11
11
  _TAI_WORD[word-prefix] newline-separated candidates
12
12
  _TAI_SEQ[previous] newline-separated candidates
13
13
  _TAI_FILE[line-without-its-last-word] 1, when that word is a file
14
+ _TAI_LIVE[word] 1, when the word is a relative path the shell
15
+ must find where it stands before offering it
14
16
 
15
17
  Commands are escaped as POSIX single-quoted strings, which zsh accepts.
16
18
  """
@@ -25,7 +27,7 @@ from pathlib import Path
25
27
  from tai.engine import (Engine, LEN_PENALTY_DIV, MAX_LEN_PEN, W_FREQ,
26
28
  W_RECENCY, W_SUCCESS, W_INSTALLED)
27
29
  from tai.knowledge import load_all as load_tool_knowledge
28
- from tai.paths import destinations_first, takes_file
30
+ from tai.paths import destinations_first, relative_word_set, takes_file
29
31
  from tai.seed import SEED_COMMANDS
30
32
  from tai.store import load_rows
31
33
  from tai.typo import shadow_map
@@ -445,13 +447,21 @@ def build(max_commands: int = 20000) -> int:
445
447
  if head:
446
448
  file_keys.add(head)
447
449
 
450
+ # The words the shells must ask the filesystem about before offering them:
451
+ # unambiguously relative paths, by the shape test paths.py already owns.
452
+ # Everything else is either not a path (flags, subcommands, bare words a
453
+ # `git` may mean), cwd-independent (`~`-paths and absolutes, judged at build
454
+ # by the stale set), or unjudgeable — and the module's rule is that an
455
+ # ambiguous token gets no opinion.
456
+ live = relative_word_set(cmds)
457
+
448
458
  zsh_file = index_path()
449
459
  zsh_file.parent.mkdir(parents=True, exist_ok=True)
450
460
  bash_file = bash_index_path()
451
461
  tmp = zsh_file.with_suffix(".tmp")
452
462
  with tmp.open("w", encoding="utf-8") as f:
453
463
  f.write("# tai embedded zsh index; generated, do not edit\n")
454
- f.write("typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE\n")
464
+ f.write("typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE\n")
455
465
  # `_TAI_X+=(key value)` — a pair appended to an associative array, one
456
466
  # builtin call per entry and no function call at all.
457
467
  #
@@ -473,12 +483,14 @@ def build(max_commands: int = 20000) -> int:
473
483
  f.write(f"_TAI_SEQ+=({_zq(key)} {_zq(chr(10).join(values))})\n")
474
484
  for key in sorted(file_keys):
475
485
  f.write(f"_TAI_FILE+=({_zq(key)} 1)\n")
486
+ for word in sorted(live):
487
+ f.write(f"_TAI_LIVE+=({_zq(word)} 1)\n")
476
488
  os.replace(tmp, zsh_file)
477
489
 
478
490
  bash_tmp = bash_file.with_suffix(".tmp")
479
491
  with bash_tmp.open("w", encoding="utf-8") as f:
480
492
  f.write("# tai embedded bash index; generated, do not edit\n")
481
- f.write("declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE\n")
493
+ f.write("declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE\n")
482
494
  for score, cmd in ranked:
483
495
  f.write(f"_TAI_SCORE[{_zq(cmd)}]={score}\n")
484
496
  for key, values in sorted(first.items()):
@@ -489,6 +501,8 @@ def build(max_commands: int = 20000) -> int:
489
501
  f.write(f"_TAI_SEQ[{_zq(key)}]={_zq(chr(10).join(values))}\n")
490
502
  for key in sorted(file_keys):
491
503
  f.write(f"_TAI_FILE[{_zq(key)}]=1\n")
504
+ for word in sorted(live):
505
+ f.write(f"_TAI_LIVE[{_zq(word)}]=1\n")
492
506
  os.replace(bash_tmp, bash_file)
493
507
  _compile_zsh_index()
494
508
  return len(cmds)
package/tai/paths.py CHANGED
@@ -459,3 +459,58 @@ def stale_in(engine, cwd: str = "") -> set[str]:
459
459
  return stale_commands(
460
460
  ((cmd, list(st.cwd)) for cmd, st in engine.cmds.items()),
461
461
  cwd)
462
+
463
+
464
+ def relative_word_set(cmds) -> frozenset[str]:
465
+ """The learned words that are unambiguously *relative* filesystem paths.
466
+
467
+ The one kind of word whose truth is a fact about the directory it is
468
+ offered in. A flag is true everywhere; `~/.ollama` and `/usr/bin` are true
469
+ wherever this machine stands, and were already judged at build; `Data/INI`
470
+ was true where it was recorded and may exist nowhere else — offered from
471
+ another directory it is the error the user is shown the moment they take
472
+ it. The shape test is this module's, unquoted first: reimplementing it in
473
+ two shells is how `git diff ~main` and `date 1/2/2024` come to be judged
474
+ as paths. The index writes the set out as _TAI_LIVE, and the shells ask
475
+ the filesystem the one question a marked word costs.
476
+ """
477
+ out: set[str] = set()
478
+ for cmd in cmds:
479
+ for token in (cmd or "").split():
480
+ token = _unquote(token)
481
+ if token.startswith("~") or token.startswith("/"):
482
+ continue
483
+ if is_path_like(token):
484
+ out.add(token)
485
+ return frozenset(out)
486
+
487
+
488
+ def alive_in(cmd: str, cwd: str = "") -> bool:
489
+ """True when every relative path in `cmd` resolves in `cwd`.
490
+
491
+ `command_verdict` above asks whether a command was ever true: a row
492
+ recorded in a directory that still holds the path survives. This asks the
493
+ other question, the one the user answers with Enter: is it true *here*,
494
+ in the directory the suggestion is being shown in? A `ls Data/INI`
495
+ recorded in the directory that has Data/INI is alive by the first answer
496
+ and dead by the second everywhere else, and it was the second answer the
497
+ report was about.
498
+
499
+ Every judgement is a stat against `cwd`, never a removal decision about a
500
+ stored row — this gates what is shown, and the recorded evidence above
501
+ stays the only thing that can retire a row. No directory, no opinion.
502
+ """
503
+ if not enabled():
504
+ return True
505
+ cwd = cwd or os.getcwd()
506
+ if not cmd or not os.path.isdir(cwd):
507
+ return True
508
+ for token in cmd.split():
509
+ token = _unquote(token)
510
+ if not is_path_like(token):
511
+ continue
512
+ if token.startswith("~") or os.path.isabs(token):
513
+ continue
514
+ if not os.path.exists(os.path.join(cwd, token)):
515
+ return False
516
+ return True
package/tai/predictor.py CHANGED
@@ -14,7 +14,7 @@ import time
14
14
 
15
15
  from tai.engine import WRAPPERS, Engine
16
16
  from tai.fresh import files
17
- from tai.paths import db_path, takes_file
17
+ from tai.paths import alive_in, db_path, takes_file
18
18
 
19
19
  _E: Engine | None = None
20
20
  _STALE: frozenset = frozenset()
@@ -95,6 +95,25 @@ def suggest(prefix: str = "", cwd: str = "", repo: str = "",
95
95
  res = _E.suggest(prefix=prefix or "", cwd=cwd, repo=repo, branch=branch,
96
96
  last_commands=last_commands or [], limit=limit, temp=temp,
97
97
  exclude=_STALE, on_path=_on_path)
98
+ # The stale set above never hides a relative path that was true where it
99
+ # was recorded — which is most of them, and that is right for a snapshot
100
+ # shown in every directory. This answer is shown in one directory, so its
101
+ # lines are judged there too: a line whose relative path does not resolve
102
+ # where the answer will be taken is not an answer, and Enter on it is the
103
+ # error the user reads. The same rule the index writes into _TAI_LIVE for
104
+ # the shells, from the same module, so the rooms cannot disagree about
105
+ # what works where the user stands.
106
+ _where = cwd or os.getcwd()
107
+ _all = res.get("choices", [])
108
+ _live = [c for c in _all if alive_in(c["cmd"], _where)]
109
+ if len(_live) != len(_all):
110
+ res["choices"] = _live
111
+ res["probabilities"] = {c["cmd"]: c["prob"] for c in _live}
112
+ _best = _live[0]["cmd"] if _live else ""
113
+ res["choice"] = _best
114
+ res["completion"] = (_best[len(prefix or ""):]
115
+ if _best.lower().startswith((prefix or "").lower())
116
+ else "")
98
117
  res = _file_answer(prefix or "", res)
99
118
  # The engine measures its own ranking; a caller that only cares about the
100
119
  # whole answer wants the process time, and a sub-millisecond rank has no