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 +1 -1
- package/package.json +1 -1
- package/plugins/bash/index.bash +2 -2
- package/plugins/bash/keys.bash +15 -2
- package/plugins/bash/lookup.bash +54 -0
- package/plugins/zsh/index.zsh +2 -2
- package/plugins/zsh/lookup.zsh +84 -0
- package/plugins/zsh/menu.zsh +13 -1
- package/tai/index.py +17 -3
- package/tai/paths.py +55 -0
- package/tai/predictor.py +20 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.2.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terminal-ai-helper",
|
|
3
|
-
"version": "2.
|
|
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": {
|
package/plugins/bash/index.bash
CHANGED
|
@@ -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
|
package/plugins/bash/keys.bash
CHANGED
|
@@ -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,,}"* ]]
|
|
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,,}"* ]]
|
|
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=""
|
package/plugins/bash/lookup.bash
CHANGED
|
@@ -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() {
|
package/plugins/zsh/index.zsh
CHANGED
|
@@ -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
|
package/plugins/zsh/lookup.zsh
CHANGED
|
@@ -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,
|
package/plugins/zsh/menu.zsh
CHANGED
|
@@ -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
|
-
|
|
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
|